👻

【テスト配置の言語文化論】なぜPythonは `tests/` に分け、Rustは `src/` に同居させるのか?

に公開

「Pythonで単体テストを書くとき、実装ファイルの中にテストコードを書いてはいけないのですか?」

RustやGoといったモダンなコンパイル言語の経験があるエンジニアが、Pythonのプロジェクト構成を見たときに抱く素朴な疑問です。
Rustでは src/lib.rs の中に mod tests を書くのが当たり前ですし、Goでも main.go の隣に main_test.go を置くのが一般的です。

しかし、Pythonのデファクトスタンダードは src/ とは別に tests/ ディレクトリを切る」 というスタイルです。

なぜ言語によってテストコードの配置場所(文化)がこれほど異なるのでしょうか?
その背景には、単なる「好み」の違いではなく、「コンパイルの仕組み」「配布(デプロイ)の仕組み」 という技術的な必然性が隠されています。

本記事では、Rust, C++, Python という異なる性質を持つ3つの言語を比較しながら、それぞれのテスト配置戦略の理由を紐解いていきます。


1. Rust / Go:モダンなコンパイル言語の場合

「同居」が正義である理由

RustやGoでは、実装コードとテストコードを**物理的に同じ場所(または隣)**に置くことが推奨されています。

// Rustの例: src/lib.rs

// 実装コード
pub fn add(a: i32, b: i32) -> i32 {
    internal_helper(a, b)
}

fn internal_helper(a: i32, b: i32) -> i32 {
    a + b
}

// テストコード(同じファイル内にある)
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add() {
        // プライベート関数も直接テストできる!
        assert_eq!(internal_helper(1, 2), 3);
    }
}

理由①:コンパイル時の除外機能

Rustには #[cfg(test)] という属性があります。これは「テストビルドの時だけコンパイルし、リリースビルド(本番用バイナリ)には含めない」という指示です。
Goの _test.go も同様に、ビルドツールが本番ビルドから自動的に除外します。

つまり、ソースコード上で混ざっていても、ユーザーに届く成果物(バイナリ)にはテストコードが1バイトも混入しません。 配布サイズへの悪影響がゼロなのです。

理由②:ホワイトボックステストへのアクセス権

同じファイル(モジュール)内にテストがあるため、pub が付いていないプライベート関数にもテストコードからアクセスできます。
これにより、内部ロジックを細かく検証する単体テスト(Unit Test)が書きやすくなり、実装とテストを行き来するコンテキストスイッチも最小限で済みます。


2. C++:伝統的なコンパイル言語の場合

「分離」が必要だった理由

C++もコンパイル言語ですが、Rustとは異なり、実装ファイル(.cpp)とテストファイルは明確にディレクトリを分けるのが一般的です。

my_project/
├── src/
│   └── math.cpp
└── tests/
    └── test_math.cpp

理由①:ビルド時間の壁

C++はコンパイル(特にリンク)に時間がかかる言語です。
もし実装ファイルの中にテストコードを書いてしまうと、テストを一行修正しただけで、実装部分も含めたファイル全体の再コンパイルが走ります。
ファイルを物理的に分けておけば、「実装コードはコンパイル済みのオブジェクトファイル(.o)としてキャッシュし、テストコードだけをコンパイルしてリンクする」という効率化が可能になります。

理由②:配布物の純粋化

C++には歴史的に、Rustのような標準化された「テストコードの自動除外機能」がありませんでした(#ifdef マクロ等で擬似的には可能ですが、コードが汚れます)。
納品するバイナリや組み込み機器のファームウェアに不要なテストコードが混入するリスクを避けるため、物理的にファイルを分けて、ビルドターゲット(Makefile等)で管理するのが最も安全な策でした。


3. Python:インタプリタ言語の場合

なぜ tests/ に隔離するのか?

そして今回の主題であるPythonです。Pythonは以下のディレクトリ構成(src レイアウト)がベストプラクティスとされています。

my_project/
├── pyproject.toml
├── src/
│   └── mypackage/
│       └── app.py
└── tests/              <-- 完全に分離!
    └── test_app.py

なぜRustのように一緒に書かないのでしょうか?

理由①:ソースコード=配布物である

Pythonはインタプリタ言語であり、コンパイル(バイナリ化)の工程を経ずに実行されます。
pip install mypackage をしたユーザーの環境には、開発者が書いた .py ファイルがそのままコピーされます(厳密にはバイトコード .pyc もありますが)。

もし src/mypackage/app.py の中にテストコードを書いてしまうと、ユーザーの環境にまでテストコードがインストールされ、ロードされてしまいます。
Rustのように「リリース時に自動で消える」機能がないため、不要なコードによるディスク容量の圧迫や、インポート時のメモリ消費増大を防ぐために、物理的にディレクトリを分ける必要があります。

理由②:インポートパスの複雑化回避

Pythonのインポートシステムは、ファイルシステムの配置に強く依存します。
もし深い階層にある src/domain/models.py の隣に test_models.py を置いた場合、そこから上位階層の設定ファイルなどを読み込もうとすると、相対インポート(../../)が複雑になったり、sys.path の解決でトラブルになりがちです。

tests/ ディレクトリをプロジェクトルートに置くことで、「常に src をパッケージとして外側からインポートして使う」 という構図が固定され、パス解決がシンプルになります。

理由③:テストランナーの探索コスト

pytest などのツールは、指定されたディレクトリ以下のファイルを走査してテストを探します。
実装ディレクトリとテストが混ざっていると、本番コードも含めた全ファイルを走査する必要が出てきます。tests/ が独立していれば、「ここだけ探せば良い」と明示でき、実行速度や誤検出のリスク低減につながります。


まとめ:言語ごとのテスト配置戦略

言語 一般的な配置 主な理由・背景 テストの性質
Rust / Go 同居
(src/ 内)
・ビルド時にテストを完全除外できる
・プライベート関数のテストが容易
ホワイトボックス寄り
(内部詳細もテスト)
C++ 分離
(tests/ 等)
・コンパイル時間の短縮
・成果物への混入防止
ブラックボックス寄り
(APIを通してテスト)
Python 分離
(tests/ 等)
ソースコード=配布物 であるため
・インポートパス問題の回避
ブラックボックス寄り
(APIを通してテスト)

RustエンジニアがPythonを書くときのマインドセット

Rustから来たエンジニアにとって、プライベート関数を直接テストできないPythonの構成はもどかしく感じるかもしれません。
しかし、Python(やJava/C++)の文化では、「テストはパブリックインターフェース(公開API)を通して振る舞いを検証するもの」 という考え方が主流です。

「内部の実装詳細に依存せず、外から見た振る舞いをテストする」という制約は、リファクタリングのしやすさ(内部実装を変えてもテストが壊れない)を生むメリットもあります。

郷に入っては郷に従え。
Pythonを書くときは、ディレクトリを綺麗に分け、tests/ から優雅に src を呼び出す構成を楽しんでみてください。

Discussion