💯

私はこれでテストが書きやすくなりました

2024/06/21に公開

はじめに

テストの書き方を学んだことが一度もなかった私は、以下のパターン化をすることで

  • どのコードに対して
  • どれだけのテストを
  • どのように書くか

を意識し、テストが書きやすくなりました。
自分の書くテストに自信が持てなかったり、既存のコードが何を根拠にテストされているのか分からないような初学者のメンタルモデル構築に役立つことを願っています。
実装にはPythonとpytestを利用しているため、スコープの有無など一部言語機能に差があります。

テスト設計

テストすべき機能の特性

どのコード(クラスや関数)に対してテストコードを書くか優先順位を付けます。
カバレッジ100%を目指して全てのコードにテストを書くのは大変かつ、テストコード自体のメンテ性も損なうので以下のコードの特性を検討指標にします。
Recent + Core + Riskなど複数の特性を持ち合わせているものから、重要度高く優先度付けてテストを作ります。

  • Recent
    新しい機能、コードの新しい部分、および修復、リファクタリング、変更を最近
    行った機能
  • Core
    製品の USP(Unique Selling Proposition)。つまり、それらが動作していないと製品の有効性が損なわれるような機能
  • Risk
    アプリケーションにおいてリスクの大きい部分。たとえば、顧客にとって重要だが開発チームがあまり使わない部分や、あまり信頼できないサードパーティのコードを使っている部分
  • Problematic
    誤動作が多い機能、または不具合がよく報告される機能
  • Expertise
    限られた人だけが理解している機能またはアルゴリズム

ハッピーパスを起点にテストケースを作成する

どのコードに対してテストを書くかが決まったら、どれだけのテストケース(入力パターン)を作るかを決定します。
テストケースはたくさん作れば良いというものではないので、MVPの作成基準を意識します。

  1. 最初に、自明ではない「ハッピーパス」テストケースを作成する(「ハッピーパス」とは、ユーザーがたどる(ユーザーにたどってほしい)一番シンプルで簡単な道筋のこと)
  2. 次に、以下の要素に対するテストケースを調べる
    1. 興味深い入力
    2. 興味深い開始状態
    3. 興味深い終了状態
    4. エラー状態として考えられるものすべて

最初のテストケースを正しく動くエッジケースにすることでテストケースの数を減らしつつ、インパクトが高い部分をカバーすることがキモです。
その後は処理の中でエラーハンドリングをしている箇所を網羅的にテストしていくのが良いです。

テスト関数を構造化する

Given-When-Thenパターン

テストコードも単一責任の原則に従って1つのテスト関数で1つの振る舞いを検証することが望ましいです。
その上でテストは、Given-When-Then(前提-もし-ならば)パターンなど3ステージに分けることを目標とします。
具体的にはテストの作成を以下3つのセクションに分割します。

  • Given: テストの前提条件を書く
  • When: テストしたい動作を指定して実行する
  • Then: 指定された動作によって予想される変更について検証する

個人的にはGiven-When-Thenを関数作った最初にコメントするようにしてから明らかにテストが書きやすくなりました。

from cards import Card


def test_to_dict():
    # Given:既知の値が設定された Card オブジェクトが与えられたとすれば
    c1 = Card("something", "brian", "todo", 123)
    
    # When:このオブジェクトで to dict() を呼び出したときに
    c2 = c1.to dict()
    
    # Then:既知の値が設定されたディクショナリが返される
    c2 expected = {
        "summary": "something",
        "owner": "brian",
        "state": "todo",
        "id": 123,
    }
    assert c2 == c2 expected

アンチパターン

Given-When-Thenパターンと対象にアクションとそれに続く状態や振る舞いのチェックを繰り返すことでワークフローを検証するパターンはアンチパターンとされています。
理由はエラーの原因が切り分けれず、テストの対象を1つの振る舞いに絞り込めないことです。
一つの統合テストで全てを包括的にテストするのではなく、単体テストと統合テストをうまく分割することで対処することができます。

テストが書きやすい設計・実装を心がける

当然ですが、テストの書きやすさはコード側の設計・実装にも大きく依存するので、テストが書きやすいコードを書くことも大事です。

ソフトウェア品質モデルのSQuaREでは、試験性(テスタビリティ)は内部品質である保守性の一要素であると言及されています。
つまり、テストが書きやすいコードはソフトウェアの品質を高めるので、アーキテクチャ特性のトレードオフに見合うラインまで書きやすくする方がよいでしょう。

テストが書きやすいコードとは以下のようなものだと考えています。

  • 単体テストがしやすい
  • SOLID原則の「単一責任の原則」が適用されている
  • 依存関係にあるモジュールがコンストラクタや関数の引数として受け渡されている
  • 欲を言えば、SOLID原則の「依存性逆転の原則」が適用されている

