プログラマのための IT 教科書

テストを書く

この部の 5 / 6 章 ・ 全体で 60 / 76 章 ・ 読了目安 40 分

この章を読むとできるようになること
  • 何をテストして何をテストしないか判断できる
  • テストが書きにくいコードの原因を設計側に見つけられる
  • 落ちるたびに直すだけのテストを作らない

テストは「バグを見つけるため」に書くものだと習います。それは半分だけ正しいです。

現場でテストが本当に効いてくるのは、コードを変える時です。 テストのないコードは、動いていても誰も触れなくなります。 この章は、その「触れるコード」をどう作るかの話です。

なぜテストを書くのか

本質は「変更を怖くなくする」こと

新人が最初に配属されて、こういう場面に必ず出会います。

このコード、直したいけど、どこに影響するか分からないから触りたくない。

これが触れないコードです。動いてはいます。障害も出ていません。 でも誰も直せないので、機能追加のたびに横に新しいコードが増えて、 似たような処理が3箇所に散らばっていきます。

テストがあると、この判断が変わります。

テストがない → 「壊れるかもしれない」→ 触らない → 腐る
テストがある → 直して実行 → 通る → 出せる

テストは、変更した時に「壊れていないこと」を数秒で確認する仕組みです。 バグの発見は副産物で、本命は変更のコストを下げることにあります。

「動作確認しました」との違い

手で動かして確認するのは、その瞬間の1回きりです。 半年後に誰かが隣の処理を直した時、その確認は再現されません。

手動確認自動テスト
実行コスト毎回、人の時間数秒、何度でも
再現性手順が人によって違う常に同じ
半年後誰も手順を覚えていないそのまま動く
変更時全部やり直す差分だけ見ればよい
テストは未来の自分への引き継ぎ書

テストコードは、「この関数はこう振る舞うべきだ」という仕様の記録です。

ドキュメントは腐りますが、テストは腐ると CI が落ちるので、 嘘のまま放置されません。これがテストが最強のドキュメントと言われる理由です。

何をテストして、何をテストしないか

「全部テストする」は現実的ではありませんし、正しくもありません。 テストコードも保守対象なので、価値のないテストは負債になります。

書くべきもの

対象例
分岐を持つロジック割引率の計算、権限判定、状態遷移
境界値・エッジケース0件、1件、上限ちょうど、空文字、null、負の数
一度壊れたところバグ修正時に、再発防止のテストを足す
他の人が使う関数ライブラリ的な共通処理

判断の目安はこうです。

if が入っている関数は、テストを書く候補です。

分岐があるということは、条件によって振る舞いが変わるということで、 そこが読み違えや実装漏れの起きる場所だからです。

書かなくていいもの

対象理由
getter / setter値を返すだけ。壊れようがない
フレームワークの機能ルーティングや ORM は本家がテスト済み
型で保証されていることTypeScript の型・Go のコンパイラの仕事
単なる委譲return repo.find(id) だけの関数
// これをテストしても、何も保証していない
test('getName は name を返す', () => {
  const user = new User({ name: '田中' });
  expect(user.getName()).toBe('田中');
});

このテストが落ちる状況を想像してみてください。ほぼありません。 落ちない可能性が高いテストは、書いても情報が増えません。

カバレッジ100%は目的ではない

カバレッジは「テストが通った行の割合」であって、 「正しさを確認できた割合」ではありません。

// カバレッジ 100%。でも何も検証していない
test('calculateTotal', () => {
  calculateTotal(order); // 呼んだだけ。expect がない
});

これで行カバレッジは埋まります。意味はありません。

カバレッジを目標値にすると壊れる

「カバレッジ80%必達」を目標に置くと、数字を埋めるためのテストが量産されます。

getter のテスト、assert のないテスト、実装をそのままコピーした期待値。 どれもカバレッジは上がりますが、バグは1つも見つけません。

カバレッジは目標ではなく、見落としを探すための地図として使ってください。 「この分岐、誰も通っていないな」に気づくためのものです。

