| name | good-test-principles |
| description | 良い単体テストの基本規範(Khorikov『単体テストの考え方/使い方』準拠)を扱う。 test-catalog の手法カタログの一部。古典学派とロンドン学派の使い分け、観察可能な 振る舞いのテスト、良いテストの4本柱(退行保護・リファクタリング耐性・高速フィード バック・保守性)、出力ベース/状態ベース/コミュニケーションベースの検証スタイルの 優先順位を検証したい、または割り当てたいときに使う。通常は test-catalog スキルの 索引経由で手法が選定された後にこのスキルを直接参照する。
|
| disable-model-invocation | true |
良いテストの規範
良い単体テストとは何かを測る基準をまとめる。
出典は Vladimir Khorikov『単体テストの考え方/使い方』(Unit Testing Principles, Practices, and Patterns)。
TDD の手順は tdd-cycle.md(サイクルの運用)と tdd-green-strategy.md(Green の戦略)、テストダブルの使い分けは test-doubles.md を参照。
このファイルは単体テストの基本規範を扱う。
学派の選び方、何を検証対象とするか、テストの価値を測る物差し、検証スタイルの三分類を示す。
脆いテストを避けるためのテスト設計と配置(脆さの回避、モック濫用の回避、Humble Object、四象限、契約による境界)は test-design-quality.md を参照。
各項目は「概要」「目的/いつ使う」「TypeScript example(vitest 想定)」「落とし穴」の構成で示す。
目次
古典学派とロンドン学派
概要
単体テストの「単体」の捉え方をめぐる二つの立場。
古典学派(Detroit / Chicago 学派)は、振る舞いの単位を一単位とし、協力オブジェクトは可能な限り本物を使う。
ロンドン学派(Mockist)は、クラス単位を一単位とし、依存はすべてモックへ差し替えて隔離する。
目的/いつ使う
Khorikov は古典学派を推す。
理由は、ロンドン学派のように協力オブジェクトを一律モック化すると、テストが実装の協調手順へ結合し、リファクタリング耐性を失うからである。
ロンドン学派が有利なのは、依存グラフが深くテスト準備が困難なときに限られる。
ただしその困難さ自体が設計の不備を示すことが多い。
TypeScript example
import { describe, it, expect, vi } from "vitest";
import { Order } from "./order";
import { PriceTable } from "./price-table";
it("classical: uses real collaborator", () => {
const order = new Order(new PriceTable({ apple: 100 }));
expect(order.total(["apple", "apple"])).toBe(200);
});
it("london: mocks the collaborator", () => {
const priceTable = { priceOf: vi.fn().mockReturnValue(100) };
const order = new Order(priceTable);
expect(order.total(["apple", "apple"])).toBe(200);
});
落とし穴
- ロンドン学派の流儀でプロセス内の協力オブジェクトまでモック化し、内部構造を変えた途端にテストが壊れる。
- 古典学派でも、共有された可変状態を持つ協力オブジェクトを本物のまま使い、テスト間が干渉する。
点検手順(この規範を満たすか確かめる)
grep -n "vi.fn\|mock\|jest.fn" <test> で、そのテストが差し替えている協力者を全部洗い出す。
- ヒットした各協力者を「プロセス内(ドメインオブジェクト・自前の純粋ロジック)」か「プロセス外(DB・外部 API・メール)」へ仕分ける。
- プロセス内協力者をモック化していたら違反。本物に差し替え、テストがなお緑かを確認する(緑なら本物で書けた証拠)。
- 逆引き:モックを外して赤くなるテストは、振る舞いではなく協調手順を検証していた疑いがある。検証対象を戻り値・状態へ移せないか問う。
- 直し方:プロセス内は本物(古典学派)、モックはプロセス外、かつ準備が困難な深い依存グラフだけに限定する。準備の困難さ自体は設計分割の信号として残す。
網羅設計での効かせ方
網羅の単位を何に取るかで、網羅が崩れにくいか脆いかが決まる。
古典学派に寄せ、網羅対象を「振る舞いの単位」(公開された結果や副作用)に取る。
こうすると内部のクラス分割やヘルパー抽出といったリファクタリングで網羅が崩れない。
ロンドン学派に寄せて協力者を一律モック化すると、行カバレッジの数字は出る。
しかしその網羅は実装の協調手順へ結合し、内部構造を変えた途端に赤くなる。
網羅率は同じでも、後者は変更のたびに作り直しを強いる無駄な網羅になる。
モックに寄せてよいのは、依存グラフが深くテスト準備が困難な箇所だけに限る。
原則は、網羅の母集合を実装の手順ではなく観察可能な振る舞いの集合に置くことだ。
観察可能な振る舞いのテスト
概要
テストは、クライアントから観察できる振る舞い(公開された結果や、外部に与える副作用)だけを検証し、実装の詳細(private メソッド、内部の中間状態、呼び出し手順)には触れない。
目的/いつ使う
常に守るべき原則である。
観察可能な振る舞いをテストすれば、内部を入れ替えてもテストは緑のままで、リファクタリングを支えられる。
逆に実装詳細を検証するテストは、振る舞いが正しくても内部変更で壊れ、偽の警報(false positive)を出す。
迷ったら問う。「このテストは利用者が気にする結果を確かめているか、それとも内部の作り方を確かめているか」。
TypeScript example
import { describe, it, expect } from "vitest";
import { Stack } from "./stack";
it("pops the last pushed value", () => {
const s = new Stack<number>();
s.push(1);
s.push(2);
expect(s.pop()).toBe(2);
});
落とし穴
- private を無理にこじ開けてテストするのは、振る舞いではなく構造をテストしている兆候である。
- 内部状態の検証が必要に感じたら、その状態を観察可能な振る舞いとして公開すべきか、テスト対象の分割を疑う。
点検手順(この規範を満たすか確かめる)
grep -n "as any\|\[.private.\]\|@ts-ignore" <test> で、private や内部フィールドをこじ開けている箇所を洗う。ヒットは実装詳細への結合の証拠。
- 検証対象が「公開された戻り値・副作用」か「private メソッド・中間状態・呼び出し手順」かを、各アサーションごとに分類する。
- 内部実装だけを変える(配列を連結リストへ替える、ヘルパーを抽出する等)小さなリファクタを当て、テストが緑のまま保たれるかを確認する。赤くなれば実装詳細を検証している。
- 逆引き:対象クラスの公開メソッドを列挙し、どれか一つでも入力と観察可能な出力の組がテストで踏まれていない公開振る舞いが残っていないかを確認する(未検証の公開振る舞い)。「未呼び出しの private」では測らない。
- 直し方:内部こじ開けを戻り値・状態の検証へ書き換える。公開できない内部状態を検証したくなったら、公開すべき振る舞いか分割対象かの信号として扱う。
網羅設計での効かせ方
網羅対象の母集合を「公開された振る舞いの集合」に固定する。
入力と観察可能な出力の組を数え上げ、その全パターンを踏むことを網羅基準とする。
private メソッドや中間状態、呼び出し手順は網羅対象に含めない。
これらを網羅対象に含めると、網羅率を上げるほど実装変更で崩れる脆い網羅になる。
private を網羅したくなったら、それは公開すべき振る舞いか分割の対象かの信号だ。
網羅の達成チェックは「未検証の公開振る舞いが残っていないか」で行う。
「未呼び出しの private が残っていないか」で測ると、網羅の的を実装詳細へずらしてしまう。
良いテストの4本柱
概要
Khorikov は良い単体テストを四つの柱で測る。
- 退行に対する保護(protection against regressions):壊れたコードをどれだけ検出できるか。
- リファクタリングへの耐性(resistance to refactoring):振る舞いを変えない内部変更で、誤って壊れないか。
- 高速なフィードバック(fast feedback):実行がどれだけ速いか。
- 保守のしやすさ(maintainability):テスト自体がどれだけ読みやすく、壊れにくいか。
目的/いつ使う
テストの価値を評価し、何を直すか決める物差しに使う。
要点は、前三者が同時に最大化できないトレードオフの関係にあることだ。
退行保護と高速性を両立させると、広い範囲を高速に回すために実物の依存をモックへ替えがちで、リファクタリング耐性が下がる。
リファクタリング耐性は実装詳細に触れないことで得られるが、これは譲ってはならない柱とされる。
したがって実務では、リファクタリング耐性を固定し、残る退行保護と高速性のあいだで均衡を探る。
TypeScript example
import { describe, it, expect } from "vitest";
import { parseAmount } from "./amount";
describe("parseAmount", () => {
it("parses a valid amount", () => expect(parseAmount("1,000")).toBe(1000));
it("rejects a negative amount", () => expect(() => parseAmount("-1")).toThrow());
});
落とし穴
- 四本すべてを同時に満点にしようとする。前三者はトレードオフであり、同時最大化はできない。
- 高速性ほしさに純粋なロジックまでモックで固め、退行保護を削ってしまう。
点検手順(この規範を満たすか確かめる)
- 対象テストを四本柱で粗く評点する(各 高/中/低)。退行保護=壊れたコードを検出できるか、耐性=内部変更で誤って壊れないか、速度=I/O なしで速いか、保守性=入出力が一目で読めるか。
- 耐性を譲っていないかを最初に確認する。耐性は固定する柱であり、ここが低いテストは他が高くても直す対象。
- 速度のために純粋ロジックをモックで潰していないか確認する。
grep -n "mock\|vi.fn" <test> のヒットが純粋関数に掛かっていれば、退行保護を削ったモックの疑い。
- 逆引き:速度が低い原因が I/O 依存なら、対象を純粋関数へ割って出力ベースで取り直せないか問う。モック追加で速くしていないか。
- 直し方:耐性を固定したまま、退行保護と速度の均衡を探る。速度はモックでなく対象の分割で稼ぐ。
網羅設計での効かせ方
網羅とは退行保護を厚くする営みであり、これは高速性とトレードオフの関係にある。
広い網羅を高速に回そうと実物をモックへ替えると、リファクタリング耐性が下がる。
そこで耐性は譲れない柱として固定し、網羅の厚みは保護と速度の均衡で決める。
網羅率を追うために実装詳細へ結合し、耐性を削ってはならない。
具体的には、網羅対象を観察可能な振る舞いに保ったまま退行保護を上げる。
速度が問題になったら、モック追加でなく対象を純粋関数へ割って網羅を取り直す。
固定する柱(耐性)と均衡を探る柱(保護と速度)を分けて考えるのが要点だ。
出力ベース、状態ベース、コミュニケーションベースの検証
概要
検証スタイルの三分類。
ひとつ上の「観察可能な振る舞いのテスト」とは軸が違う。観察可能な振る舞いは何を検証対象に選ぶか(対概念は実装詳細)を決める原則で、こちらは選んだ振る舞いをどう検証するか(対概念は他の検証手段)の分類である。観察可能な振る舞いを対象に据えたうえで、その振る舞いが戻り値に出るなら出力ベース、状態に出るなら状態ベース、外部への通信に出るならコミュニケーションベースで確かめる、という関係になる。
- 出力ベース:入力を与え、戻り値だけを検証する。副作用のない純粋関数に適す。
- 状態ベース:操作後のシステムの状態を検証する。
- コミュニケーションベース:協力者への呼び出しをモックで検証する。
目的/いつ使う
Khorikov は出力ベースを最良とする。
最も脆くなりにくく、リファクタリング耐性と保守性が高いからである。
状態ベースは次点。
コミュニケーションベースは脆さを招きやすいため、管理下にないプロセス外依存に限って使う。
設計を関数型寄り(副作用を端へ追い出す)に保つほど、出力ベースで書ける割合が増える。
TypeScript example
import { describe, it, expect, vi } from "vitest";
import { calcTax } from "./tax";
import { Wallet } from "./wallet";
import { Notifier } from "./notifier";
it("output-based", () => expect(calcTax(1000)).toBe(100));
it("state-based", () => {
const w = new Wallet(1000);
w.withdraw(300);
expect(w.balance).toBe(700);
});
it("communication-based", () => {
const sms = { send: vi.fn() };
new Notifier(sms).alert("hi");
expect(sms.send).toHaveBeenCalledWith("hi");
});
落とし穴
- 純粋関数化できるロジックをわざわざ状態ベースやコミュニケーションベースで書き、脆さを呼び込む。
- コミュニケーションベースをプロセス内協力者へ適用し、リファクタリングで壊れるテストを作る。
点検手順(この規範を満たすか確かめる)
- 各テストの検証スタイルを三分類のどれかへ仕分ける。
toHaveBeenCalled 系があればコミュニケーションベース、状態フィールドの参照があれば状態ベース、戻り値のみなら出力ベース。
- コミュニケーションベース・状態ベースで書いた箇所が、出力ベースで取れないかを問う。対象を副作用なしの純粋関数に割れるなら、戻り値検証へ書き換える。
grep -n "toHaveBeenCalled" <test> の各ヒットが掛かる協力者を、プロセス内かプロセス外かで仕分ける。プロセス内協力者へのコミュニケーションベースは脆さの証拠。
- 逆引き:そのコミュニケーションベース検証は、管理下にないプロセス外依存(メール・外部 API・バス)に限定されているか。管理下 DB やドメインオブジェクトに掛かっていれば違反。
- 直し方:出力ベース→状態ベース→コミュニケーションベースの優先順で書き換え、コミュニケーションベースは管理外プロセス外の最小限へ閉じ込める。
網羅設計での効かせ方
網羅を出力ベースで取れる割合を最大化すると、最も頑健な網羅になる。
出力ベースの網羅は入力と戻り値の組を数え上げるだけで、実装詳細に結合しない。
設計を関数型寄り(副作用を端へ追い出す)に保つほど、出力ベースで網羅できる範囲が広がる。
状態ベースの網羅は次点で、操作後の状態を確かめる箇所に使う。
コミュニケーションベースの網羅は脆いので、管理下にないプロセス外依存に限定する。
網羅設計の目安は、まず出力ベースで取れる所を全部取り、残りを状態ベースで覆い、最後に外部通信だけをコミュニケーションベースで網羅することだ。
この順で配分すると、脆い網羅をプロセス外依存の最小限へ閉じ込められる。
脆さの回避とテスト設計は別ファイルへ
ここまでは単体テストの基本規範を扱った。
脆いテストを避けるためのテスト設計と配置(脆さの回避、モック濫用の回避、Humble Object、四象限、契約による境界)は test-design-quality.md を参照。