価値ある機能の最小単位でテストできることが最も好ましいので、テストが書きやすいコードは単体テストがしやすいコードであり、そのために各クラス・関数の責務が小さいことや依存関係のモック容易性があることが好ましいと考えました。

実際にはこれらを考慮しつつ、どういったテストを書くことになるか想定しながら設計・実装を行うことが最もテストの書きやすさに直結すると思います。
また、上記のマインドは実装によるフィードバックからちょうど良い設計を目指すというテスト駆動開発の利点を副次的に持っている点も魅力的なので実践を心がけたいです。

フィードバックが回って、より良い設計・よりシンプルな設計に気づきやすくなることがテスト駆動開発の大事なところなんですよね。
...
テスト設計・インターフェースの設計は若干理想から入っていって理想一辺倒になっちゃうとオーバーエンジンリアリングになっちゃう。実装の方はどちらかというと現実解の世界からやってきて、現実解一辺倒だとアンダーエンジニアリングになってしまうので、どうやって理想から引っ張りつつ現実からのフィードバックを効かせてちょうど良い落とし所というのに近づけ続けるかが大事
fukabori.fmの『テスト駆動開発とは何であって、何でなかったのか?』より

その他:良いテストを書くためのpytestテクニック

fixtureでsetup / teardownを使う

testのためのsetupや終了後のcleanupをfixture関数にyieldを用いることで実現できます。
yield-fixtureはテストが何かの不具合で失敗しても必ずteardownが動いてくれる点でゴミが残らず安全です。
この例では、 test_email_received の実行前にsending_user, receiving_userがsetupで実行されます。その後テスト関数を抜ける際に終了処理としてメールボックスのクリアやユーザーの削除処理が行われるという流れです。

# content of test_emaillib.py
from emaillib import Email, MailAdminClient

import pytest


@pytest.fixture
def mail_admin():
    return MailAdminClient()

@pytest.fixture
def sending_user(mail_admin):
    # setup
    user = mail_admin.create_user()
    
    # do
    yield user
    
    # teardown
    mail_admin.delete_user(user)

@pytest.fixture
def receiving_user(mail_admin):
    # setup
    user = mail_admin.create_user()
    
    # do
    yield user
    
    # teardown
    user.clear_mailbox()
    mail_admin.delete_user(user)

def test_email_received(sending_user, receiving_user):
    email = Email(subject="Hey!", body="How's it going?")
    sending_user.send_email(email, receiving_user)
    assert email in receiving_user.inbox

parametrizeで純粋関数に寄せてメンテナビリティを高める

parametrizeを使うと入力と想定出力のテストデータを関数の外に出すことができます。
これによってテスト関数を純粋関数に寄せることができるのと同時に、失敗時でも独立したテストケースとしてハンドリングすることができます。

import pytest
from cards import Card


@pytest.mark.parametrize(
    "start summary, start state", [
        ("write a book", "done"),
        ("second edition", "in prog"),
        ("create a course", "todo"),
    ],
)
def test finish(cards db, start_summary, start_state):
    initial_card = Card(summary=start_summary, state=start_state)
    index = cards_db.add_card(initial_card)
    
    cards_db.finish(index)
    
    card = cards_db.get_card(index)
    assert card.state == "done"

想定される例外をテストする

pytest.raises() で想定例外をキャッチできます。
実装側で独自例外を定義することで例外テストの価値や意味がより強まります。

import pytest
import cards


def test_no_path_raises():
    # TypeErrorが発生した場合のみテストが成功する
    with pytest.raises(TypeError):
        cards.CardsDB()

型チェックと正規表現を組み合わせた柔軟なテストも可能です。

def test_raises_with_info():
    match regex = "missing 1 .* positional argument"
    # 型チェック + エラー文を正規表現レベルでチェックできる
    with pytest.raises(TypeError, match=match regex):
    cards.CardsDB()

def test_raises_with_info_alt():
    # exc_infoはExceptionInfo型で、例外の追加検証が可能(group_containsでExceptionGroup検証などが可能)
    # ExceptionInfo: https://docs.pytest.org/en/latest/reference/reference.html#exceptioninfo
    with pytest.raises(TypeError) as exc_info:
        cards.CardsDB()
        expected = "missing 1 required positional argument"
        assert expected in str(exc info.value)

おわりに

私自身がこの一年間で意識してテストが少し書きやすくなった知見をまとめました。
テストは大事というけども、どうすれば良いか分からないという人の足がかりになれば幸いです。

https://www.shoeisha.co.jp/book/detail/9784798177458

Discussion