AAA パターン — テストは読み物でもある

テストは3段に分けて書きます。Arrange(準備)/ Act(実行)/ Assert(検証) です。

test('継続3年の会員には10%の割引が適用される', () => {
  // Arrange: 前提を用意する
  const member = createMember({ years: 3 });
  const order = createOrder({ subtotal: 1000 });
 
  // Act: テスト対象を1回だけ呼ぶ
  const result = applyMemberDiscount(order, member);
 
  // Assert: 結果を検証する
  expect(result.total).toBe(900);
});

この形を守ると、どこを読めば何が分かるかが固定されます。 レビュアーは Act の1行だけ見れば「何をテストしているか」が分かります。

Act は1回だけ

// 悪い: 何を検証したいテストなのか分からない
test('注文の処理', () => {
  const order = createOrder();
  addItem(order, itemA);
  expect(order.total).toBe(500);
  addItem(order, itemB);
  expect(order.total).toBe(1200);
  applyCoupon(order, coupon);
  expect(order.total).toBe(1080);
});

これが落ちた時、原因が3箇所のどこにあるか分かりません。 1テスト1検証が原則です。分けてください。

名前で「何を保証しているか」を伝える

// 何が保証されているか分からない
test('applyCoupon のテスト', ...)
test('test1', ...)
 
// 名前が仕様になっている
test('有効期限切れのクーポンは適用できない', ...)
test('同じクーポンは同一注文に2回使えない', ...)
test('割引後の金額が0円未満になることはない', ...)

CI が落ちた時、人が最初に見るのはテスト名だけです。 test1 failed では何が壊れたか分かりませんが、 有効期限切れのクーポンは適用できない failed なら即座に見当がつきます。

テスト名を先に並べると、仕様の穴が見つかる

実装の前に、テスト名だけを箇条書きしてみてください。

  • 正常に適用できる
  • 期限切れは弾く
  • 2回は使えない
  • 金額が0円未満にならない
  • 上限額を超えるクーポンを、少額の注文に使ったら?

最後の1行のように、書き出して初めて気づく仕様が必ず出てきます。 実装してから気づくより、はるかに安く済みます。

テストが書きにくいのは、設計のサイン

新人が最初にぶつかる壁がここです。

テストを書こうとしたら、書けなかった。

この時、「テストが難しい」のではありません。 そのコードの設計に問題があることを、テストが教えてくれています。

書きにくくなる原因は、だいたいこの4つです。

原因症状
時刻time.Now() / new Date() を関数の中で呼んでいる
乱数・ID生成Math.random() / uuid.New() を中で呼んでいる
グローバル状態シングルトン、パッケージ変数、環境変数を直接読む
外部依存関数の中で DB や HTTP を直接叩いている

共通する解決策は1つです。

中で作らず、引数で受け取る。

時刻 — TypeScript

// テストできない: 実行するたびに結果が変わる
export function isExpired(token: Token): boolean {
  return token.expiresAt < new Date();
}

このテストを書こうとすると、「今から1時間後の日付」のような 相対的な値を作ることになり、テストが読みづらくなります。 日付をまたぐ深夜に落ちる、といった事故も起きます。

// 引数で受け取るだけで、急に書けるようになる
export function isExpired(token: Token, now: Date): boolean {
  return token.expiresAt < now;
}
test('有効期限を1秒でも過ぎていたら期限切れと判定する', () => {
  const token = { expiresAt: new Date('2026-01-01T00:00:00Z') };
  const now = new Date('2026-01-01T00:00:01Z');
 
  expect(isExpired(token, now)).toBe(true);
});

固定の日付を書けるので、読めば何を検証しているか分かるテストになりました。

時刻 — Go

// テストできない
func IsExpired(token Token) bool {
    return token.ExpiresAt.Before(time.Now())
}
 
