【Python】「テストしにくいコード」は設計が悪い証拠:テスト駆動で設計を洗練させる方法

「この関数のテストコード、どう書けばいいんだ…?」 開発中にそう頭を抱えた経験はありませんか?多くの場合、その原因はテストの書き方の問題ではなく、テスト対象のコードの設計そのものにあります。

「テストのしにくさ」は、コードが抱える設計上の問題点を映し出す鏡です。一つの関数に責務が集中しすぎている、外部への依存が強すぎるなど、様々な「コードの匂い」を教えてくれます。

今回は、テストが困難な巨大関数を例に、テストのしやすさを追求することが、いかにしてクリーンで堅牢なクラス設計に繋がるかを、具体的なリファクタリングを通して見ていきましょう。

目次

問題点:テストが困難な、巨大で多機能な関数

あるCSVファイルから学生の成績データを読み込み、無効なデータを除外しつつ、全体の平均点を算出する関数を考えてみましょう。

悪い例:すべてのロジックが一つの関数に詰め込まれている

# reports/analyzer.py
import csv

def process_grade_report(filepath: str) -> tuple[float, list]:
    """成績CSVを読み込み、有効なデータを集計して平均点とリストを返す。"""
    valid_grades = []
    with open(filepath, "r", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            # 1. パースと型変換
            try:
                row["student_id"] = int(row["student_id"])
                row["score"] = int(row["score"])
            except (ValueError, KeyError):
                continue # 型変換できない行はスキップ

            # 2. バリデーション
            if not (0 <= row["score"] <= 100):
                continue # 0点未満、100点超の行はスキップ
            
            valid_grades.append(row)

    # 3. 集計
    if not valid_grades:
        return 0.0, []
        
    total_score = sum(g["score"] for g in valid_grades)
    average = total_score / len(valid_grades)
    return average, valid_grades

このprocess_grade_report関数は、ファイルI/O、データパース、バリデーション、集計という、少なくとも4つの異なる責務を抱えています。この関数をテストしようとすると、次のような困難に直面します。

  • テストの準備が煩雑: 「スコアがマイナス点」という一つの異常系をテストしたいだけなのに、毎回CSVファイルをテストコード内で作成する必要があります。
  • 関心の分離ができていない: バリデーションロジックのテストと、平均点の計算ロジックのテストを独立して行うことができません。
  • テストが遅くなる: ファイルI/Oを伴うため、テストの実行速度が遅くなります。

解決策:テストのしやすさを道しるべに、責務をクラスに分割する

この困難を解消する鍵は、「どうすれば、もっと楽にテストできるか?」と自問し、コードをより小さな、独立した単位に分割していくことです。

ステップ1:単一のデータ構造を表現するクラスを作る

まず、責務の最小単位である「一人の学生の一つの成績」を表現するGradeクラスを定義します。そして、それに関するバリデーションロジックをクラスの責務として持たせます。

reports/models.py

from dataclasses import dataclass

@dataclass
class Grade:
    student_id: int
    score: int
        
    def validate(self):
        """この成績データが有効であるかを検証する。"""
        if not (0 <= self.score <= 100):
            raise ValueError("スコアは0から100の間でなければなりません。")

Gradeクラスのテスト (tests/test_models.py): これで、バリデーションロジックをファイルI/Oから完全に切り離し、メモリ上で高速にテストできるようになりました。

import pytest
from reports.models import Grade

def test_grade_validation_raises_error_when_score_is_negative():
    invalid_grade = Grade(student_id=1, score=-10)
    with pytest.raises(ValueError):
        invalid_grade.validate()

ステップ2:データの集合を扱うクラスを作る

次に、「成績データの集合(レポート)」を表現するGradeReportクラスを定義します。CSVファイルからデータを読み込む責務や、全体の平均点を計算する責務をこのクラスに移動させます。

reports/models.py

import csv
from dataclasses import dataclass
from typing import Iterator, List

# (Gradeクラスの定義は省略)

@dataclass
class GradeReport:
    grades: List[Grade]
        
    @property
    def average_score(self) -> float:
        """レポート全体の平均点を計算する。"""
        if not self.grades:
            return 0.0
        total_score = sum(g.score for g in self.grades)
        return total_score / len(self.grades)
        
    @classmethod
    def from_csv(cls, filepath: str) -> "GradeReport":
        """CSVファイルから有効な成績データを読み込み、インスタンスを生成する。"""
        valid_grades = []
        with open(filepath, "r", encoding="utf-8") as f:
            for row in csv.DictReader(f):
                try:
                    grade = Grade(
                        student_id=int(row["student_id"]),
                        score=int(row["score"])
                    )
                    grade.validate() # ここで単一データのバリデーションを呼び出す
                    valid_grades.append(grade)
                except (ValueError, KeyError):
                    continue # 無効な行はスキップ
        return cls(grades=valid_grades)

GradeReportクラスのテスト (tests/test_models.py): 平均点の計算ロジックも、ファイルI/Oとは無関係に、Gradeオブジェクトのリストを直接渡すことでテストできます。

def test_grade_report_average_score():
    report = GradeReport(grades=[
        Grade(student_id=1, score=80),
        Grade(student_id=2, score=90),
        Grade(student_id=3, score=100),
    ])
    assert report.average_score == 90.0

結果:テスト容易性の高い、クリーンな設計

リファクタリングの結果、巨大な関数は、それぞれが単一の責務を持つ2つのクラスに分割されました。

  • Gradeクラス: 一つの成績データの構造と、それ自体の正当性を保証する責務を持つ。
  • GradeReportクラス: Gradeの集合を管理し、ファイルからの読み込みや、集合全体に対する集計処理の責務を持つ。

この設計は、**単一責任の原則(Single Responsibility Principle)**に従っており、各クラスが独立してテスト可能であるため、非常に堅牢で保守性の高いものになりました。

まとめ

コードのテストが難しいと感じたときは、一度立ち止まって、そのコードの設計を疑ってみましょう。

  • テストのしにくさは、責務が過剰であることのサイン。
  • 「どうすればテストしやすくなるか?」という問いは、責務を分離し、関心を分離するための強力な羅針盤となる。

ユニットテストを書くという行為は、単なる品質保証活動ではありません。それは、コードの設計を健全な方向へと導くための、最も効果的なフィードバックループの一つなのです。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

私が勉強したこと、実践したこと、してることを書いているブログです。
主に資産運用について書いていたのですが、
最近はプログラミングに興味があるので、今はそればっかりです。

目次