はじめに
この記事の内容は『単体テストの考え方/使い方』(Vladimir Khorikov 著)を自分なりに集約したものです。
重要な部分を見直しやすいよう細かい部分は飛ばして書いているので、詳細な説明や補足が気になる方は書籍を手に取ってご確認いただけますと幸いです。
目次
-
AAAパターンの利用
1-1. 全般のアンチパターン
1-1-1. if文を使うこと
1-1-2. 同じフェーズを複数回用意すること
1-2. AAAパターンのアンチパターン
1-2-1. 準備フェーズが長すぎる
1-2-2. 実行フェーズが複数行にわたる
1-3. AAAパターンのより良い実践
1-3-1. テスト対象システムを一目で認識できるようにする
1-3-2. 適切なテスト・フィクスチャを共有する
1-3-3. テストメソッドには分かりやすい名前を付ける
1-3-4. パラメータ化テストの利用
1-3-4. 確認フェーズの可読性を向上させる
AAAパターンの利用
単体テストの実装によく利用されているのはAAAパターンと呼ばれる次の3フェーズで構成されるテストコードです。
- 準備(Arrange)
- 実行(Act)
- 確認(Assert)
この記事ではAAAパターンを利用する上で注意すべき点やアンチパターン、より良い実践を紹介してきます。
全般のアンチパターン
まずはAAAパターンで注意すべき点というよりも、単体テスト全般で注意すべき点を挙げていきます。
以下は単体テストで回避すべき行為の例です。
if文を使うこと
詳細
テストケースの中にif文が含まれることは1つのテストケースの中で複数の検証を行っていることを示唆します。また、テストコードが分岐していることはそれだけでテストの内容を理解しにくいものにしてしまいます。そのため、テストコードの中にif文が含まれるような場合はテストケースを複数に分割することが求められます。なお、テストコードの中にif文を使わないというのは統合テストにおいても同様のことが言えます。同じフェーズを複数回用意すること
詳細
単体テストで「準備」→「実行」→「確認」とフェーズを進めた後に別の「準備」「実行」「確認」フェーズを入れることは基本的に間違いです。なぜなら、単体テストの中に複数の確認フェーズが入ることはそのテストケースの中で複数の振る舞いを検証することを意味し、そのような検証は統合テストですべきことだからです。AAAパターンのアンチパターン
続いて、AAAパターンの実践における注意事項には次のものがあります。
準備フェーズが長すぎる
詳細
通常、AAAパターンの準備フェーズは他のフェーズよりも大きくなります。しかし、もし準備フェーズが他の2つのフェーズを合わせたサイズよりはるかに大きくなる場合は準備フェーズの一部をメソッドや関数、あるいはファクトリクラスとして切り分けた方がいいでしょう。長すぎる準備フェーズは単純に読みにくいですし、複数のテストケースで同様の処理をしている場合は協力者オブジェクトの仕様変更がテストコードの保守に大きく影響するからです。📝切り分けの参考
準備フェーズのコードをテストケース間で共有するときの有用なパターンとしてよく知られているものに「オブジェクト・マザー」や「テスト・データ・ビルダー」と呼ばれるパターンがあります。実行フェーズが複数行にわたる
詳細
実行フェーズが1行で記述できない場合、それはテスト対象オブジェクトのカプセル化が失敗しており、設計に問題があることを示唆しています。カプセル化とは、データの整合性が損なわれること(不変条件の侵害)を防ぐことです。例えば、ある単体テストで実行フェーズが「物品を購入する処理」と「在庫を減らす処理」の2行に分かれていたとすると、そのテスト対象オブジェクトは購入処理と在庫処理を両方呼び出す必要があることを知っていなければ安全には使えません。カプセル化はこのようなデータの整合性を損なう危険性に対処し、通常は一連の処理を1つのメソッドにまとめているはずです。そのため、実行フェーズが複数行にわたる場合はテスト対象オブジェクトのカプセル化が適切に行われていない可能性を考えるべきです。
AAAパターンのより良い実践
AAAパターンのより良い実践のために意識した方がいい事柄には次のものがあります。
テスト対象システムを一目で認識できるようにする
詳細
単体テストでは複数の協力者オブジェクトが登場し、時々どのオブジェクトがテスト対象のオブジェクトなのかが分かりにくくなる場合があります。そのため、単体テストではしばしばテスト対象オブジェクトを一目で認識できるように分かりやすい変数名を付けるべきとされています。 なお、一般的にテスト対象オブジェクトの変数名にはSystem Under Test(テスト対象システム)の頭文字をとって"sut"という変数名が付けられることが多いようです。class CalculatorTests:
def sum_of_two_numbers():
first = 10
second = 20
sut = Calculator() # テスト対象システム(System Under Test)
result = sut.sum(first, second)
assert result == 30
適切なテスト・フィクスチャを共有する
詳細
テスト・フィクスチャとは、準備フェーズの中で協力者オブジェクトを取得するためのプライベートなファクトリメソッドであり、複数のテストケース間で共有できるように設計されたものです。単体テストでは通常複数のテストケースを扱うため、各テストケースで毎回同じようなコードを書くのは生産性が悪いので、同じような処理はテスト・フィクスチャを使って共通化するようにしましょう。
📝テスト・フィクスチャのアンチパターン
単体テストは複数のテストケースをまとめたクラスとして実装されることが多いのですが、次の理由から、テスト・フィクスチャをコンストラクタで定義することはアンチパターンとされています。- テストケースが読みづらくなる
テスト・フィクスチャをコンストラクタで実装すると協力者オブジェクトが暗黙的に生成されるため、テストメソッドだけを見てテストが検証したい内容を理解することが難しくなります - テストケース間の隔離状態を崩す恐れがある
テスト・フィクスチャをコンストラクタで実装すると協力者オブジェクトがクラス内で共有されるため、1つのテストメソッドの実行結果が他のテストメソッドの実行条件に影響を与える恐れがあります
class CustomerTests:
def purchase_succeeds_when_enough_inventory(self):
store = self.__create_store_with_inventory("shampoo", 10)
sut = self.__create_customer()
success = sut.purchase(store, "shampoo", 5)
assert success is True
assert store.get_inventory("shampoo") == 5
def purchase_fails_when_not_enough_inventory(self):
store = self.__create_store_with_inventory("shampoo", 10)
sut = self.__create_customer()
success = sut.purchase(store, "shampoo", 15)
assert sucess is False
assert store.get_inventory("shampoo") == 10
# 協力者オブジェクトを取得するファクトリメソッド(テスト・フィクスチャ)
def __create_store_with_inventory(self, product, quantity):
store = Store()
store.add_inventory(product, quantity)
return store
# テスト対象オブジェクトを取得するファクトリメソッド
def __create_customer(self):
return Customer()
テストメソッドには分かりやすい名前を付ける
詳細
テストメソッドには、そのメソッドが何を検証し、テスト対象オブジェクトがどのように振舞うかを明確にする名前を付けるようにしましょう。以下はテストメソッドを命名するときの注意点をまとめたものです。
- 厳格な命名規則に縛られないようにする
複雑な振る舞いを厳格な命名規則に従って表現するには限界があるため、命名には自由で自然な表現を認めましょう - 非エンジニアにとっても検証内容が分かりやすい名前を付ける
システムを利用する非開発者にとっても分かりやすい名前を付けることは、テストの価値をクライアントやユーザに説明しやすくなることにもつながります - アンダースコアを使って単語を区切るようにする
キャメル記法はエンジニアにとってはなじみのある表現ですが、非エンジニアにとっては単語をアンダースコアで区切って文章のように見せた方が自然で理解しやすい命名になります - テスト対象のメソッド名をテストメソッド名に含めない
単体テストはコードを検証するのではなくオブジェクトの振る舞いを検証するためのものです。また、テストで検証すべき振る舞いに対しテスト対象のメソッド名は変化しやすいため、テストメソッド名にテスト対象のメソッド名を含める運用はテストの保守コストを上げる原因になります
事例:配達サービスに対して過去の日付は指定できないことを検証する場合
# 厳格な命名規則に従ったテストメソッド名(アンチパターン)
def isDeliveryValid_InvalidDate_ReturnsFalse():
past_date = datetime.now() - timedelta(1)
sut = DeliveryService()
is_valid = sut.isDeliveryValid(past_date)
assert is_valid == False
# step1: 普通の言い回しに変更
def delivery_with_invalid_date_should_be_considered_invalid():
# step2: 抽象的な部分を具体的な内容に置き換える
def delivery_with_past_date_should_be_considered_invalid():
# step3: 希望や推測を含む表現を置き換える
def delivery_with_past_date_is_invalid():
パラメータ化テストの利用
詳細
1つのテスト対象(1単位の振る舞い)を検証する場合、普通テストケースは1つだと不十分です。また、検証する振る舞いが非常に複雑なものである場合、その振る舞いに対するテストケースは劇的に増大します。単体テストではテストケースごとに複数のテストコードを書かなければならないのですが、もしテストケースの間に共通点があればそのテストのコードはパラメータ化テスト機能を使って非常に簡潔に簡潔に書ける場合があります。
事例:ある配達システムは2日後以降の配達日指定だけ承認します。
このシステムで次の4ケースを検証する場合を考えます。
・1日前の日付
・今日の日付
・1日後の日付
・2日後の日付
# 協力者オブジェクト
class Delivery:
def __init__(self, date):
self.date = date
# テスト対象オブジェクト
class DeliveryService:
def is_delivery_valid(self, delivery):
return delivery.date >= (datetime.now() + timedelta(2))
# unittestを使った場合
import unittest
from datetime import datetime, timedelta
class TestDeliveryService(unittest.TestCase):
test_cases = [
(-1, False),
(0, False),
(1, False),
(2, True),
]
def test_can_detect_an_invalid_delivery_date(self):
sut = DeliveryService()
for days_from_now, expected in TestDeliveryService.test_cases:
with self.subTest(days_from_now=days_from_now, expected=expected):
delivery_date = datetime.now() + timedelta(days_from_now)
delivery = Delivery(delivery_date)
is_valid = sut.is_delivery_valid(delivery)
self.assertEqual(is_valid, expected)
if __name__ == '__main__':
unittest.main()
# ------------------
# 実行コマンド
# python sample.py
# ------------------
# pytestを使った場合
import pytest
from datetime import datetime, timedelta
@pytest.mark.parametrize(
"days_from_now, expected",
[
(-1, False),
(0, False),
(1, False),
(2, True),
]
)
def test_can_detect_an_invalid_delivery_date(days_from_now, expected):
sut = DeliveryService()
delivery_date = datetime.now() + timedelta(days_from_now)
delivery = Delivery(delivery_date)
is_valid = sut.is_delivery_valid(delivery)
assert is_valid == expected
print("complete!")
# ------------------
# 実行コマンド
# pytest sample.py
# ------------------
📝フレームワークの利用(※個人の見解)
Pythonに限らずメジャーな言語には単体テストのフレームワークが用意されています。 これは原著にはない個人の見解ですが、フレームワークの利用には次のようなメリットがあるため、パラメータ化テストなどの広く普及しているテスト手法を実践する時にはなるべくフレームワークが利用できないか確認か検討してみるべきだと思います。- テストコードを短くできる
- AAAパターン(準備・実行・確認)の各フェーズが見やすくなる
- テストコードの書き方を標準化できる
確認フェーズの可読性を向上させる
詳細
テストコードの読みやすさを仕上げるには確認フェーズの可読性を向上させることも大切です。 確認フェーズの読みやすさを向上させるアプローチには次のようなものがあります。- アサーションヘルパーを使う
pytestなどのライブラリには浮動小数点数の比較ができるpytest.approx()などのアサーションヘルパーと呼ばれるメソッドが用意されており、それらを使うことで確認フェーズのコードをシンプルに記述できる場合があります
- 確認用のヘルパー関数を作る
アサーションヘルパーはアサーションの記述量削減に貢献しやすいですが、必ずしも非エンジニアから見みた分かりやすさにはつながるとは限りません
確認フェーズの内容を直感的に分かりやすくするには、確認内容を自然言語的に表現した関数を自作することも視野に入れましょう
- 確認コードを自然言語化する
例えば、C#にはFluent Assertionというライブラリがあり、「Assert Equal(30, result)」のような確認フェーズのコードを「result.Should().Be(30)」のような自然言語に近い形で表現可能にするツールがあります📝pytest_bdd
PythonにはC#のFluent Assertionに近いライブラリとしてpytest_bddがあるようでしたが、アノテーションの記述量が多くなり、個人的には可読性向上の面では使いにくく感じるところがあったのでこの記事でのサンプル投稿は控えました。
サンプル:アサーションヘルパーの利用
import pytest
def div(a, b):
return a / b
# アサーションヘルパーを使わない場合
def test_division():
value_a, value_b = 7, 3
result = div(value_a, value_b)
assert abs(result - 2.3333) < 0.001
# アサーションヘルパーを使った場合
def test_division_with_helper():
value_a, value_b = 7, 3
result = div(value_a, value_b)
assert result == pytest.approx(2.3333, 0.001)
📝pytestのアサーションヘルパー
以下はpytestに実装されているアサーションヘルパーの一部です- assertAlmostEqual: 2つの浮動小数点数がほぼ等しいことを確認
- assertGreater: 1つの値がもう1つの値より大きいことを確認
- assertGreaterEqual: 1つの値がもう1つの値以上であることを確認
- assertIn: 値がリストやタプルなどのシーケンスに含まれていることを確認
- assertIs: 2つのオブジェクトが同じであることを確認
- assertIsNone: 値がNoneであることを確認
- assertIsInstance: オブジェクトが特定の型であることを確認
- assertRaise: 特定の例外が発生することを確認
- assertRegex: 正規表現パターンにマッチするかを確認
- assertCountEqual: 2つのコンテナが同じ要素を含んでいることを確認
- assertDictContainsSubject: 辞書が部分的に他の辞書を含んでいることを確認
- assertTupleEqual: 2つのタプルが等しいことを確認
# 確認用のヘルパー関数を作る
import pytest
def div(a, b):
return a / b
def valid_result_is_almost_equal(result, expected_result):
assert result == pytest.approx(2.3333, 0.001) # 許容誤差: 0.001未満
def test_division():
value_a, value_b = 7, 3
result = div(value_a, value_b)
valid!_result_is_almost_equal(result, 2.3333)
if __name__ == '__main__':
test_division()