// 引数で受け取る
func IsExpired(token Token, now time.Time) bool {
    return token.ExpiresAt.Before(now)
}
func TestIsExpired(t *testing.T) {
    token := Token{ExpiresAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
    now := token.ExpiresAt.Add(time.Second)
 
    if !IsExpired(token, now) {
        t.Error("期限を過ぎたトークンが有効と判定された")
    }
}

「今」を呼び出し側(HTTP ハンドラなど、アプリの一番外側)で1回だけ取得し、 そこから内側へ渡していきます。これは時刻もまた入力の1つという考え方です。

外部依存 — インターフェースで受け取る

// テストできない: 実 DB がないと1行も動かせない
func (s *OrderService) Cancel(ctx context.Context, id string) error {
    order, err := spannerClient.GetOrder(ctx, id) // グローバルなクライアント
    if err != nil {
        return err
    }
    if order.Status == StatusShipped {
        return ErrAlreadyShipped
    }
    return spannerClient.UpdateStatus(ctx, id, StatusCanceled)
}

このコードでテストしたいのは「発送済みならキャンセルできない」という 業務ルール1行だけなのに、DB を用意しないと確かめられません。

// 依存をインターフェースで受け取る
type OrderRepository interface {
    Get(ctx context.Context, id string) (*Order, error)
    UpdateStatus(ctx context.Context, id string, s Status) error
}
 
type OrderService struct {
    repo OrderRepository
}
 
func (s *OrderService) Cancel(ctx context.Context, id string) error {
    order, err := s.repo.Get(ctx, id)
    if err != nil {
        return err
    }
    if order.Status == StatusShipped {
        return ErrAlreadyShipped
    }
    return s.repo.UpdateStatus(ctx, id, StatusCanceled)
}

テスト側では、メモリ上で動く実装を渡します。

type fakeRepo struct {
    order *Order
}
 
func (f *fakeRepo) Get(ctx context.Context, id string) (*Order, error) {
    return f.order, nil
}
 
func (f *fakeRepo) UpdateStatus(ctx context.Context, id string, s Status) error {
    f.order.Status = s
    return nil
}
 
func TestCancel_発送済みならキャンセルできない(t *testing.T) {
    svc := &OrderService{repo: &fakeRepo{order: &Order{Status: StatusShipped}}}
 
    err := svc.Cancel(context.Background(), "order-1")
 
    if !errors.Is(err, ErrAlreadyShipped) {
        t.Errorf("want ErrAlreadyShipped, got %v", err)
    }
}

なお OrderRepository は、DB 実装のパッケージではなく、 それを使うサービス側に置くのが Go の流儀です。 こうすると、サービスは「自分が必要とする操作」だけを宣言でき、 インターフェースが小さく保たれます。小さいインターフェースは、 テスト用の実装も数行で書けます。

ある関数のテストを書こうとしたら、DB 接続と現在時刻のモックが必要で、準備コードが30行になりました。どう考えるべきですか。

Go の table-driven test

Go では、同じ関数に複数の入力を与えるテストを表として書くのが標準です。

func TestDiscountRate(t *testing.T) {
    tests := []struct {
        name  string
        years int
        want  float64
    }{
        {name: "新規会員は割引なし", years: 0, want: 0.00},
        {name: "1年で5%", years: 1, want: 0.05},
        {name: "3年で10%", years: 3, want: 0.10},
        {name: "上限を超えても20%で頭打ち", years: 100, want: 0.20},
        {name: "負の値は0として扱う", years: -1, want: 0.00},
    }
 
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := DiscountRate(tt.years)
            if got != tt.want {
                t.Errorf("DiscountRate(%d) = %v, want %v", tt.years, got, tt.want)
            }
        })
    }
}

この形の利点は3つあります。

  1. ケースの追加が1行で済む — 境界値を思いついたらすぐ足せる
  2. 表が仕様書になる — 上から読むと割引ルールが分かる
  3. t.Run でケースごとに名前が付く — 落ちた時にどのケースか即分かる
