「この関数のテストコード、どう書けばいいんだ…?」 開発中にそう頭を抱えた経験はありませんか?多くの場合、その原因はテストの書き方の問題ではなく、テスト対象のコードの設計そのものにあります。
「テストのしにくさ」は、コードが抱える設計上の問題点を映し出す鏡です。一つの関数に責務が集中しすぎている、外部への依存が強すぎるなど、様々な「コードの匂い」を教えてくれます。
今回は、テストが困難な巨大関数を例に、テストのしやすさを追求することが、いかにしてクリーンで堅牢なクラス設計に繋がるかを、具体的なリファクタリングを通して見ていきましょう。
問題点:テストが困難な、巨大で多機能な関数
ある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)**に従っており、各クラスが独立してテスト可能であるため、非常に堅牢で保守性の高いものになりました。
まとめ
コードのテストが難しいと感じたときは、一度立ち止まって、そのコードの設計を疑ってみましょう。
- テストのしにくさは、責務が過剰であることのサイン。
- 「どうすればテストしやすくなるか?」という問いは、責務を分離し、関心を分離するための強力な羅針盤となる。
ユニットテストを書くという行為は、単なる品質保証活動ではありません。それは、コードの設計を健全な方向へと導くための、最も効果的なフィードバックループの一つなのです。