最近開発をしていて、どこまでテストをやればいいのか、どういう観点でテストケースを作ればいいかという疑問が湧いてきました。
体系的に勉強するために、『単体テストの考え方/使い方』(Vladimir Khorikov 著)を読み始めたので備忘録として残します。
TL;DR
- 単体テストはプロジェクトの成長を持続可能なものにするために行う
- 古典学派とロンドン学派があり、それぞれ隔離において解釈が別れる
- AAAパターンを使用することで、テストケースに対して簡潔で統一された構造を持たせられる
- よい単体テストを構成する4本の柱がある
1. 単体テストの目的
単体テストの目標は「ソフトウェア開発プロジェクトの成長を持続可能なものにする」という定義が本書ではなされています。
多くのプロジェクトでは、作って終わりではなく、継続的に改善を行います。テストを書かなければ、開発初期は素早く機能を追加できます。しかし、コードが増えるにつれて変更の影響を把握することが難しくなり、同じ規模の機能追加にも多くの時間がかかるようになります。
適切なテストを用意しておけば、変更によって生じた退行(リグレッション)を検出するセーフティネットになります。その結果、新機能の追加やリファクタリングの際にも、既存の機能が正しく振る舞うという確信を持ちやすくなります。
2. 古典学派とロンドン学派
本書では、自動化されていて、次の3つの性質をすべて備えるものを単体テストとして捉えています。
- 単体(unit)と呼ばれる少量のコードを検証する
- 実行時間が短い
- 隔離された状態で実行される
ここで解釈が分かれるのが、「隔離」の意味です。
実際にECサイトのアプリケーションで、「顧客が商品を買う」という一つのユースケースでそれぞれの学派の違いを見てみます。
ここでは、在庫を管理するStoreと、購入処理を行うpurchase(store, quantity)があるとします。purchaseは在庫が足りれば指定個数を減らしてTrueを返し、足りなければ在庫を変更せずにFalseを返します。
古典学派の考え方では、テスト専用のStoreを作り、購入結果と在庫を確認できます。
import unittest
from unittest.mock import Mock
from shop import Store, purchase
class PurchaseTests(unittest.TestCase):
# 古典学派の例
def test_purchase_succeeds_when_enough_inventory(self):
# Arrange
store = Store(stock=10)
# Act
result = purchase(store, quantity=3)
# Assert
self.assertTrue(result)
self.assertEqual(store.stock, 7)
purchaseとStoreを一緒に動かしていますが、確かめているのは「在庫が足りれば購入でき、在庫が減る」という一つの振る舞いです。古典学派では、複数のクラスを使うことだけを理由に統合テストとは呼びません。
ロンドン学派の考え方では、協力者であるStoreをテストダブルに置き換えます。
テストダブルとは本物の実装を模倣するコードの総称です。モックやスタブはテストダブルの一種です。
# ロンドン学派の例
def test_purchase_succeeds_when_enough_inventory(self):
# Arrange: Storeをテストダブルに置き換える
store = Mock(spec=Store)
store.has_inventory.return_value = True
# Act
result = purchase(store, quantity=3)
# Assert: 結果に加えて、依存先への指示を確認する
self.assertTrue(result)
store.remove_inventory.assert_called_once_with(3)
こちらが確認するのは「購入処理が在庫を3個減らす指示を出した」ことです。実物のStoreは動いていないため、在庫が本当に減るかは、このテストでは確認できません。協力者自身のテストや、組み合わせたテストが別途必要です。
| 隔離対象 | 単体の意味 | テスト・ダブルの置き換え対象 | |
|---|---|---|---|
| 古典学派 | テストケース | 1つのクラス、もしくは同じ目的を達成するためのクラスの1グループ | 共有依存 |
| ロンドン学派 | 単体 | 1つのクラス | 不変依存を除く全ての依存 |
この隔離に対する考え方の違いが、単体とは何か、そしてテスト対象システムが必要とする依存をどのように扱うかということに関する見解に影響を与えています。
3. AAAパターン
AAAは、テストを次の3段階に分けるパターンです。
| フェーズ | 役割 | 今回の例 |
|---|---|---|
| Arrange | テストケースの事前条件を満たすようにテスト対象システムとその依存の状態を設定する | 在庫10個のStoreを作る |
| Act | テスト対象システムのメソッドを呼び出すことで、テスト対象の振る舞いを実行させる | 3個購入する |
| Assert | 実行結果が想定した結果であることを確認する | 成功し、在庫が7個になる |
先ほどのテストも、この順番で書いています。コメントや空行で区切ると、準備のコードと検証対象の操作を見分けやすくなります。
AAAパターンのポイント:
- テストケースに同じフェーズが複数含まれる場合、そのテストケースは複数の1単位の振る舞いを一度に検証しようとしていることを示唆する
- 実行フェーズに記述するコードが1行を超す場合、テスト対象となるコードのAPIがきちんと設計されていないことを示唆する
4. よい単体テストを構成する4本の柱
第4章では、テストの価値を次の4つの性質から考えます。
| 柱 | 確かめたいこと |
|---|---|
| 退行(regression)に対する保護 | 動いていた機能を壊したときに検知できるか |
| リファクタリングへの耐性 | 振る舞いを保つ内部変更で失敗しないか |
| 迅速なフィードバック | 短い待ち時間で結果を得られるか |
| 保守のしやすさ | 意図を理解し、実行・修正しやすいか |
最初の二つの柱を理解するために、テストの「見逃し」と「誤警報」を整理します。
ここでは、不具合を検出した状態を「陽性」、検出しなかった状態を「陰性」と呼びます。
| 実際の振る舞い \ テスト結果 | 成功 | 失敗 |
|---|---|---|
| 正しい | 真陰性 | ❌ 偽陽性:正しいのに誤警報を出した |
| 正しくない | ❌ 偽陰性:不具合を見逃した | 真陽性 |
偽陽性は本番のバグが警告されずに本番環境に持ち込まれる偽陰性に比べると、それほど大きな問題ではないように見えます。
しかし、偽陽性が増えてしまうと、開発者は嘘の警告に慣れてしまい、その警告に注意を払わなくなってしまいます。また、テストを信頼できるセーフティネットとして見ることができなくなり、そのテストへの信頼が失われることになります。
それでは4つの柱を具体的に見ていきます。
① 退行に対する保護
退行とは、変更によって、それまで動いていた機能が意図どおりに動かなくなることです。
退行に対する保護が強いテストは、こうした不具合を検出し、偽陰性、つまり見逃しを減らします。
② リファクタリングへの耐性
リファクタリングは、外から見た振る舞いを保ったまま内部構造を変えることです。それだけでテストが失敗すると、不具合ではない失敗、つまり偽陽性が増えます。
例えば、IDを指定してユーザーを取得する処理を考えます。
class UserRepository:
def __init__(self, connection):
self.connection = connection
def get_by_id(self, user_id):
return self.connection.execute(
"SELECT * FROM users WHERE user_id = ?",
(user_id,),
).fetchone()
ここで、「正しいSQLを実行しているか」を、SQL文字列の完全一致で確認してしまうとどうなるでしょうか。
from unittest.mock import Mock
def test_get_by_id_executes_expected_sql():
connection = Mock()
repository = UserRepository(connection)
repository.get_by_id(5)
connection.execute.assert_called_once_with(
"SELECT * FROM users WHERE user_id = ?",
(5,),
)
このテストは、次のように取得する列を明示するだけで失敗します。
-- 変更前
SELECT * FROM users WHERE user_id = ?
-- 変更後(usersの列はuser_idとnameの2つとする)
SELECT user_id, name FROM users WHERE user_id = ?
このテーブルでは、どちらも同じユーザー情報を返します。それでもテストが失敗するのは、「IDが5のユーザーを取得する」という振る舞いではなく、特定のSQL文字列を使うという実装の詳細に依存しているためです。SQLの大文字・小文字や空白を変えただけでも、同じことが起こります。
ですので、How「どのように取得したか」より、What「何を取得できたか」を確認するテストにすることが重要です。
③ 迅速なフィードバック
テストを行ったあと、フィードバックをすぐに受け取れることは単体テストにとって必要不可欠です。
④ 保守のしやすさ
保守のしやすさがどのくらいテストケースに備わっているかは次の二つの観点から把握できると本書では言っています。
- テストケースを理解することがどのくらい難しいのか?
- テストを行うことがどのくらい難しいのか?
テストケースの価値はこの4本の柱がそれぞれどのくらい備わっているかの度合いを掛け算することで算出できます。そのため、もし1本でも備えられていない柱があれば、そのテストケースには価値がないことになります。
まとめ
単体テストの考え方/使い方について第一部と第二部の内容をかいつまんでメモしました。
続けて第三部の統合テストや第四部の単体テストのアンチパターンについて読んでいきたいと思います。
