🦤

テストコードを「動作するドキュメント」にする

2024/12/14に公開

テストコードを「動作するドキュメント」にする

webアプリケーションを開発していると、仕様書とコードがどんどん乖離していく経験というのは誰しも心当たりがあるのではないかと思います。Excelで仕様書なんかを書いていたりすると、コードを変更するたびにExcelの仕様書も変更する必要があるのです。

「すでにコードを書いたのに、さらにはExcelまで書き直さないといけないなんて!」

そんな中、こちらのTDDのライブコーディング動画を拝見したところ、テストコードは「動作するドキュメント」であるとありました。

正しく構造化されたテストコードは、それそのものが仕様書になるという考え方です。このテストコードの仕様書はまさに「動作」しますから、テストを実行することでコードが仕様書と乖離していないことが分かります。(テストがエラーしていたら乖離していると分かります)

もう二度と、コードと乖離するExcel仕様書に悩まされることはないのです!

実際に見てみましょう。
以下のFizzBuzzクラスを例にします。

class FizzBuzz:
    def convert(self, n):
        if not isinstance(n, int):
            raise ValueError("引数がint型ではありません。")
        if (n % 3 == 0) and (n % 5 == 0):
            return "FizzBuzz"
        elif n % 3 == 0:
            return "Fizz"
        elif n % 5 == 0:
            return "Buzz"
        else:
            return str(n)

これに対して、pytestを用いて以下のようにテストを書いてみます。

def test_3の倍数でFizzを返す():
    fizzbuzz = FizzBuzz()
    assert fizzbuzz.convert(3) == "Fizz"

def test_5の倍数でBuzzを返す():
    fizzbuzz = FizzBuzz()
    assert fizzbuzz.convert(5) == "Buzz"

def test_3の倍数かつ5の倍数の場合でFizzBuzzを返す():
    fizzbuzz = FizzBuzz()
    assert fizzbuzz.convert(15) == "FizzBuzz"

def test_それ以外の場合はnを返す():
    fizzbuzz = FizzBuzz()
    assert fizzbuzz.convert(7) == "7"

def test_引数がint型でない場合にValueErrorを発生させる():
    fizzbuzz = FizzBuzz()
    with pytest.raises(ValueError) as e:
        fizzbuzz.convert("a")
    assert str(e.value) == "引数がint型ではありません。"

このテストを実行すると以下の結果が出力されます。

tests/test_fizzbuzz.py::test_3の倍数でFizzを返す PASSED
tests/test_fizzbuzz.py::test_5の倍数でBuzzを返す PASSED
tests/test_fizzbuzz.py::test_3の倍数かつ5の倍数の場合でFizzBuzzを返す PASSED
tests/test_fizzbuzz.py::test_それ以外の場合はnを返す PASSED
tests/test_fizzbuzz.py::test_引数がint型でない場合にValueErrorを発生させる PASSED 

いかがでしょうか?
ただのテスト結果ではあるのですが、このテスト名を仕様としてみることでFizzBuzzクラスがどういう挙動をするのか確認できると思います。

テストコードをドキュメントにするテクニック

ただこのテスト結果をそのままドキュメントだ、仕様書だと言い張るのは若干無理がありそうです。
工夫をしてよりドキュメントっぽくしてみたいと思います。

クラスを使って構造化する

テストコードにクラスを使うことでテストを構造化することができます。
実際にFizzBuzzクラスのテストをクラスを使って構造化してみましょう。

class Test_FizzBuzzクラスの:

    class Test_convertメソッドは:

        def test_値が3の倍数の場合はFizzを返す(self):
            fizzbuzz = FizzBuzz()
            assert fizzbuzz.convert(3) == "Fizz"
    
        def test_値が5の倍数の場合はBuzzを返す(self):
            fizzbuzz = FizzBuzz()
            assert fizzbuzz.convert(5) == "Buzz"
    
        def test_値が3の倍数かつ5の倍数の場合はFizzBuzzを返す(self):
            fizzbuzz = FizzBuzz()
            assert fizzbuzz.convert(15) == "FizzBuzz"

        def test_それ以外の場合はnを返す(self):
            fizzbuzz = FizzBuzz()
            assert fizzbuzz.convert(7) == "7"

        def test_引数がint型でない場合はValueErrorを発生させる(self):
            fizzbuzz = FizzBuzz()
            with pytest.raises(ValueError) as e:
                fizzbuzz.convert("a")
            assert str(e.value) == "引数がint型ではありません。"