--- FAIL: TestDiscountRate/負の値は0として扱う
    discount_test.go:24: DiscountRate(-1) = -0.05, want 0

エラーを返す関数なら、want に加えて wantErr を持たせるのが定番です。

{name: "在庫不足", qty: 999, wantErr: ErrOutOfStock},

検証は errors.Is(err, tt.wantErr) で行います。 エラーメッセージの文字列比較は、文言を変えただけで壊れるので避けてください。

モックは「境界」だけ

モックは強力なので、覚えたてのうちは使いすぎます。 そして、使いすぎたテストはリファクタのたびに壊れます。

モックだらけのテストは、実装の写経になる

// 悪い例: 自分のコードまで全部モックしている
test('注文を作成する', () => {
  const validator = { validate: vi.fn().mockReturnValue(true) };
  const calculator = { calc: vi.fn().mockReturnValue(1000) };
  const formatter = { format: vi.fn().mockReturnValue('¥1,000') };
  const repo = { save: vi.fn() };
 
  const service = new OrderService(validator, calculator, formatter, repo);
  service.create(input);
 
  expect(validator.validate).toHaveBeenCalledWith(input);
  expect(calculator.calc).toHaveBeenCalledAfter(validator.validate);
  expect(formatter.format).toHaveBeenCalledWith(1000);
});

このテストが検証しているのは、「実装がこの順番で呼んでいること」だけです。 金額が正しいかは1行も確認していません。

しかも、calculator を使わない実装に変えたら、 振る舞いが同じでもテストは落ちます。 つまりこのテストは、リファクタを妨害する側に回っています。

境界だけモックする

モックにするのは、自分たちで制御できないものだけです。

モックにするモックにしない
外部 API(決済、メール送信、SMS)自分たちが書いたロジック
時刻・乱数純粋な計算・変換
課金が発生するもの値オブジェクト、DTO
極端に遅いもの同じパッケージ内のヘルパー
// 良い例: 外部決済サービスだけ差し替え、中のロジックは本物を使う
test('決済が失敗した注文は保存されない', async () => {
  const payment = {
    charge: vi.fn().mockRejectedValue(new PaymentDeclinedError()),
  };
  const repo = new InMemoryOrderRepository(); // 本物に近い偽物
  const service = new OrderService(repo, payment);
 
  await expect(service.create(input)).rejects.toThrow(PaymentDeclinedError);
  expect(repo.count()).toBe(0);
});

検証しているのが 「呼び出しの順番」ではなく「結果としての状態」 になっている点に 注目してください。実装をどう書き換えても、この保証は残ります。

モックは「本物と同じ振る舞い」を保証しない

モックは、あなたが「こう返ってくるはず」と思っている値を返します。

外部 API が実は 200 ではなく 202 を返していたり、 配列ではなく null を返す場合があったりしても、モックは教えてくれません。 モックが正しいという前提そのものがテストされていないためです。

だから、外部との繋ぎ込みは別途「結合テスト」で1回は本物に通す必要があります。 ユニットテストが全部通っても、それは繋がる保証にはなりません。

テストの種類と割合

テストは、対象の広さで3つに分かれます。

種類対象実行時間安定性量の目安
ユニット関数・型・クラス単体ミリ秒高い多い
結合DB や別モジュールを含む一連の処理秒中中くらい
E2Eブラウザから通しで操作分低い少ない

下にいくほど現実に近い代わりに、遅く・不安定・原因特定が難しくなります。 だから下が細い三角形(テストピラミッド)にするのが定石です。

E2E を厚くしすぎるとどうなるか

E2E を主軸にしたプロジェクトは、この順番で壊れていきます。

  1. E2E が増え、CI が40分かかるようになる
  2. たまに落ちるテストが出てくる(ネットワーク、描画の待ち、順番)
  3. 「また落ちた、再実行しよう」が習慣になる
  4. 本物のバグで落ちた時も再実行される
  5. 誰も CI を信じなくなる

