「厳密な」テスト駆動開発サンプルと現場でのテストの書き方紹介

こんにちは! クロスマートバックエンドエンジニアの小久保です!
突然ですが、みなさん、テスト書いてますか?

やろうやろうとは思ってるんだけど、時間もないし敬遠してる。。。!
そもそもあれって現場でほんとにやれんの?

なーんて思ってる方、朗報です!

今回は簡単なプログラムを「厳密な」テスト駆動開発を使って開発する様子を見せちゃいます!

ガチンコのテスト駆動開発を体験し、そのエッセンスを持ち帰っちゃってください!

また、最後に、現場ではどんな風にテストを書いてるの? みたいなお話もしちゃいますね。

奥深きTDD(Test Driven Development)の世界の第一歩をここから始めていきましょう!

「厳密な」テスト駆動開発を使って作るプログラム

いまからこんなプログラムを作ってみます。

世界のナベアツクラス」

  • 数字を入れたら数字を返す
  • 3の倍数のときはアホになる
  • 3がつく数字のときもアホになる

FizzBuzzみたいな簡単なプログラムですね。

もはやテストを書くまでもなく、作れちゃう方も多いのではないでしょうか?

それではまずはナベアツクラスを作り...

class Nabeatu:
    def __init__(self, num: int):

と行きたいところですがNGです!

今回は「厳密な」テスト駆動開発を体験していただくため、
神経質なまでに原則を守りつつ、テスト駆動開発をやっていきます!

テスト駆動開発のルール

テスト駆動開発の原則は下記です。

  • Red - 失敗するテストを書く
  • Green - 最速で雑にテストを通す
  • Refactor - テストOKを維持したままリファクタ

これだけです。このループをひたすら回しどんどん機能を作っていきます。

それではやってみます。

環境構築

下記にPython+pytestで実施できる環境を用意しました。

github.com

READMEを見ながら、環境を作成ください。

Red - 失敗するテストを書く

では早速。

テスト駆動開発は、文字通りテスト「駆動」のため、プロダクションコードを最初に書くことが許されていません。

そのため、マジで何の用意もなくいきなりテストから書きます。

それでは最初の仕様、数字を入れたら数字を返す、のテストを書いてみます。

# tests/test_nabeatu.py

import pytest


class TestNabeatu:
    def test_1(self):
        assert "1" == Nabeatu(1).call()

もちろん、これを実行すると下記のようにエラーになります。

% pytest -s -p no:warnings
...
========================================================================================================================== FAILURES ==========================================================================================================================
_____________________________________________________________________________________________________________________ TestNabeatu.test_1 _____________________________________________________________________________________________________________________

self = <tests.test_nabeatu.TestNabeatu object at 0x106c93c90>

    def test_1(self):
>       assert "1" == Nabeatu(1).call()
E       NameError: name 'Nabeatu' is not defined

tests/test_nabeatu.py:6: NameError
================================================================================================================== short test summary info ===================================================================================================================
FAILED tests/test_nabeatu.py::TestNabeatu::test_1 - NameError: name 'Nabeatu' is not defined

Nabeatuクラスなんてどこにもないですからね。

でもテストから書きます。意地でもプロダクションコード書いちゃいけません。

これで最初の「Red - 失敗するテストを書く」が完了です。

Green - 最速で雑にテストを通す

エラーになるテストが書けたので、お次はいよいよNabeatuクラスを書いていきます。

このとき、ロジック等は書かず、とんでもなくバカみたいなコードを書いて最速でテストを通します。

# app/nabeatu.py

class Nabeatu:
    def __init__(self, number):
        self.number = number

    def call(self):
        return "1"

うむ。これはバカみたいでとても良いです。カッコつける必要はなく、マジでこんなコードで構いません。

試しにテストを通してみると。。。

% pytest -s -p no:warnings
==================================================================================================================== test session starts =====================================================================================================================
platform darwin -- Python 3.11.3, pytest-8.0.0, pluggy-1.4.0
rootdir: /Users/yoheikokubo/work/fundamentalist_tdd
collected 1 item                                                                                                                                                                                                                                             

tests/test_nabeatu.py .

===================================================================================================================== 1 passed in 0.03s ======================================================================================================================

通りました!

Refactor - テストOKを維持したままリファクタ

TDDの1ループ目の最後は、リファクタリングです。

テストOKを維持しながら、より良いコードに変えていきます。

# app/nabeatu.py

