テストコードを書くときに気をつけていること4選

バックエンドエンジニアの小久保(@yhei_hei)です。

今回はテストコード大好きエンジニアの僕が、
テストコードを書く時に気をつけていることを、
サンプルコードを交えながら紹介します。

サンプルコードのフレームワークDjangoです。

が、Djangoを知らない方でも雰囲気で読めると思います。

是非読んでいっていただけると幸いです!

ドキュメントだと思って書く

テストコードを見ていると、こんなことありませんか?

  • 何をしたいのかわからん
  • エラーって出てるけどなんでこの期待値になるかわからん
  • 何もわからん。キレそう

あるあるですよね。

例を出してみましょう。

下記のコードは、架空日記アプリのテストコードです。

日記投稿APIのテストが書かれています。

from diary.models import Post
from django.test import Client, TestCase


class TestApiPosts(TestCase):
    fixtures = ["fixtures/post.json"]

    def test_1(self):
        response = Client().post(
            "/api/posts/",
            data={
                "title": "test",
                "body": "test",
            },
            content_type="application/json",
        )
        self.assertEquals(401, response.status_code)

日記投稿APIのエンドポイント/api/posts/に向けてPOSTリクエストし、
日記の新規登録を行うようです。
しかし、ステータスコードの期待値は401です。

推測するに、何か認証めいたものがないと/api/posts/が叩けない仕様のようです。
推測はできますが。。。ちょっと気持ち悪いですよね。なんの認証?

なので、このテストコードを「ドキュメントだと思って」読みやすくしてみます。

class TestApiCreatePosts(TestCase):
    """
    日記投稿のエンドポイントのテスト
    """
...
    def test_jwt_unauthorized_error(self):
        """
        JWT認証のトークンがない場合は401を返すこと
        """
        unauthorized_response = Client().post(
            "/api/posts/",
            data={
                "title": "test",
                "body": "test",
            },
            content_type="application/json",
        )
        self.assertEquals(401, unauthorized_response.status_code)

意識するのは3点。

  • テストの関数名やクラス名から何をテストするかが分かるようにする
  • コメントを書く。仕様を書くようなイメージ。「XXしたら〇〇すること」
  • 変数も仕様を表現する命名にする

これらを意識すると、test関数を流し見するだけでAPIの仕様が分かるようになります。
まさに動く仕様書としてテストコードが機能するってわけです。

1テスト1assertion(できるだけ)

テスト界隈で有名なアンチパターンとして、
Assertion Roulette(アサーションルーレット)なるものがあります。
下記で例を挙げてみます。

    def test_various(self):
        response = Client().post(
            "/api/posts/",
            data={
                "title": "test",
                "body": "test",
            },
            content_type="application/json",
        )
        self.assertEquals(401, response.status_code)
        refresh = RefreshToken.for_user(User.objects.get(id=1))
        response = Client().post(
            "/api/posts/",
            data={
                "title": "test",
                "body": "test",
            },
            content_type="application/json",
            HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
        )
        self.assertEqual(True, Post.objects.filter(title="test", body="test").exists())
        self.assertEquals(201, response.status_code)
        response...ここから3回ぐらい似たようなテストが繰り返される

このコードを最後まできちんと読んだ方はいないと思います。それでいいです。

妙にassertの数が多いですよね。。。

このテスト関数では

  • JWT認証のトークンがない場合に401になること
  • トークンがある場合は日記が指定した内容で登録できること
  • 書いてないけど多分この後titleがないときのテストとかも続きます

などを確認しています。

仮にこのtest関数でエラーが出た場合、どういった目にあうでしょうか。
エラーの行数を見て、「え〜っとここかな。このエラーは。。。なんのテストだっけ??」
などと、エラー箇所を探すのに手間取ります。

assertが多くてどこで落ちたかが分かりづらいんですよね。。。

このような状態を「Assertion Roulette」と呼びます。
名前はおしゃれですが、テストの可読性をいちじるしく下げるのでやめましょう。

僕がTDDBC(テスト駆動開発ブートキャンプ)に参加した際は、
1テスト1assertionを推奨されました。
実開発では1つのassertですませられることは少ないため、下記のことを徹底するようにしています。

  • 1つのtest関数内で複数のテストをやらない
  • assertは必要最低限の個数にする

エラー箇所がすぐに分かる上、読みやすくなります。

他のテストに依存するテストを書かない

