はじめに
CIで時々落ちる並行テスト、DBが絡んで遅い統合テスト、戻り値を見てもまだ処理が走っている非同期テスト。
自分もこの3つに何度もハマってきました。
ただ、よく見るとどれも「いつ終わるか」「どの順で動くか」「前の状態が残っていないか」が揺れているだけでした。
そこに気づいてから、対処の仕方を整理しやすくなったので、同じ視点で順にたどっていきます。
TL;DR
- 並行・非同期・永続化のテストが難しいのは、3領域とも実行が不確定(同じ入力でも結果や順序が揺れる)になるから
- どの領域でも、やることは似ています。揺れる部分をそのままテストせず、外から安定して確認できる形に変える
- テストできる構造を逆算して本体側を設計するのが、3領域に共通する対処です
1. なぜこの3領域は「最難所」なのか
普段書いている単体テストは、実は3つの暗黙の前提に支えられています。その前提が崩れる領域こそが「並行・非同期・永続化」です。まずは前提と崩れ方を整理します。
1-1. 通常のテストが置いている3つの前提
普段のテストが楽に書ける理由は、次の3点が揃っているからです。
| 前提 | 意味 |
|---|---|
| 同期実行 | 呼び出しが戻ったら処理は終わっている |
| 即時観測 | 戻り値や例外でその場で結果が分かる |
| 状態が消える | テストごとに状態が独立しており、次のテストに影響しない |
この3つが揃っていると、同じ入力なら同じ結果になります。
呼び出した処理はその場で終わり、戻り値や例外で結果も分かる。
この記事では、こういう安定した状態をざっくり「決定論的」と呼びます。
1-2. 3領域では前提が崩れる
ところが、3領域ではこの3つの前提のどれかが崩れます。
- 永続化ではDB周りに状態が残り続け、テスト間で干渉する
- 並行性ではスレッドの実行順をこちらで制御できない
- 非同期では呼び出しが戻った時点では処理がまだ走っている
前提が1つでも崩れると、同じ入力でも結果が揺れます。同じ入力で結果が揺れる状態を、この記事では非決定論的と呼びます。
1-3. 共通する正体:実行の不確定性
3領域の難しさは、要するに実行が揺れているということです。
不確定だと、テスト結果が運次第になります。
10回中9回パスして1回落ちる、というテストを、この記事では「時々落ちるテスト」と呼びます(一般には flaky test と呼ばれます)。
「たまたまだろう」と再実行を繰り返すうちに本物の不具合のシグナルまで無視するようになり、フィードバックそのものが信用できなくなっていきます。
この記事では、この「揺れる実行を、外から安定して確認できる形にする」ことを軸にします。
いくら処理が安定していても、外から状態や結果を確認できなければテストになりません。
3領域でやることは似ています。揺れる部分をそのままテストしようとせず、外から安定して確認できる形に変える。
違うのは、何が揺れているかです。永続化なら状態、並行なら実行順、非同期なら完了タイミングです。
2. 永続化:状態が残ることへの対処
永続化テストでまず問題になりやすいのは、状態がテストをまたいで残ることと、DBなどの外部リソースが介在することです。
対処は「既知状態を作る」「境界を明示する」「層ごとに役割を分ける」の3つです。
2-1. 開始時に既知状態を作る、という選択肢
直感的には「テストの最後にロールバックして元に戻せばいい」と思いがちです。
ロールバックで戻す方式も広く使われています。ただ、失敗時の調査やコミット時の挙動まで見たい場合は、テスト開始時に既知状態へリセットする方式が効きます。
理由は3つあります。
- 失敗時の診断材料が残る: テスト終了時に消してしまうと、なぜ落ちたかをDBで追えない。開始時リセットなら最後の状態がそのまま残る
- スイート全体での独立性は十分に保たれる: 次のテストが開始時に掃除してから走るので、テスト間干渉は起きない
- コミット後に見える挙動まで検査範囲に入れられる: ロールバック方式だけに寄せると、実際にコミットして初めて見える挙動を見逃すことがあります。ORMのフラッシュ、DB制約、トランザクション境界をまたいだ読み直しなどがその代表です
ORMを使っている場合、メモリ上の変更がすぐDBに出ているとは限りません。
フラッシュやコミットのタイミングで初めて見える挙動があるので、そこまで検査範囲に入れたい場合は、実際にコミットする形のテストが向いています。その場合、開始時に既知状態を作っておくと扱いやすくなります。
「終わったら片付ける」ではなく「始める前に整える」と置くだけで、診断のしやすさと検査範囲の広さが両方手に入ります。
2-2. トランザクション境界を明示する
トランザクション はDBに対する一連の操作の塊で、コミット で確定、ロールバック で取り消しになります。
永続化テストでは、このトランザクション境界をテストコードから見えるところに明示するのが定石です。
たとえばカートに商品を追加する処理をテストする場合、次のような書き方を意識します。
def test_カートに商品を追加するとDBに反映される():
db_cleaner.reset() # 開始時に既知状態へ
with transactor.run(): # トランザクション境界を明示
cart_repo.add(cart_id="C1", item_id="ITEM-001", qty=2)
with transactor.run(): # 別トランザクションで読み直す
cart = cart_repo.find("C1")
assert cart.items[0].qty == 2
書き込みと読み出しを別トランザクションで囲うと、コミット時の挙動も検査範囲に入ります。
「テスト中で何が起きているか」がコードを読むだけで見える、という状態を作れば、結果も安定します。
2-3. 速度問題は3層の役割分担で解く
実DBに対するテストは、どうしても遅くなります。
これに対して「全テストを速くする」を目指すと、無理な抽象化や偽物のDBで重要な検査が抜けます。
現実的なのは、速さと現実性を1つのテストで両立させようとせず、役割を分けることです。
- 単体テスト: 永続化のインターフェースをモックで置き換え、メモリ内で完結させる。速い代わりに、ORMやSQLの実挙動は検査できない
- 統合テスト: 実DBに対して細かい粒度で検査する。マッピング・クエリ・整合性制約の確認はここで行う
- E2Eテスト: 本番に近い環境で、システム全体を通して走らせる。遅いので、頻度は低めに抑える
3層に分けると、速いフィードバックを単体・統合で受け取りつつ、現実性の検査をE2Eで担保できます。1つの層で両立させない、というのが現実的なやり方です。
「全部速くする」は一見魅力的に聞こえますが、検査範囲を犠牲にした結果、本番でしか出ない不具合を量産することが多いです。
速さと現実性は、構造(層)で分けるのが基本になります。
3. 並行性:スレッドの不確定性への対処
並行テストの不確定性は、複数スレッドをどう調整しているか(同期の仕組み)を直接観測しにくいことに由来します。
スレッド実行順はOSのスケジューラが決めるので、こちらでは制御できません。
対処は「機能と同期を分離する」「機能だけを単一スレッドで検査する」「同期をストレステストで観測する」の3段構えです。
3-1. まず機能と同期を分離する
並行コードの中には、2つの関心事が混ざっています。
たとえば複数の通知サービスに並行で配信し、全部完了したら呼び出し元に1回だけ通知する処理を考えます。
この処理には配信ロジック(機能)と「全部完了の検知」(同期の仕組み)の2つが混ざっています。
両方を1つのオブジェクトに閉じ込めると、テストで落ちたときに「機能のバグなのか、同期のバグなのか」が切り分けられません。
対処はシンプルで、タスクの実行手段を外から差し込めるようにする ことです。
タスクを実行する仕組みを外から渡せるようにし、テストでは単一スレッドの実行器、本番ではスレッドプールを渡せるようにする、という形です(このように依存を外から渡す作りは、一般に依存注入と呼ばれます)。
これによって、どこで並行実行しているかが外から見えやすくなります。
3-2. 機能だけを単一スレッドで検査する
差し込みの仕組みができたら、機能テストでは単一スレッドで順番に走らせる実行器を渡します。
テストの呼び出し後にキューに積まれたタスクを順次走らせるだけなので、結果は完全に決定論的になります。
| 観点 | 機能テスト | ストレステスト |
|---|---|---|
| 何を検査するか | 機能ロジックの正しさ | 同期の仕組みの正しさ |
| スレッド数 | 単一スレッド | 多スレッド |
| 観測対象 | 入力に対する出力・呼び出し回数 | スレッド数によらず成立する性質 |
| 想定される失敗 | 機能の誤り | 競合状態・取りこぼし |
機能テストは「スレッドが絡まない世界」で書けるので、アサーションも素直に書けます。
テストが本来の役割(機能の正しさの検査)に専念できる、という状態が手に入ります。
3-3. 同期の仕組みはストレステストで観測する
機能が正しくても、同期が壊れていれば本番では事故が起きます。
スケジューラの実行順を完全には制御できないので、実務では外から確認できる不変条件を立てて、多スレッド・多数回で検証することが多いです。
- 「検索完了通知は何スレッドが走ろうと1回だけ」
- 「カウンタの最終値は、加算処理を呼んだ回数と一致する」
- 「キューに入れた件数と、処理された件数が一致する」
こうした不変条件を多スレッド・多数回で検証し、並行性バグを炙り出すのが ストレステスト です。
狙いは レースコンディション(実行順依存で結果が変わるバグ) を症状として捕まえることで、確率的な信頼度の確保にあたります。
順序のコツとして、まず「壊れることを観測する」テストを書いてから同期を入れて直します。
壊れないテストでは同期バグを検出できているか判断できない、という「test the test(テストをテストする)」の発想です。
4. 非同期:観測タイミングの不確定性への対処
非同期処理ではメソッドが戻ってきても処理は終わっておらず、テストはいつアサートすればいいのか分からなくなります。
非同期テストの不確定性は 観測タイミング に由来します。
対処は「成功を待ち、タイムアウトで失敗とみなす」「待ち方を選ぶ」「時間依存を外部化する」の3つです。
4-1. 大原則:成功を待つ/タイムアウトで失敗
同期テストの作法(呼び出し直後にアサート)は、非同期では使えません。戻ってきた瞬間にはまだ処理が走っているからです。
非同期テストの大原則はこうです。
- 期待する状態に到達するまで待つ
- 一定時間内に到達しなければ、失敗とみなす
この原則が成立するためには、テストしたい挙動が 観測可能な効果 を持っていなければなりません。
「外から見える形で何かが変わる」がない処理は、そもそも非同期テストの対象にできない、ということです。
もう1つの原則は、成功したらすぐ次に進むことです。
タイムアウトはあくまで失敗判定のためのもので、成功時に毎回その時間を待つ作りにはしません。
4-2. 待ち方の2方式:イベントを待つ/ポーリングする
「期待状態に到達するまで待つ」の実装には2方式あります。
| 方式 | 仕組み | 強み | 弱み |
|---|---|---|---|
| listening | システムからのイベントを購読して待つ | 速い・取りこぼしが少ない | システム側にイベント発信の実装が必要 |
| sampling | 状態を一定間隔でポーリングして待つ | 汎用・どんなシステムでも適用可 | ポーリング隙間の取りこぼし・負荷 |
多くのシステムはイベントを発信していないので、sampling が現実解になることが多いです。
1つのテストで両方を組み合わせることもあります(UI側は sampling、サーバ通信側は listening、など)。
タイムアウト値は 1箇所で管理する のが鉄則です。
環境差(CIマシンとローカル)やシステムの成長で値を調整する必要が必ず出るので、散らばっているとメンテナンスができなくなります。
4-3. 非同期テストの典型的なアンチパターン
非同期テストには名前がついている典型的なアンチパターンが3つあります。1つずつ見ていきます。
時々落ちるテスト
§1-3 で触れた「合否がチラつくテスト」です。非同期テストでこれが起きる原因は、ほぼ2つに絞られます。
- タイムアウトが処理時間に近すぎる(運が悪いと間に合わない)
- 同期判定の論理が不正確(待つべき状態と違うものを待っている)
対処は「タイムアウト値を1箇所で管理して見直しやすくする」と「成功判定の論理を見直す」の2点です。
「再実行で通った」で済ませると、後で本物の不具合と区別がつかなくなります。
先走って通ってしまうテスト
システムより先に走り抜けて、まだ何も起きていないのにテストがパスしてしまうケースです(runaway test と呼ばれます)。
本当はバグがあるのにテストが通ってしまう現象はテストの信頼を一番壊します。
最終状態が初期状態に戻ることをアサートしてしまうと発生しやすいパターンです。
たとえば「在庫イベントが処理されると一時的に保留在庫が増え、別イベントで0に戻る」挙動を考えます。
このとき最終状態(0)だけをアサートすると、システムがまだ処理を始めていない状態でもテストが通ってしまいます。
対処は 中間状態を経由したことを確認するアサーションを挟む ことです。
def test_別地域の取引は保留在庫に含まれない():
system.send(取引イベント_地域A_数量3)
wait_for(lambda: system.pending_stock() == 3) # 中間状態を確認
system.send(取引イベント_地域B_数量5)
wait_for(lambda: system.pending_stock() == 3) # 含まれないことを確認
最初の wait_for で「処理が走った」を確認してから次のアサートに進む、という構造にすれば、runaway を防げます。
ポーリングの隙間で変化を見落とすテスト
ポーリングの隙間で状態変化を見落とす現象は、sampling の弱みそのものです。
状態が一瞬変化してすぐ別の値に戻ると、ポーリングのタイミング次第で変化を見逃します。
対処は 「刺激する → 観測する → 安定を確認する」のフェーズ構造でテストを書くことです。
1つのテストの中で複数の刺激を与える場合、各刺激の後に「変化が観測でき、かつ安定した」ことを確認してから次に進みます。
これによって、ポーリング隙間で次の刺激を撃ち込んでしまう事故を防げます。
3つのアンチパターンを並べると、共通項が見えます。名前を付けて認識することが、予防の第一歩です。最後にまとめの表を置いておきます。
| アンチパターン | 症状 | 主な原因 | 対処 |
|---|---|---|---|
| 時々落ちるテスト | 時々落ちる | タイムアウト不足・同期判定の不正確 | タイムアウトを1箇所で管理し、判定論理を見直す |
| 先走って通ってしまうテスト | バグがあるのに通る | 最終状態だけをアサート | 中間状態のアサーションを挟む |
| ポーリングで変化を見落とす | 状態変化を見逃す | ポーリング隙間で変化が消える | 刺激→観測→安定確認のフェーズ構造で書く |
4-4. 時間依存の不確定性は外部化する
非同期テストの最大の敵は、システム内部に閉じ込められたタイマー(スケジューラ)です。
「1分後に通知を送る」「毎時0分にバッチを起動する」のような時間依存処理は、テストでそのまま待つわけにはいきません。
対処は スケジューラを外部から駆動できる形にする ことです。
スケジューラを外部サービス化したり、メッセージバス経由でイベントを受け取る形にしたりすると、テストはスケジューラ役を演じてイベントを決定論的に発火できます。
# テストがスケジューラ役を演じる
def test_1時間後に通知が送られる():
system.start()
fake_scheduler.fire(after="1h") # 1時間後イベントを発火
wait_for(lambda: notifier.sent_count() == 1)
これは「テストしやすさが設計を改善する」典型例です。
スケジューラを外部化すると、テスト以外でも運用面の利点(イベント発火タイミングのモニタリング、再送、外部からの差し込み)が手に入ります。
5. 共通する原則:決定論性とフィードバック
3領域の戦略を並べ終えたところで、共通項を抽出します。
5-1. 戦略を1枚に並べると見える共通項
| 領域 | 何が不確定か | どう観測可能にするか | 本体側に要求する設計 |
|---|---|---|---|
| 永続化 | 状態が残ること | 既知状態の用意+トランザクション境界明示 | 既知状態を作るインフラ・境界を切れるAPI |
| 並行 | スレッドの実行順 | 機能と同期を分離+不変条件のストレステスト | タスク実行手段を外から差し込める設計 |
| 非同期 | 完了タイミング | 成功を待つ+スケジューラ外部化 | 観測可能な効果・時間を外から進められる設計 |
つまり、揺れる部分をそのまま相手にせず、テストから確認できる形に直しているわけです。
5-2. テストできる構造は本体の設計を変える
「決定論的に観測できる」ためには、アプリケーション側の作りも少し変える必要があります。具体的には次のような工夫です。
- 依存注入でタスク実行手段を外から差し込む
- スケジューラを外部化してイベントを発火可能にする
- 状態変化を外から確認できるイベントを発信する
- トランザクション境界をテストから扱える形にする
どれもテストのためだけの設計ではなく、運用・監視・モジュール性の向上にも直接効きます。
テストしにくさは、設計を見直す手がかりになります。
「テストが書けない」ではなく「テストが書ける構造になっていない」と読み替えると、次に何を直せばいいかが見えてきます。
5-3. AIにテストを書かせる場面でも同じ
AIにテストを書かせる場面でも、この観点は残ります。
非同期や並行処理のテストは、形だけそれらしいコードが出ても、時々落ちるテストになっていることがあります。
そのテストが安定して観測できる形になっているかは、最終的に人間が見る必要があります。
おわりに
3領域の戦略は別物に見えて、やっていることは同じでした。
揺れる部分をそのままテストせず、外から安定して確認できる形に変える。この一点で見ると、永続化・並行・非同期のテストは同じ観点で整理できます。
テストが書きにくいと感じたら、まず「何が揺れているのか」を言葉にしてみるところから始めると、次に何を直せばいいかが見えてきます。