22:単体テストをする観点から実装の設計を洗練させる

単体テストの意味は何でしょうか? もちろんテスト対象の動作を保証することも大切ですが、「単体テストしやすいか?」という観点から実装の設計を洗練させることも大切です。 「テストしにくい実装は設計が悪い」という感覚を身につけましょう。

具体的な失敗

まずテスト対象になる、イマイチな設計の関数を見てみましょう。 この関数は sales.csv を読み込んで、合計の金額と、CSVファイルから読み込んだデータのリストを返します。

リスト 1.9 sales.py
import csv


def load_sales(sales_path='./sales.csv'):
    sales = []
    with open(sales_path, encoding="utf-8") as f:
        for sale in csv.DictReader(f):
            # 値の型変換
            try:
                sale['price'] = int(sale['price'])
                sale['amount'] = int(sale['amount'])
            except (ValueError, TypeError, KeyError):
                continue
            # 値のチェック
            if sale['price'] <= 0:
                continue
            if sale['amount'] <= 0:
                continue
            sales.append(sale)

    # 売上の計算
    sum_price = 0
    for sale in sales:
        sum_price += sale['amount'] * sale['price']
    return sum_price, sales

この関数をテストしようとすると、以下のようになります。

リスト 1.10 tests.py
class TestLoadSales:
    def test_invalid_row(self, tmpdir):
        test_file = tmpdir.join("test.csv")
        test_file.write("""id,item_id,price
1,1,100
2,1,100
""")
        sum_price, actual_sales = load_sales(test_file.strpath)
        assert sum_price == 0
        assert len(actual_sales) == 0

    def test_invalid_type_amount(self, tmpdir):
        # 解説: テストのたびにCSVファイルを毎度用意する必要がある

        test_file = tmpdir.join("test.csv")
        test_file.write("""id,item_id,price,amount
1,1,100,foobar
2,1,200,2
""")
        sum_price, actual_sales = load_sales(test_file.strpath)
        assert sum_price == 400
        assert len(actual_sales) == 1

    def test_invalid_type_price(self):
         ...

    def test_invalid_value_amount(self):
        ...

    def test_invalid_value_price(self):
        ...

    def test_sum(self):
        ...

load_sales 関数をテストするときは、毎度CSVファイルを用意する必要があり面倒です。 無効な行がある場合を確認するとき、値が無効なとき、価格が無効なときなど、個別の確認をするためにCSVファイルの用意が必要です。 小さな違いの確認のために、たくさんコードを書く必要があります。

ベストプラクティス

単体テストを通して、テスト対象コードの設計を見直しましょう。

  • 関数の引数や フィクスチャー に大げさな値が必要な設計にしない

  • 処理を分離して、すべての動作確認にすべてのデータが必要ないようにする

  • 関数やクラスを分離して、細かいテストは分離した関数、クラスを対象に行う (分離した関数を呼び出す関数では、細かいテストは書かないようにする)

元の処理も以下のように改善しました。

リスト 1.11 sales.py
import csv
from dataclasses import dataclass
from typing import List


# 解説: 売上(CSVの各行)を表すクラスに分離する
@dataclass
class Sale:
    id: int
    item_id: int
    price: int
    amount: int

    def validate(self):
       if sale['price'] <= 0:
           raise ValueError("Invalid sale.price")
       if sale['amount'] <= 0:
           raise ValueError("Invalid sale.amount")

   # 解説: 各売上の料金を計算する処理をSalesに実装
    @property
    def price(self):
        return self.amount * self.price


@dataclass
class Sales:
    data: List[Sale]

    @property
    def price(self):
        return sum(sale.price for sale in self.data)

    @classmethod
    def from_asset(cls, path="./sales.csv"):
        data = []
        with open(path, encoding="utf-8") as f:
            reader = csv.DictReader(f)
            for row in reader:
                try:
                    sale = Sale(**row)
                    sale.validate()
                except Exception:
                    # TODO: Logging
                    continue
                data.append(sale)
        return cls(data=data)

プログラムの行数は少し長くなりましたが、テストのしやすさ、 再利用性 、 可読性 が向上しています。 単体テストも、各クラス SaleSales ごとに細かく書けます。

import pytest


class TestSale:
    def test_validate_invalid_price(self):
        # 解説: 値の確認をするテストでCSVを用意する必要がなくなった
        sale = Sale(1, 1, 0, 2)
        with pytest.raises(ValueError):
            sale.validate()

    def test_validate_invalid_amount(self):
        sale = Sale(1, 1, 1000, 0)
        with pytest.raises(ValueError):
            sale.validate()

    def test_price(self):
        ...


class TestSales:
    def test_from_asset_invalid_row(self):
        ...

    def test_from_asset(self):
        ...

    def test_price(self):
        ...

cover

(中略)詳細は書籍 自走プログラマー をご参照ください