他のテストに依存するテストとは、例えば下記のようなコードです。

    def test_create_and_update(self):
        # 新規登録のエンドポイントのテスト
        refresh = RefreshToken.for_user(User.objects.get(id=1))
        response = Client().post(
            "/api/posts/",
            data={
                "title": "test",
                "body": "test",
            },
            content_type="application/json",
            HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
        )
        self.assertEqual(True, Post.objects.filter(title="test", body="test").exists())
        self.assertEquals(201, response.status_code)

        # 更新のエンドポイントのテスト
        # ★↓ここが新規登録のロジックに依存する
        created_post = Post.objects.get(title="test", body="test")
        response = Client().put(
            f"/api/posts/{created_post.pk}",
            data={
                "title": "modified",
                "body": "modified",
            },
            content_type="application/json",
            HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
        )
        updated_post = Post.objects.get(pk=created_post.pk)
        self.assertEqual("modified", updated_post.title)
        self.assertEqual("modified", updated_post.body)
        self.assertEquals(200, response.status_code)

このテストコードは、新規登録のテストを行った後、
そのテストで作った日記データを使って更新のテストを行っています。
単純な日記みたいなAPIの場合は問題ないように見えます。

しかし日記登録とは違ったもっと複雑なドメインの登録処理だったらどうでしょう。
万一、新規登録処理がバグっておりassertでそれを拾えなかった場合。
後続のテストが謎のエラーで落ちる可能性があります。
テストが別のテストに依存する形になっており、エラー原因の特定を困難にします。

Assertion Rouletteと似ていますが、あくまで1テスト1概念のテストにとどめることで、
別のロジックがテストの成否に関わることを防ぎます。

この場合、下記のようにtest関数を分けると良いでしょう。

class TestApiPosts(TestCase):
    fixtures = [
        "fixtures/user.json",
        "fixtures/post.json",  # 予め更新用に使うデータを入れておく
    ]

    def test_create(self):
        # 新規登録のエンドポイントのテスト
        refresh = RefreshToken.for_user(User.objects.get(id=1))
        response = Client().post(
            "/api/posts/",
            data={
                "title": "test",
                "body": "test",
            },
            content_type="application/json",
            HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
        )
        self.assertEqual(True, Post.objects.filter(title="test", body="test").exists())
        self.assertEquals(201, response.status_code)

    def test_update(self):
        # 更新のエンドポイントのテスト
        refresh = RefreshToken.for_user(User.objects.get(id=1))
        response = Client().put(
            "/api/posts/1",  # 1は予めfixturesで作成されている日記ID
            data={
                "title": "modified",
                "body": "modified",
            },
            content_type="application/json",
            HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
        )
        updated_post = Post.objects.get(pk=1)
        self.assertEqual("modified", updated_post.title)
        self.assertEqual("modified", updated_post.body)
        self.assertEquals(200, response.status_code)

※ ただし、E2E的な一連のシナリオをなぞるようなテストを書きたい場合は、↑の限りではありません(TDDBCでも言ってた)

また、test関数を分けていても、下記のようになる場合はもちろんNGです。

def test_1(self):
  # なんらかのテスト
  # ここでtest_2の準備をする

def test_2(self):
  # test_1のデータ(ファイルとか設定とか)を使ってテスト

これはtest_1のロジックがtest_2に影響を与えるって理由もありますが、
テストランナーの仕様によっては、test関数がランダム順で実行されるからです。
PHPUnitJUnitなんかだと、test関数はランダム順で実行されます。
すなわち、↑のようなことをするとテストが通ったり通らなかったりし、発狂します。

あえてDRY(Don’t Repeat Your Self)原則を無視して愚直に書く

テストコードを書いていると、似たような処理をたくさん書くことになります。
すると「共通処理は関数化するか」と、DRY原則に従いたくなるのがエンジニアの性ですよね。

しかし、テストコードはドキュメントのようなものです。

なーんも考えずに上から下へ読み飛ばすだけで仕様が分かるようにする。これが原則だと思っています。

したがって、僕はテストコードにはDRY目的の関数を一切書きません。

余分な認知負荷がかかるからです。

視線の上下動も起こり、めちゃくちゃ読みにくくなります。

ドキュメントとして読んでいたところを、急にプログラミングの世界に戻されたようなイメージ。

これについては、ソニックガーデンの伊藤さんが大変わかりやすい記事を書いています。

blog.jnito.com

ぜひご確認ください!

まとめ

テストコードを書くときに気をつけていること、それは

  • ドキュメントだと思って書く
  • 1テスト1assertion(できるだけ)
  • 他のテストに依存するテストを書かない
  • あえてDRY原則を無視して愚直に書く

でした!

とにかくドキュメントだと思って可読性よく書く。
そこに尽きるお話でしたね。

今日から使えるテクニックだと思うので、皆さんもぜひ試してみてください!

クロスマートが気になってきた方はこちら

xorder.notion.site