はじめに
テストを書くこと自体は習慣になった。
でも半年後に自分のテストを読み返して、
一発で「何をテストしていたか」を思い出せるでしょうか。
- テスト名を見ても何を検証しているか推測できない
- テストメソッドを開いたら準備と検証が入り混じっている
- 似たようなセットアップが何箇所にもコピーされている
――そんな経験はありませんか?
プロダクションコードの可読性には気を遣うのに、
テストコードは「動けばいい」で済ませてしまう。
自分もまさにそうでした。
調べてみると、テストが読めなくなる原因は大きく3つに分解できます。
構造の欠如、命名の失敗、再利用の罠です。
この記事では、それぞれの原因に対する処方箋を紹介します。
TL;DR
- テストはAAAパターン(Arrange / Act / Assert)で構造化し、1テスト1検証を守る
- テスト名は機械的テンプレートではなく、振る舞いを平易な言葉で説明する名前にする
- テストフィクスチャの共有はコンストラクタではなくファクトリメソッドで行い、テスト間の結合を防ぐ
原因1: テストの中身が一目で分からない ― 構造の欠如
テストメソッドを開いたとき、
「どこが準備で、どこが実行で、どこが検証か」が
一目で分からない。
これがテストが読めなくなる最初の原因です。
AAAパターンで「読む型」を作る
テストの構造を統一するパターンとして
AAAパターンがあります。
1つのテストを3つのセクションに分ける考え方です。
- Arrange(準備): テスト対象と依存を目的の状態にセットアップする
- Act(実行): テスト対象のメソッドを呼び出す
- Assert(検証): 結果を検証する
┌─────────────────────────────────┐
│ AAAパターンの構造 │
├─────────────────────────────────┤
│ │
│ Arrange ... 準備(状態の構築) │
│ ↓ │
│ Act ... 実行(1つの操作) │
│ ↓ │
│ Assert ... 検証(結果の確認) │
│ │
└─────────────────────────────────┘
AAAパターンの最大の利点は統一性です。
テストスイート全体が同じ構造になるため、
誰が書いたテストでも同じ「読み方」で理解できます。
新しくチームに入ったメンバーも、
AAAの型を知っていればすぐにテストを読み始められます。
セクションの区切りは空行で十分です。
// Arrange のようなコメントは基本的に不要ですが、
Arrangeセクションの中に空行が必要なほど
準備が大きいテストでは併用すると分かりやすくなります。
Given-When-Thenとの関係
BDD(振る舞い駆動開発)で使われるGiven-When-Thenパターンは、
構造的にはAAAと同じです。
Given = Arrange、When = Act、Then = Assert に対応します。
非エンジニアにもテストの意図を伝えたい場面では
Given-When-Thenのほうが適しています。
もう1つ、テストの可読性に効くシンプルな工夫があります。
テスト対象のオブジェクトをsutと命名することです。
SUT(System Under Test)は「テスト対象」を意味するテスト用語です。
テスト内でテスト対象をsutという変数名にしておくと、
依存オブジェクトが複数あっても「どれがテスト対象か」が即座に分かります。
各セクションのサイズが教えてくれること
AAAの3セクションには、それぞれ「健全なサイズ」があります。
サイズの偏りが、設計上の問題を教えてくれることがあるのです。
Arrangeセクション ― 最大になるのが普通
3セクションの中でArrangeが一番大きくなるのは自然です。
テストに必要なオブジェクトやデータを準備する以上、
コード量が増えるのは避けられません。
ただし、Arrangeが膨れすぎたら段階的に対処できます。
- ファクトリメソッドへの抽出(「原因3」のセクションで詳しく扱います)
- Object Mother や Test Data Builder といったパターンの導入(大規模な場合)
Actセクション ― 1行ルール
Actセクションが1行を超えたら注意信号です。
ただし、テスト自体の問題ではなく、
テスト対象のAPI設計に問題がある可能性を示しています。
たとえば、ECサイトの「購入」処理を考えてみます。
# Actが2行 ― 何かがおかしい
result = customer.purchase(product, quantity)
store.remove_inventory(product, quantity) # ← なぜ呼び出し側が在庫を減らす?
購入が成功したら在庫が減る。
これは「購入」という1つの操作に含まれるべき処理です。
にもかかわらず、呼び出し側が2ステップの手順を
覚えておく必要がある設計になっています。
これはカプセル化(エンカプセレーション)の違反です。
カプセル化とは、操作の結果が一貫した状態になることを
オブジェクト自身が保証する設計原則のことです。
Actが複数行になるということは、
「操作の途中状態」が外部に漏れ出しているサインです。
# Actが1行 ― purchaseの中で在庫も減る
result = customer.purchase(store, product, quantity)
このルールはビジネスロジックのコードに対して有効です。
ユーティリティコードや基盤コードには
厳密に適用しなくても構いません。
Assertセクション ― 「1テスト1アサーション」にこだわらない
「1つのテストには1つのアサーション」という
ルールを耳にしたことがあるかもしれません。
しかし、1つの振る舞いが複数の結果を持つことは自然です。
たとえば「購入が成功した」ことを検証するなら、
以下の両方を確認するのは妥当です。
# 「購入が成功した」の検証 ― 2つのアサーションで1つの振る舞いを確認
assert result is True # 購入結果がtrue
assert store.get_inventory("shampoo") == 5 # 在庫が減っている
ただし、アサーションが際限なく増えていく場合は
別の問題を疑ってください。
アサーションが多すぎるときは、
プロダクションコード側に
「等値比較の抽象(equalsメソッドなど)」が
足りていないサインかもしれません。
オブジェクト全体を1つのアサーションで比較できれば、
アサーションの行数は自然と減ります。
構造に違反しているサイン ― 複数AAAとif文
AAAパターンを知っていても、
気づかないうちに構造を壊してしまうことがあります。
2つの代表的なアンチパターンを紹介します。
複数のAAAセクション
Arrange → Act → Assert → Arrange → Act → Assert ...
この形になっているテストは、
1つのテストで複数の振る舞いを検証しているサインです。
ユニットテストでは1テストに1つのActが原則なので、
テストを分割しましょう。
遅い統合テストでは、状態が自然に連鎖するケースに限り
複数Actを許容する場合があります。
たとえば「ユーザー作成 → ログイン → プロフィール取得」
のように前のActの結果が次のArrangeになるケースです。
ただし、ユニットテストでは不要な妥協です。
if文の混入
テスト内にif文が入っているということは、
1つのテストが複数のシナリオを扱おうとしています。
# テスト内のif文 ― シナリオを分けるべきサイン
if product.is_in_stock():
assert purchase_result is True
else:
assert purchase_result is False
テストは1つの振る舞いについての事実の記述です。
条件分岐を入れる場所ではありません。
シナリオが分かれるなら、テストを分けましょう。
Fluent Assertionsでアサーションを読みやすくする
AAAの構造を整えても、
Assertセクションの「読み方」が不自然だと
理解に余計な負荷がかかります。
多くの言語の標準的なアサーションは、
主語と目的語の順序が直感に反しています。
// 標準的なアサーション(期待値が先、実際値が後)
Assert.Equal(30, result)
// Fluent Assertions(実際値が先 → 期待値が後)
result.Should().Be(30)
プログラミング言語の多くは英語ベースで、
自然な語順は「主語 → 動作 → 目的語」です。
| 形式 | 主語 | 動作 | 目的語 |
|---|---|---|---|
| 標準 | (不明瞭) | Equal | 30, result |
| Fluent | result | Should Be | 30 |
Fluent Assertionsは
この語順に沿っているため、
アサーションが英文のように自然に読めます。
多くの言語に同様のライブラリがあります。
-
Java: AssertJの
assertThat(result).isEqualTo(30) -
JavaScript: Chaiの
expect(result).to.equal(30) -
Python: PyHamcrestの
assert_that(result, equal_to(30))
追加の依存が増えるというコストはありますが、
テストの可読性向上への寄与は大きいです。
原因2: テスト名から何を検証しているか分からない ― 命名の失敗
テストが失敗したとき、
テスト名だけで「何が壊れたか」を推測できないと、
毎回テストの中身を読む羽目になります。
テスト結果の一覧(テスト名 = 目次):
✓ Delivery_with_a_past_date_is_invalid
✗ test_method_3 ← 何が壊れた?
テスト名は、テストスイート全体の目次として
機能するべきものです。
機械的命名の問題点
よく見かける命名規則があります。
[テスト対象メソッド名]_[シナリオ]_[期待結果]
たとえば Sum_TwoNumbers_ReturnsSum のような形です。
論理的には筋が通っているように見えますが、
いくつかの問題があります。
-
実装に縛られる: メソッド名を変更したら
テスト名も変更が必要になる -
振る舞いが伝わらない: プログラマの目には
論理的に見えても、
「何の振る舞いを検証しているか」は直感的に分からない -
認知負荷が蓄積する: テスト名が暗号的になり、
テストスイート全体を俯瞰するときの
認知コストが積み上がっていく
根本的な問題は、
テストは 「コード」をテストしているのではなく「振る舞い」をテストしている という点です。
メソッド名はあくまでエントリポイントにすぎません。
振る舞いベースの命名ガイドライン
では、どうやってテスト名を付ければいいのか。
3つの原則があります。
-
厳格な命名テンプレートに従わない。
複雑な振る舞いの説明を機械的な箱に押し込めようとしない -
ドメインに詳しい非プログラマに説明するつもりで名前を付ける。
ビジネスアナリストが読んで意味が通る名前が理想 -
単語はアンダースコアで区切る。
長い名前でも可読性を確保できる
また、テスト名にテスト対象のメソッド名を
含めないほうが良いです。
メソッド名が変わるたびにテスト名の変更が発生するのは
保守コストの無駄です。
ユーティリティコードは例外です。
ビジネスロジックを持たない補助的なコードであれば、
メソッド名をテスト名に含めても問題ありません。
命名の段階的改善
実際に改善プロセスを追ってみましょう。
「過去の日付を指定した配送は無効になる」
ことを検証するテストを例にします。
ステップ1(機械的命名):
IsDeliveryValid_InvalidDate_ReturnsFalse
ステップ2(平易な英語の第一歩):
Delivery_with_invalid_date_should_be_considered_invalid
ステップ3(具体化 + 冗長さの除去):
Delivery_with_a_past_date_is_invalid
改善のポイントは3つです。
-
「無効な日付」→「過去の日付」:
何が無効なのかを名前で具体的に伝える。
「invalid」だけでは意味が広すぎる -
「should be」→「is」:
テストは願望ではなく事実の記述。
「〜であるべき」ではなく「〜である」と書く -
冠詞(a, the)を恐れない:
自然な英語として読めることを最優先する。
プログラマの世界では冠詞を省きがちだけれど、
テスト名は散文に近いほうが読みやすい
パラメータ化テストと命名のトレードオフ
振る舞いベースの命名を身につけたところで、
次の壁にぶつかります。
「同じ構造で入力値だけ異なるテストが大量にある」場面です。
多くのテストフレームワークはパラメータ化テストを提供しています。
同じテストロジックを、入力値のセットを変えて
繰り返し実行する仕組みです。
-
xUnit(C#):
[Theory]+[InlineData] -
JUnit5(Java):
@ParameterizedTest+@ValueSource -
Jest(JavaScript):
test.each -
pytest(Python):
@pytest.mark.parametrize
パラメータ化テストには
コード量の削減と可読性のトレードオフがあります。
テストをまとめるほどテスト名は汎用的になり、
個々のケースが何を表しているか分かりにくくなります。
パラメータが増えるほどこの傾向は強まります。
実践的な妥協点は、正常系と異常系の分離です。
| 正常系 | 異常系 | |
|---|---|---|
| 方針 | 個別のテストメソッド | パラメータ化テスト |
| 理由 | テスト名の表現力が重要 | 入力パターンの網羅が目的 |
異常系は「どんな入力が拒否されるか」の網羅が目的なので、
パラメータ化してまとめても意図は伝わります。
一方、正常系は「この振る舞いはこういう意味がある」
ということをテスト名で明示したいので、
個別のメソッドにするほうが読みやすくなります。
入力パラメータから「どのケースが何を意味するか」が
自明でない場合は、パラメータ化しないほうが良いです。
パラメータの組み合わせが複雑になると、
テスト結果を見ても何が失敗したか分からなくなります。
原因3: テストの準備コードが散在・重複する ― 再利用の罠
テストが増えるにつれ、
同じセットアップコードがあちこちに現れます。
「DRYにしたい」という気持ちは自然ですが、
やり方を間違えるとテスト間に不要な結合が生まれます。
テストフィクスチャとは
まず用語を整理します。
テストフィクスチャとは、
テストの実行に必要なオブジェクトやデータのことです。
たとえば以下のようなものが該当します。
- テスト対象のオブジェクト(
Store、Customerなど) - テストに必要な初期データ(在庫数、ユーザー情報など)
- 外部依存のモックやスタブ
テストが毎回同じ既知の状態(fixed state)から
始まることを保証するための「お膳立て」です。
Arrangeセクションが肥大化してくると、
テスト間でフィクスチャを共有したいという欲求が生まれます。
ここからが罠の始まりです。
コンストラクタでの共有 ― なぜ問題なのか
最もよく見かけるやり方は、
テストクラスのコンストラクタ(またはSetUpメソッド)で
フィクスチャを初期化し、フィールドとして共有する方法です。
class TestOrder:
def setup_method(self):
self.store = Store()
self.store.add_inventory("shampoo", 10)
self.customer = Customer()
def test_purchase_succeeds(self):
result = self.customer.purchase(self.store, "shampoo", 5)
assert result is True
def test_purchase_fails_when_not_enough(self):
result = self.customer.purchase(self.store, "shampoo", 15)
assert result is False
コード量は減りますが、2つの大きな問題があります。
問題1: テスト間の高結合
共有フィクスチャの変更が全テストに波及します。
setup_method: 在庫数を 10 → 15 に変更
↓
test_purchase_succeeds → そのまま通る
test_purchase_fails_when_not_enough → 壊れる(15 ≤ 15 で在庫が足りてしまう)
1つのテストのために変えた値が、
別のテストの前提を崩してしまうのです。
ここにある原則は明確です。
1つのテストの修正が、他のテストに影響してはならない。
テストの独立修正性と呼べるものです。
問題2: 可読性の低下
テストメソッドだけを見ても全体像が分かりません。
test_purchase_succeeds を読んでいて...
→ self.store の在庫は何個?
→ setup_method を見に行く
→ テストに戻る
「このself.storeの在庫は何個だっけ?」と
コンストラクタを確認しに行く必要があり、
テストが自己完結していない状態です。
ファクトリメソッドによる正しい共有
推奨されるのは、テストクラス内に
プライベートなファクトリメソッドを定義し、
各テストから呼び出す方法です。
class TestOrder:
def test_purchase_succeeds(self):
store = self._create_store_with_inventory("shampoo", 10)
customer = Customer()
result = customer.purchase(store, "shampoo", 5)
assert result is True
def test_purchase_fails_when_not_enough(self):
store = self._create_store_with_inventory("shampoo", 10)
customer = Customer()
result = customer.purchase(store, "shampoo", 15)
assert result is False
def _create_store_with_inventory(self, product, quantity):
store = Store()
store.add_inventory(product, quantity)
return store
ファクトリメソッドの設計で大事なのは、
テストごとに「自分がどういうフィクスチャを
必要としているか」を引数で明示することです。
_create_store_with_inventory("shampoo", 10) と書いてあれば、
テストメソッドだけ読んでも
「シャンプーが10個ある店」が前提だと分かります。
| 観点 | コンストラクタ共有 | ファクトリメソッド |
|---|---|---|
| テスト間の結合 | 高い(共有状態) | 低い(各テストが独立) |
| 可読性 | 低い(別の場所を見に行く) | 高い(テスト内で完結) |
| コード量 | 最小 | やや増加 |
コード量はやや増えますが、
テストの独立性と可読性の向上はそのコストに十分見合います。
全テストで共通の依存(たとえばDB接続)は例外です。
この場合は基底クラスのコンストラクタで初期化し、
サブクラスから参照する形が妥当です。
ただし、個別のテストクラスのコンストラクタではなく
基底クラスに配置することがポイントです。
まとめ
テストが「読めなくなる」原因を
構造・命名・再利用の3つに分解してきました。
| 原因 | 処方箋 | 注意点 |
|---|---|---|
| 構造の欠如 | AAAパターンで統一 | Actが1行を超えたらAPI設計を疑う |
| 命名の失敗 | 振る舞いベースの命名 | メソッド名ではなく振る舞いを説明する |
| 再利用の罠 | ファクトリメソッドで共有 | コンストラクタ共有はテスト間の結合を生む |
どれも地味な作法ですが、
テストスイートの保守性を日々支えるのは
こうした基盤の積み重ねです。
プロダクションコードと同じように、
テストコードにも「書き方の品質」があるのだと思います。