E2E は「主要な導線が繋がっていること」を数本だけ確認するのに向いています。 ログインして商品を買えること は E2E で、 割引率の計算が正しいこと はユニットで確認します。

結合テストは本物に近い環境で回す

DB を含むテストは、モックではなく実物に近い環境で回すのが今の標準です。

  • コンテナで本物の DB を起動する(Testcontainers など)
  • クラウドサービスが提供するエミュレータを使う

「DB クライアントをモックしたテスト」は、SQL の書き間違いを1つも見つけません。 DB を絡めるなら、SQL が実際に通ることまで確かめて初めて意味があります。

壊れやすいテスト(Flaky test)

同じコードなのに、通ったり落ちたりするテストを Flaky と呼びます。 これは、テストが無い状態より有害です。

理由は単純で、誰も CI を信じなくなるからです。 落ちても「またあれか」で再実行され、本物の障害が素通りします。

原因1: 時刻依存

// 深夜0時をまたぐと落ちる
expect(formatDate(new Date())).toBe('2026-08-05');
 
// 月末に落ちる
expect(nextMonth(new Date()).getDate()).toBe(new Date().getDate());

対策は前述のとおり、時刻を引数で受け取ることです。 どうしても内部で取得する場合は、テスト用の固定時刻に差し替えます。

原因2: 実行順依存

let userId: string; // テスト間で共有された状態
 
test('ユーザーを作成する', () => {
  userId = createUser().id;
});
 
test('ユーザーを削除する', () => {
  deleteUser(userId); // 上のテストが先に走る前提
});

テストの実行順は保証されません(並列実行なら特に)。 各テストは、単独で実行しても通るように書きます。

Go では順序依存を検出するオプションがあります。

go test -shuffle=on ./...

原因3: sleep での待ち合わせ

// 最悪: マシンが遅い日に落ちる。速い日は無駄に待つ
await new Promise((r) => setTimeout(r, 1000));
expect(await getStatus()).toBe('done');

sleep は「たぶんこれくらいで終わるだろう」という祈りです。 CI のマシンは手元より遅いことが多く、そこで落ちます。

// 条件が満たされるまで待つ(タイムアウト付き)
await waitFor(() => expect(getStatus()).resolves.toBe('done'), {
  timeout: 5_000,
});

時間ではなく、状態を待ちます。

原因4: テスト間の状態共有

DB、グローバル変数、ファイル、キャッシュを共有すると、 前のテストの残骸が次のテストに影響します。

  • 各テストの前後で必ずクリーンな状態に戻す
  • 固定 ID(user-1)ではなく、テストごとに一意な値を使う
  • 並列実行するなら、テストごとに別スキーマ・別プレフィックスを使う
Flaky を見つけたら、無視せず即座に扱う

「たまに落ちるけど、再実行すれば通るから」は最も危険な状態です。

やるべきことは次のどれかです。

  1. その場で原因を直す(最善)
  2. 直せないならスキップして issue を立てる(誰も見ない不安定テストより明確)

放置だけはしないでください。 Flaky が2〜3個増えた瞬間から、 チームは CI の赤を無視し始めます。そうなると CI はただの飾りになります。

CI で回す

手元で通ることと、チームで通ることは違う

手元の環境には、あなたが気づいていない前提が積み上がっています。

手元だけにあるもの起きること
ローカルの .envCI で環境変数が無くて落ちる
前に流したテストの DB データCI のクリーンな DB では落ちる
インストール済みのツールCI に無い
特定の OS・タイムゾーンUTC の CI で日付がずれる
コミットし忘れたファイル他人の環境でビルドが通らない

CI は「本当にコミットされたものだけで動くか」を確認する場です。 「自分の環境では通りました」は、レビューで最も信用されない言葉の1つです。

最低限 CI で回すもの

# GitHub Actions の例(要点だけ)
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      # TypeScript
      - run: npm ci          # lock ファイルどおりに入れる(npm install ではない)
      - run: npm run typecheck
      - run: npm run lint
      - run: npm test
 
      # Go
      - run: go vet ./...
      - run: go test -race -count=1 ./...