class Nabeatu:
    def __init__(self, number):
        self.number = number

    def call(self):
        return str(self.number)

ツッコミどころはあると思います。

ですが、最初の仕様「数字を入れたら数字を返す」を満たすコードにはなっています。

念の為テストを通してみましょう!

% pytest -s -p no:warnings
==================================================================================================================== test session starts =====================================================================================================================
platform darwin -- Python 3.11.3, pytest-8.0.0, pluggy-1.4.0
rootdir: /Users/yoheikokubo/work/fundamentalist_tdd
collected 1 item                                                                                                                                                                                                                                             

tests/test_nabeatu.py .

===================================================================================================================== 1 passed in 0.03s ======================================================================================================================

OKですね!

このように、テストで仕様を壊していないことを確認しながら、どんどん機能を作っていきます。

ただ、準正常系等まったく考慮されていないのでちょっと不安です。

次のループでは「数字を入れたら数字を返す」の準正常系を考えてみます。

2週目 Red - 失敗するテストを書く

では次に、数字以外を入れたらどうなるかを定義します。

まずは失敗するテストを書いて仕様を定義します。

class TestNabeatu:
    def test_数字を入れたら数字の文字列を返す(self):
        assert "1" == Nabeatu(1).call()

    # test関数に日本語を入れっちゃっても良い
    def test_数字以外を入れたらValueErrorになる(self):
        with pytest.raises(ValueError) as e:
            Nabeatu("a").call()
        assert '数字を入れてください' == str(e.value)

数字以外でNabeatuを初期化しようとした場合、ValueErrorが出るようにします。

ついでにtest関数を整備して、仕様をわかりやすくしてみました。

テストを通してみましょう。

% pytest -s -p no:warnings
...
========================================================================================================================== FAILURES ==========================================================================================================================
__________________________________________________________________________________________________________ TestNabeatu.test_数字以外を入れたらValueErrorになる ___________________________________________________________________________________________________________

self = <tests.test_nabeatu.TestNabeatu object at 0x10292aad0>

    def test_数字以外を入れたらValueErrorになる(self):
>       with pytest.raises(ValueError) as e:
E       Failed: DID NOT RAISE <class 'ValueError'>

tests/test_nabeatu.py:11: Failed
================================================================================================================== short test summary info ===================================================================================================================
FAILED tests/test_nabeatu.py::TestNabeatu::test_数字以外を入れたらValueErrorになる - Failed: DID NOT RAISE <class 'ValueError'>
================================================================================================================ 1 failed, 1 passed in 0.03s =================================================================================================================

OK!

まだ実装していないのでテストはエラーになります。想定通りです。

このコントロール感がたまんないです。あえてエラーをおこしてんだぞと。

2週目 Green - 最速で雑にテストを通す

さて、楽しみな雑にテストを通す時間です。

なるべくバカみたいなコードになるよう心がけましょう。

class Nabeatu:
    def __init__(self, number):
        if number == "a":
            raise ValueError("数字を入れてください")
        self.number = number

いくらなんでも。と思うでしょうが、これで良いのです。

TDD成功の鍵は、いかに小さく変更を重ね積み上げていくことにかかっています。

この時点ではコードの正しさなんてどうでもよく、ただ前に進めていくことさえできれば構いません。

それではテストを通してみます。

今回は-vオプションをつけて、どんなテストがOKになったか表示してみます。

% pytest -s -p no:warnings -v
========================================================================================================================================================== test session starts ===========================================================================================================================================================
platform darwin -- Python 3.11.3, pytest-8.0.0, pluggy-1.4.0 -- /Users/yoheikokubo/.pyenv/versions/3.11.3/bin/python3.11
cachedir: .pytest_cache
rootdir: /Users/yoheikokubo/work/fundamentalist_tdd
collected 2 items                                                                                                                                                                                                                                                                                                                        

tests/test_nabeatu.py::TestNabeatu::test_数字を入れたら数字の文字列を返す PASSED
tests/test_nabeatu.py::TestNabeatu::test_数字以外を入れたらValueErrorになる PASSED

=========================================================================================================================================================== 2 passed in 0.01s ============================================================================================================================================================

OKですね。

2週目 Refactor - テストOKを維持したままリファクタ

では「数字以外を入れたらValueErrorになる」の実装をリファクタしていきます。

こんな感じでしょうか。

class Nabeatu:
    def __init__(self, number: int):
        # 静的解析ツールでチェックせい などとマウントをとってはいけません
        if type(number) != int:
            raise ValueError("数字を入れてください")
        self.number = number

