はじめに:キャッシュのキー名ひとつで全ユーザーがログアウトした話
ある木曜の夜、ユーザーサービスのリファクタリングをリリースしました。変更内容は、Redis に保存しているプロフィールのキャッシュを camelCase から snake_case に揃えるだけ。レビューも通り、CI はすべて緑でした。
リリースはブルーグリーン方式で、切り替えの途中は新旧両方のバージョンが同じ Redis を見ています。新バージョンが書いた display_name を旧バージョンが読みにいき、displayName が見つからず null に。セッション検証がそこでコケて、約 15 分間、ログイン中のユーザーが次々と強制ログアウトされました。
単体テストは 700 本以上ありました。でも Redis はすべてモックで、「新旧バージョンが同じキャッシュを共有する」という状況を再現したテストは 1 本もありませんでした。
この記事では、その後チームで結合テストをどう組み直したかを、失敗も含めてまとめます。
結合テストを一言でいうと
自分のコードと、自分が完全にはコントロールできない何か(DB、キャッシュ、メッセージキュー、他チームの API、本番の設定値)が、ちゃんと噛み合っているかを確かめるテストです。
単体テストは箱の中身のロジックを確認します。結合テストは箱と箱のつなぎ目を確認します。用語の定義やトップダウン・ボトムアップといった進め方まで体系的に押さえたい方は、integration testing についての解説記事がまとまっているので、そちらも参考になると思います。この記事では、実際に運用して効いたことだけに絞ります。
ポイントは、壊れ方がまったく違うことです。単体テストはロジックが間違っているときに落ちます。結合テストは「前提」が間違っているときに落ちます。そして前提は、誰も見ていないところで静かに変わります。
緑の CI が教えてくれなかったこと
正直に言うと、当時のチームはカバレッジの数字をかなり気にしていました。ユーザーサービスは 85% 超え。それでも本番は落ちました。
ここで少し反論を受けそうな意見を書きます。自分たちで持っている DB やキャッシュをモックするのは、多くの場合テストではなく「安心材料」です。 モックには「このミドルウェアはこう動くはず」という自分の思い込みがそのまま書かれています。そして検証すべきなのは、まさにその思い込みのほうです。
単体テストが不要という話ではありません。料金計算やバリデーションのような分岐の多いロジックには今も単体テストを書いています。ただ、「単体テストが全部通った」をデプロイの安全性の根拠にするのはやめました。
数字の面でも、本番に出てから見つかる問題のコストは小さくありません。2024 年の DORA レポート(Google Cloud の DevOps 調査)では、変更失敗率がエリート層で約 5%、ローパフォーマー層で約 40% と大きな差が出ています。この差のすべてがテストのせいではありませんが、つなぎ目のバグをデプロイ前に潰せているかどうかは無関係ではないはずです。
結合バグが潜んでいた場所
過去のインシデントを振り返ると、純粋なロジックのバグはほとんどありませんでした。偏っていたのは次の 3 か所です。
1. 共有されるデータの形(キャッシュ・DB スキーマ)
冒頭の Redis の件がまさにこれです。ローリングアップデートやブルーグリーンでは、新旧のコードが同じデータを同時に読み書きする時間が必ずあります。カラム名の変更、キャッシュのキー形式の変更、JSON のフィールド名変更は、すべて同じ罠にはまります。
対策は地味で、「広げてから縮める」です。新旧両方の形式を読めるコードを先に出し、データを移行し、古い形式のサポートは次のリリースで消します。そして「旧形式のデータを新コードが読めるか」を結合テストで確認します。
2. サービス間の API 契約
別の案件で、決済サービスがステータスに PENDING_REVIEW を追加しました。注文サービス側の処理は、知らないステータスが来たら例外を投げる実装でした。追加自体はまっとうな変更なのに、注文履歴画面が一部のユーザーで真っ白になりました。
どちらのサービスもテストはしっかり書かれていました。ただ、実際のレスポンスを相手とやり取りするテストがなかっただけです。
3. 環境ごとの設定の差
これは短く。ステージングでは外部 API のタイムアウトが 30 秒、本番ではサイドカーのプロキシ側で 5 秒に切られていました。重いレポート生成 API だけが本番でだけ 504 を返す、という調査しづらいバグになりました。本番と同じコンテナイメージ、同じ設定で結合テストを回していれば、もっと早く気づけたはずです。
今のテスト構成
ルールはシンプルです。自分たちが持っている・デプロイしているものは本物を動かす。他社や他チームのものはネットワークの境界でスタブにする。
Postgres、Redis、メッセージブローカーはテスト実行時にコンテナで立ち上げます。決済代行やメール配信のような外部 API は HTTP レベルでスタブにし、そのスタブは定期的に本番で記録した実際のレスポンスで更新しています。放っておくとスタブはすぐに「フィクション」になるからです。
冒頭の事故を防ぐなら、こんなテストになります。Jest、Supertest、Testcontainers を使い、本物の Redis に対して旧形式のキャッシュを読めるかを確認しています。
const { RedisContainer } = require('@testcontainers/redis');
const request = require('supertest');
const { createApp } = require('../src/app');
describe('GET /profiles/:id(本物の Redis)', () => {
let container, app;
beforeAll(async () => {
container = await new RedisContainer('redis:7-alpine').start();
app = await createApp({ redisUrl: container.getConnectionUrl() });
}, 60_000);
afterAll(async () => {
await app.close();
await container.stop();
});
test('旧バージョンが書いたキャッシュ形式も読める', async () => {
await app.redis.set('profile:42', JSON.stringify({ displayName: '田中' }));
const res = await request(app.server).get('/profiles/42').expect(200);
expect(res.body.display_name).toBe('田中');
});
});
モックでは「Redis にこう入っているはず」を自分で書くことになりますが、このテストは実際に旧形式のデータを入れて、新しいコードがそれを読めるかを確かめています。リリースのたびに新旧の互換性を機械的にチェックできるのが大きいです。
運用面で気をつけていることもいくつかあります。
- コンテナはテストごとではなくファイルごとに 1 つ。 起動が一番重いので。各テストはユニークな ID でデータを作り、テーブルやキーを消して回る運用はやめました。並列実行しても干渉しません。
- 結合テストはプルリクエストごとに実行。 夜間バッチにしていた時期もありますが、結果を誰も見なくなりました。
- CI のイメージは本番と揃える。 ベースイメージ、タイムゾーン、ロケールまで同じにします。
フレーキーなテストへの対応も決めています。不安定になったテストはその日のうちに隔離し、担当者と期限をつけます。「とりあえずリトライ 3 回」は禁止です。リトライは本物の競合状態を「たまに 2 回目で通るテスト」に変えてしまいます。
やらかしたこと
データ形式の変更とコード変更を同じリリースに入れた。 冒頭の件です。正直に言うと、これは一度では懲りませんでした。
外部 API の手書きモックを 1 年以上放置した。 相手側はその間にエラーレスポンスの形式を 2 回変えていました。モックは古い形式のままで、本番ではリトライ処理が一度も発火していなかったことに後から気づきました。
全テスト共通の巨大なシードデータ。 誰かが自分のテストのために 1 行いじると、関係ないテストが 3 本落ちる。修正に追われる時間のほうが、機能開発より長い週もありました。テストごとにデータを作る方式に変えて、ようやく落ち着きました。
これは業界として新しい話でもありません。2002 年に NIST が公表した調査では、ソフトウェアのバグによる米国経済の損失は年間約 595 億ドルと推計され、そのうち約 220 億ドルはテスト基盤の改善で早期に欠陥を見つければ避けられたとされています。数字は古いですが、構図は今も変わっていないと感じます。
ひとつだけ持ち帰るなら
次にデータの形を変えるときは、「旧バージョンのコードが新しいデータを読めるか」「新バージョンが古いデータを読めるか」 を結合テストで確認してください。半日あれば仕組みは作れて、リリースで一番怖い瞬間がかなり退屈になります。
最後に質問です。単体テストは全部通ったのに、システムのつなぎ目で起きたバグで一番痛かったものは何でしたか? どんなテストがあれば防げたか、ぜひコメントで教えてください。