実装の変更に振り回されないこと
「実装の内部構造を変えただけでテストが壊れる」状態は、リファクタリングを躊躇させるので、テストがあることが逆に開発の足枷になります。
学習者リファクタしただけでテストが赤くなる…。これってテストが悪いの?それともコードが悪いの?

テストの偽陽性とは
テストの偽陽性とは、テストが失敗したが、実際にはバグがないことを意味します。
偽陽性が少ないことは、リファクタリングの耐性を高めることにつながります。
逆に、偽陽性が多いテストは、失敗しても「またテストが壊れただけかもしれない」と思われるようになります。
テストの価値は、赤くなったときに開発者がすぐ反応できることです。失敗の信頼性が落ちると、テストは警報として機能しなくなります。
偽陽性でテストの意味がなくなってしまう
開発でコードを記述していて、問題あるコードを事前に検知できることがテストの意味ですが、偽陽性が多いと、その信頼性が失われてしまいます。
テストの偽陽性を減らす方法
最終的な結果を確認することで、偽陽性を減らすことができます。
テストが確認すべきなのは「外部から観測できるもの」です。これは大きく3つあります。
- 戻り値
- 状態の変化(DBに保存された、オブジェクトのプロパティが変わった等)
- 外部への呼び出し
区別の基準はシンプルで、「その検証対象を変えたとき、ユーザーや外部システムが気づくか」です。
気づくなら振る舞い、気づかないなら実装の詳細です。
偽陽性を生みやすいテストの例
次のテストは、ユーザー名を正規化する処理を確認しているように見えます。
it('ユーザー名を正規化する', () => {
const normalizer = vi.fn((name: string) => name.trim().toLowerCase());
const service = new UserService(normalizer);
service.createUser(' Alice ');
expect(normalizer).toHaveBeenCalledWith(' Alice ');
});このテストは、normalizer が呼ばれたことを確認しています。
しかし、ユーザーから見て重要なのは「保存されたユーザー名が alice になっていること」です。内部で normalizer を呼ぶかどうかは実装の詳細です。
次のように、観測できる結果を見る方がリファクタリングに強くなります。
it('ユーザー名の前後の空白を取り除き、小文字で保存する', () => {
const repository = new InMemoryUserRepository();
const service = new UserService(repository);
service.createUser(' Alice ');
expect(repository.findByName('alice')).toEqual({
name: 'alice',
});
});このテストなら、内部で trim と toLowerCase を直接呼んでも、別の関数に切り出しても、振る舞いが同じなら壊れません。
先生テストが実装の「手順」を監視しすぎると、リファクタリングの自由度が下がります。できるだけ「結果」を見ましょう。
実装の詳細に依存しているサイン
次のようなテストは、偽陽性が増えやすい傾向があります。
- privateメソッドや内部関数の呼び出しを検証している
- メソッドの呼び出し順を細かく検証している
- テスト対象の内部データ構造を直接見ている
- テスト名が「何を保証するか」ではなく「どのメソッドを呼ぶか」になっている
- リファクタリングだけで大量のテストが失敗する
もちろん、外部APIの呼び出しやメール送信など、呼び出し自体が振る舞いになるケースもあります。
大事なのは、「その呼び出しを変えたら、ユーザーや外部システムが困るか」を考えることです。
リファクタリング耐性を高める書き方
リファクタリング耐性を高めるには、次の順で検証方法を選びます。
- 戻り値で確認できるなら、戻り値を検証する
- 戻り値がないなら、状態の変化を検証する
- 外部への通知や保存が本質なら、呼び出しを検証する
この優先順位は、後の章で扱う「出力値ベース」「状態変化ベース」「コミュニケーションベース」の違いにもつながります。
偽陽性を減らすことは、単にテストを壊れにくくする話ではありません。
安心してリファクタリングできる状態を作り、アプリケーションを長く変更し続けるための土台になります。