テストを通してみます。

% pytest -s -p no:warnings -v
========================================================================================================================================================== test session starts ===========================================================================================================================================================
platform darwin -- Python 3.11.3, pytest-8.0.0, pluggy-1.4.0 -- /Users/yoheikokubo/.pyenv/versions/3.11.3/bin/python3.11
cachedir: .pytest_cache
rootdir: /Users/yoheikokubo/work/fundamentalist_tdd
collected 2 items                                                                                                                                                                                                                                                                                                                        

tests/test_nabeatu.py::TestNabeatu::test_数字を入れたら数字の文字列を返す PASSED
tests/test_nabeatu.py::TestNabeatu::test_数字以外を入れたらValueErrorになる PASSED

=========================================================================================================================================================== 2 passed in 0.01s ============================================================================================================================================================

良い感じですね。

このように、仕様にそってRed/Green/Refactorのループを回し、機能を完成させていきます。

大事なポイントまとめ

これまでを振り返ってどうでしょうか。

徐々にTDDの威力に気づき始めた方もいると思います。

ここで大事なポイントをまとめてみましょう。

テストから書くことでクライアントの使い勝手をはじめから意識できる

クラスを書いていて、いつの間にか「なんかこのクラス、使いにくいなあ」って思うことありませんか?

実装している間にどんどん意図がずれていき、使い勝手の悪いクラスになってしまった。。。

あるあるですよね。

TDDの場合、テストから書かなければいけないので、クライアントがどう使うかを「最初に」書かざるをえません。

class TestNabeatu:
    def test_数字を入れたら数字の文字列を返す(self):
        assert "1" == Nabeatu(1).call()

実装に入る前、テストを書いている段階で「なんか変じゃね。。。?」と気づくことができるので、完成後に「なにか違う」といったことが起こりにくくなります。

また、万が一大幅にリファクタリングが必要になっても、仕様がすべてテストコードに書いてあります。勇気を持ってリファクタリングすることができます。

1テスト1アサーションを心がけ ドキュメントとしてテストコードを書く

1テスト関数内に、色々なassertを書かないようにしましょう。

class TestNabeatu:
    def test_1(self):
        assert "1" == Nabeatu(1).call()
        assert '3(アホ)' == Nabeatu(3).call()
        with pytest.raises(ValueError) as e:
            Nabeatu('a').call()
        assert '数字を入れてください' == str(e.value)
        assert '13(アホ)' == Nabeatu(13).call()

このテストは、仕様を正しく表してはいます。

が、

  • テストエラーが出たときに、ぱっと見でどこでエラーが出ているかわかりにくい
  • 仕様を読み解くのに苦労する

という問題があります。

1テスト関数内では、1つだけassertを書くというルールにしましょう。

テストコードがまるでドキュメントとして機能するようになります。

テストコードを動くドキュメントのように書く

テスト関数にコメントを残したり、test関数自体を説明的にし、テストコードをドキュメントのように書くことを意識します。

下記はナベアツクラスを完成させた際のテスト実行結果です。

% pytest -s -p no:warnings -v
========================================================================================================================================================== test session starts ===========================================================================================================================================================
...                                                                                                                                                                                                                                                                                                                   

tests/test_nabeatu.py::TestNabeatu::test_1以上の数値かつ3の倍数でも3が付く数字でもない場合はそのまま文字列として返す PASSED
tests/test_nabeatu.py::TestNabeatu::test_3の倍数の場合アホになる PASSED
tests/test_nabeatu.py::TestNabeatu::test_3が付く数字の場合もアホになる PASSED
tests/test_nabeatu.py::TestNabeatu::test_数字以外を入れたらValueErrorになる PASSED
tests/test_nabeatu.py::TestNabeatu::test_0以下の値を入れたらValueErrorになる PASSED

=========================================================================================================================================================== 5 passed in 0.03s ============================================================================================================================================================

テストの実行結果およびテスト関数の名前を見るだけで、ナベアツクラスの仕様が丸わかりです。

もはや仕様を探してNotion内をうろついたり、実装チケットをさがしたりといったことをする必要がなくなります。(理論的には)(それができれば苦労はしねェ...!!!!)

続きを書いてみよう

それでは続きを書いてみてください。

ナベアツクラスの仕様を再掲。

  • 数字を入れたら数字を返す
  • 3の倍数のときはアホになる
  • 3がつく数字のときもアホになる