オプション意味
-raceデータ競合を検出する。CI では必ず付ける
-count=1テスト結果のキャッシュを無効化する
-shuffle=on実行順をランダム化し、順序依存を炙り出す

-race を CI で回す理由

Go の並行処理のバグは、手元では再現しないのに本番で起きる種類のバグです。 -race を付けると、実際に競合が起きた瞬間に検出して落ちます。

WARNING: DATA RACE
Write at 0x00c0000a4010 by goroutine 8:
  main.(*Counter).Increment()

実行は遅くなる(数倍)ので、手元では素で回し、 CI では必ず -race を付けるのが一般的な運用です。

CI が赤いまま次の作業に進まない

CI が落ちているのに別の PR を積み上げると、 どの変更が原因か分からなくなり、切り分けに何倍もの時間がかかります。

赤くしたら、その場で直すか revert する。 これがチームの基本ルールです。 自分が原因で main を止めていると気づいたら、すぐ共有してください。 黙って直そうとするより、報告した方が早く解決します。

新人が今日からできること

いきなり全体にテストを整備する必要はありません。この順で始めてください。

  1. バグを直す時、まず落ちるテストを書く — 再現できて、修正後に緑になる
  2. 自分が新しく書いた関数のうち、if があるものにだけ書く
  3. テスト名を日本語で、仕様として書く
  4. 書きにくいと感じたら、設計を疑って先輩に相談する

1番目が特に効きます。バグ修正は「本当に直ったか」の確認が必要なので、 テストを書く理由が最も明確な場面です。そして同じバグの再発を永久に防げます。

実務の落とし穴まとめ

  1. カバレッジを目標値にする — assert のないテストが量産される
  2. モックだらけのテスト — 実装の写経になり、リファクタを妨害する
  3. Flaky の放置 — 誰も CI を信じなくなり、本物の障害を見逃す
  4. sleep で待ち合わせ — CI の遅いマシンで落ちる。状態を待つ
  5. E2E を厚くしすぎる — 遅い・不安定・原因が特定できない
  6. テスト間の状態共有 — 単独実行や並列実行で落ちる
  7. 「手元では通りました」 — CI はコミットされたものだけで動く

まとめ

  • テストの本質は変更を怖くなくすること。バグの発見は副産物
  • テストが無いコードは、動いていても誰も触れなくなる
  • 分岐とエッジケースには書く。getter やフレームワークの機能には書かない
  • カバレッジ100%は目的ではない。見落としを探す地図として使う
  • AAA(Arrange / Act / Assert)で書き、Act は1回。名前を仕様にする
  • テストが書きにくいのは設計のサイン。時刻・乱数・依存は引数で受け取る
  • モックは境界(外部サービス)だけ。自分のコードは本物を使う
  • Go では table-driven test でケースを表にする
  • Flaky なテストは無いより有害。放置せず、直すかスキップして記録する
  • CI で回して初めて意味がある。Go は -race を CI で必ず付ける

公式ドキュメント

迷ったら一次情報に戻ってください。

対象リンク
Go testing パッケージhttps://pkg.go.dev/testing
Vitesthttps://vitest.dev/
Playwright(E2E)https://playwright.dev/docs/intro
Google Testing Bloghttps://testing.googleblog.com/

章末問題

CI で「たまに落ちるが、再実行すると通る」テストが3つあります。どう対応すべきですか。

テストは、あなたが今日書いたコードを、 半年後の誰かが安心して変えられるようにするための仕組みです。

「テストを書く時間がない」と感じたら、思い出してください。 時間を奪っているのはテストではなく、テストが無いせいで触れなくなったコードです。

次の章では、テストが通っていても間違っているコードを集めます。 浮動小数点、境界値、文字コード、時刻——実務で踏み抜く罠の図鑑です。

読み終わったら記録しておくと、目次で進み具合が分かります。