このように、クラスを使って「どのクラス」の「どのメソッド」に対してのテストであるかを構造的に理解しやすくしてみました。
これを実行すると以下のように結果が出力されます。

tests/test_fizzbuzz.py::Test_FizzBuzzクラスの::Test_convertメソッドは::test_3の倍数の場合はFizzを返す PASSED
tests/test_fizzbuzz.py::Test_FizzBuzzクラスの::Test_convertメソッドは::test_5の倍数の場合はBuzzを返す PASSED
tests/test_fizzbuzz.py::Test_FizzBuzzクラスの::Test_convertメソッドは::test_3の倍数かつ5の倍数の場合はFizzBuzzを返す PASSED
tests/test_fizzbuzz.py::Test_FizzBuzzクラスの::Test_convertメソッドは::test_それ以外の場合はnを返す PASSED
tests/test_fizzbuzz.py::Test_FizzBuzzクラスの::Test_convertメソッドは::test_引数がint型でない場合はValueErrorを発生させる PASSED

クラス名、関数名が::で結合されて結果となります。
テストクラスであることを示すTest_、テスト関数であることを示すtest_が邪魔ですが、先ほどよりは出力結果がドキュメントに近づいたのではないでしょうか。

接頭語をなくしてテスト名を完全に日本語にする

先ほどの結果では、テストクラスであることを示すTest_、テスト関数であることを示すtest_が邪魔でした。pytestでは、Test testのプレフィックスを持つクラス、関数・メソッドがテスト対象として認識されるため、デフォルトではどうしてもこうなってしまいます。

ただ、pytestの設定ファイルpytest.iniで、プレフィックスを好きなように変更することができます。

まずpytest.iniを作成します。

.
├── tests
├── src
└── pytest.ini

pytest.iniに以下のように記載してプレフィックスを指定します。
プレフィックスの間に半角スペースを挿入することで複数のプレフィックスを指定することもできます。
今回は「〜の」「〜は」となっているクラスと「〜場合〜」となっている関数・メソッドをテスト対象に指定しました。

pytest.ini
[pytest]
python_classes = **は 
python_functions = *場合* 

改めてテストを書いてみます。
今回のテストコードではTest testをクラス名、メソッド名の先頭に入れる必要がないため削除しています。

class FizzBuzzクラスの:

    class convertメソッドは:

        def 値が3の倍数の場合はFizzを返す(self):
            fizzbuzz = FizzBuzz()
            assert fizzbuzz.convert(3) == "Fizz"
    
        def 値が5の倍数の場合はBuzzを返す(self):
            fizzbuzz = FizzBuzz()
            assert fizzbuzz.convert(5) == "Buzz"
    
        def 値が3の倍数かつ5の倍数の場合はFizzBuzzを返す(self):
            fizzbuzz = FizzBuzz()
            assert fizzbuzz.convert(15) == "FizzBuzz"

        def それ以外の場合はnを返す(self):
            fizzbuzz = FizzBuzz()
            assert fizzbuzz.convert(7) == "7"

        def 引数がint型でない場合はValueErrorを発生させる(self):
            fizzbuzz = FizzBuzz()
            with pytest.raises(ValueError) as e:
                fizzbuzz.convert("a")
            assert str(e.value) == "引数がint型ではありません。"

テストを実行してみると、以下の結果が出力されます。

tests/test_fizzbuzz.py::FizzBuzzクラスの::convertメソッドは::値が3の倍数の場合はFizzを返す PASSED
tests/test_fizzbuzz.py::FizzBuzzクラスの::convertメソッドは::値が5の倍数の場合はBuzzを返す PASSED
tests/test_fizzbuzz.py::FizzBuzzクラスの::convertメソッドは::値が3の倍数かつ5の倍数の場合はFizzBuzzを返す PASSED
tests/test_fizzbuzz.py::FizzBuzzクラスの::convertメソッドは::それ以外の場合はnを返す PASSED
tests/test_fizzbuzz.py::FizzBuzzクラスの::convertメソッドは::引数がint型でない場合はValueErrorを発生させる PASSED

これによって、FizzBuzzクラスの::convertメソッドは::値が3の倍数の場合はFizzを返すと、テスト結果が日本語のドキュメントのように出力されました。


以上、テストコードを「動作するドキュメント」にしてみてはどうかという提案と、ドキュメントとするためのpytestにおけるテクニックでした。

参考

Discussion