TDDのルールは下記だけです。

  • Red - 失敗するテストを書く
  • Green - 最速で雑にテストを通す
  • Refactor - テストOKを維持したままリファクタ

解答例は下記に書いておきました。

fundamentalist_tdd/tests/test_nabeatu.py at demo · yheihei/fundamentalist_tdd · GitHub

fundamentalist_tdd/app/nabeatu.py at demo · yheihei/fundamentalist_tdd · GitHub

TDDを駆使して、自分だけのナベアツクラスを作ってみてください!

最後に - 現場ではどんな風にテストを書いているの?

最後にクロスマートでどのようなテストを書いているか紹介いたします。

基本的には厳密なTDDを実施してはいません。

例えばこんなの。

class TestSupplierProductMasterV2ViewSet(TransactionTestCase):
    """
    商品マスタについてのエンドポイントのテスト
    """

    fixtures = [
        ...
    ]

    ...

    def test_list(self):
        """
        商品マスタ一覧のテスト
        """
        refresh = RefreshToken.for_user(User.objects.get(pk=2))

        with self.subTest("商品キーでGroupByし、idの大きいものを表示。その中でmodifiedの降順でレスポンスされること"):
            response = Client().get(
                reverse("supplier_order:supplier-order-product-master-v2-list"),
                content_type="application/json",
            )

            self.assertEqual(
                {
                    "count": 4,
                    "next": None,
                    "previous": None,
                    "results": [
                        {
                            "id": 3,
                            "created": "2023-08-03T08:21:10.288000+09:00",
                            "modified": "2023-10-03T08:21:10.288000+09:00",
                            "active": True,
                            ...
                        },
                    ],
                },
                response.json(),
            )

        with self.subTest("codeでのソートができること"):
            response = Client().get(
                reverse("supplier_order:supplier-order-product-master-v2-list"),
                {
                    "sort": "-code",
                },
                content_type="application/json",
            )
            self.assertEqual("3000", response.json()["results"][0]["code"])

        with self.subTest("nameでのソートができること"):
            response = Client().get(
                reverse("supplier_order:supplier-order-product-master-v2-list"),
                {
                    "sort": "name",
                },
                content_type="application/json",
            )
            self.assertEqual("A商品コードなしつまようじ", response.json()["results"][0]["name"])

APIのテストであれば、

  • ユースケースレベルで1assertを書き
  • 関数レベルでのテストも「必要であれば」書く

を意識しています。

厳密なTDDを実施する場合、Modelレベルでのテストから書くハメになるので、さすがにそれは。。。

ということで楽をしています。

しかし、「テストから書く」ということだけはぶらさずにやっています。

↑のコードであれば、一覧取得のAPIのど正常を

        with self.subTest("商品キーでGroupByし、idの大きいものを表示。その中でmodifiedの降順でレスポンスされること"):
            response = Client().get(
                reverse("supplier_order:supplier-order-product-master-v2-list"),
                content_type="application/json",
            )

で書き、これが実装できたら

        with self.subTest("codeでのソートができること"):
            response = Client().get(
                reverse("supplier_order:supplier-order-product-master-v2-list"),
                {
                    "sort": "-code",
                },
                content_type="application/json",
            )

次はソートのテストを書く -> エラーになる -> プロダクションコードを書く

というように、Red Green Refactorのループを素早く回して書いています。

こうすることで、カバレッジをほぼ100%にしつつ、定義した仕様を壊した場合は速攻でエラーになるように作ることができます。

実際、クロスマートのプロジェクトでは、テスト仕様に守られていることで、バックエンド起因のデグレーションが非常に少ないです。

また、慣れるとローカル環境を立ち上げることなく、テストだけで機能を実装することが可能です。

開発スピードも格段にあがっていきます。

まとめ

いかがだったでしょうか?

TDDはテストからコードを書いていき、下記のサイクルを高速で回していくことがキモです。

  • Red - 失敗するテストを書く
  • Green - 最速で雑にテストを通す
  • Refactor - テストOKを維持したままリファクタ

現場ではさすがに厳密なTDDを実践することはないものの、テストから仕様を記述していくことで、

  • デグレリスクの低減
  • 仕様を動くドキュメントにし、保守性をアップ
  • 開発スピードのアップ

を実現しています。

是非実践してみてくださいね!

クロスマートではエンジニアやBizDevなど広く一緒に働く仲間を募集中です。

組織も事業も急拡大していて、やること満載なので興味がある場合は是非こちらをご覧ください☺️

xorder.notion.site