APIが 200 OK を返した。
でも、その後ろで動く処理まで成功したとは限りません。
非同期処理では、APIがリクエストを受け付けた後に、キューやワーカーを介して処理が続きます。
例えば、こんな流れです。
APIは「受け付けた」というところまでしか保証していません。
実際の処理は、その後も続いています。
だからAPIが正常終了しただけでは、
非同期処理全体が正しく終わった
とは言えません。
では、最初から最後までE2Eで確認すればいいのか。
自分は、そう単純でもないと思っています。
非同期処理は「途中まで成功した状態」で壊れる
正常系だけなら分かりやすいです。
難しいのは途中で失敗した場合です。
例えば、外部APIへの送信が成功した直後にワーカーが停止したとします。
外部にはすでに通知されています。
しかし、DBには完了したことが記録されていません。
その後、この処理が再試行されたらどうなるでしょうか。
ここで設計を間違えると、同じ通知が二度送られるかもしれません。
非同期処理で難しいのは、
成功したか、失敗したかだけではなく、どこまで成功した状態で失敗したか
が残ることです。
DB、キュー、ワーカー、外部API。
それぞれが別々に状態を持つため、途中失敗によって食い違いが生まれます。
E2E一本では、壊れた場所が遠い
もちろん、ユーザー操作から最後まで確認するE2Eには価値があります。
例えば、
この一連の流れが、本当に成立していることを確認できます。
ただ、このE2Eが次のように失敗したとします。
期待: 完了
実際: 処理中
分かるのは、
最終的に期待した状態にならなかった
ということだけです。
原因はまだ分かりません。
- キューへ登録されなかった
- ワーカーが起動しなかった
- 外部APIが失敗した
- DB更新だけ失敗した
- 再試行中だった
- 単に処理がまだ終わっていなかった
非同期処理なので、時間も関係します。
「5秒待つ」「完了するまで繰り返し確認する」といった実装も入りやすくなります。
E2Eが悪いわけではありません。
ただ、確認したい問題から遠い場所で結果を見ているため、細かな壊れ方をすべてE2Eで守ろうとすると扱いづらくなります。
先に「どう壊れるか」を分ける
そこで、自分はテストを書く前に、まず壊れ方を分けます。
例えば今回なら、こんな問題があります。
| 起きると困ること | 何を確認するか | 主に見る場所 |
|---|---|---|
| キューへ正しく登録されない | 登録内容が正しい | キュー |
| 同じ処理が二重に実行される | 外部への処理が増えていない | 外部APIとの境界 |
| 再試行で状態が壊れる | 最終状態が正しい | DB |
| ワーカーの処理が壊れる | 期待した処理が行われる | ワーカー + DB |
| 外部APIへ誤った内容を送る | 実際の送信内容 | 外部APIとの境界 |
| 処理全体がつながらない | 最終結果まで成立する | E2E |
こうすると、
全部E2Eで確認する
必要はなくなります。
例えば二重実行が問題なら、ブラウザから操作する必要はありません。
同じ処理を意図的に二回発生させて、
外部APIへの送信回数 = 1回
を直接確認すればいい。
途中失敗が問題なら、途中で意図的に失敗させて、再試行後のDBを確認すればいい。
守りたいものに近い場所までテストを降ろしていきます。
DB・キュー・ワーカーを実際につなぐ
ここで統合テストが使いやすくなります。
例えば、自分なら次のような境界でテストします。
DBは実際のものを使う。
キューも実際に動かす。
ワーカーも本番と同じ実装を使う。
一方で外部APIは、テスト用の代替サーバーに置き換える。
これによって、
アプリケーション
↓
DB
↓
キュー
↓
ワーカー
↓
DB更新
という、自分たちが責任を持つ範囲を実際につなげた状態で確認できます。
外部サービスそのものの可用性やネットワーク障害までは、このテストには持ち込みません。
ここは大事だと思っています。
統合テストだから全部を本物にする必要はありません。
どこまで本物を使うかは、何を確認したいかで決めます。
外部APIは「呼んだこと」ではなく「何を送ったか」を見る
例えば、
ワーカーが外部APIへ正しい内容を送っているか
を確認したいとします。
本物の外部サービスを呼ぶ必要はありません。
テスト用のサーバーで受け取った内容を記録して、
- 送信先
- HTTPメソッド
- ヘッダー
- 本文
- 呼び出し回数
を確認できます。
これなら、
ワーカーから外部サービスとの境界まで正しい
ことを確認できます。
一方、
実際の外部サービスに認証を含めて接続できるか
は別の問題です。
そこは実サービスを利用した接続確認や、必要最小限のスモークテストとして分ければいい。
一つのテストですべてを確認しようとしない方が扱いやすくなります。
二重実行を意図的に作る
例えば、同じ処理が複数回実行された場合を確認します。
概念を示すための簡単な例です。
it("同じ処理が再実行されても二重送信しない", async () => {
const delivery = await createDelivery();
await enqueue(delivery.id);
await waitUntilProcessed(delivery.id);
// 同じ処理をもう一度発生させる
await enqueue(delivery.id);
await waitUntilProcessed(delivery.id);
const requests = await externalApi.requestsFor(delivery.id);
expect(requests).toHaveLength(1);
const state = await findDelivery(delivery.id);
expect(state.status).toBe("completed");
});
見たいのは、
ワーカーが二回動いたか
ではありません。
確認したいのは、
同じ処理が再実行された
↓
外部への送信は増えていない
↓
DBの最終状態も壊れていない
という結果です。
キューによっては、同じ処理が複数回実行されること自体を完全には避けられません。
BullMQも、再試行されても最終的な状態が変わらないよう、ジョブを冪等に設計することを推奨しています。
つまり、
二度実行されないこと
ではなく、
二度実行されても壊れないこと
を保証したい場合があります。
途中失敗も意図的に作る
本番で偶然壊れるのを待つ必要もありません。
例えば外部APIの代替サーバーを、
1回目 → 500
2回目 → 200
と返すようにします。
ここで、
- 再試行されたか
- 必要以上の回数呼ばれていないか
- DBの途中状態は正しいか
- 最終的に完了状態になったか
を確認できます。
壊したい場所を、テスト側から狙って壊せる。
非同期処理の統合テストでは、これがかなり重要だと思っています。
正常系を一本通すだけでは見つけにくい問題を、意図的に再現できます。
では、何をどこで保証するか
最終的には、こんな分け方になります。
重要だからE2Eにするわけではありません。
逆に、何でも統合テストにすればいいわけでもありません。
単純な計算や分岐ならUnit Testの方が速くて分かりやすい。
DB、キュー、ワーカーをまたいだときに初めて発生する問題なら、統合テストで見る。
実サービスと本当に接続できることが重要なら、そこだけ実際に接続する。
ユーザーから見た一連の流れが成立することはE2Eで見る。
壊れる場所に近いところへ保証を置く。
自分はこれを基準にしています。
E2Eをなくしたいわけではない
ここまで書くと、
E2Eはいらないのでは?
と見えるかもしれません。
そういう話ではありません。
例えば、
こうした全体のつながりは、E2Eだから確認しやすいものです。
自分も必要だと思っています。
ただ、
- 二重実行
- 再試行
- DBの状態遷移
- キューの内容
- 外部APIの呼び出し回数
まで、全部E2Eで再現する必要はありません。
もっと近い場所で、もっと直接確認できます。
E2Eで確認できることと、E2Eで確認した方がいいことは同じではありません。
CIで落ちたときにも違いが出る
この違いは、成功しているときより失敗したときに大きく出ます。
例えばCIで、
E2E failed
期待: completed
実際: processing
と出ても、まだ原因は分かりません。
一方、
二重送信防止テストに失敗
期待する外部API呼び出し回数: 1
実際の呼び出し回数: 2
なら、見る場所はかなり絞れます。
テストは、
壊れたことを検知する
だけではなく、
何が壊れたのかを次の人へ伝える
仕組みでもあります。
DB、キュー、ワーカー、外部APIと処理が分かれている非同期システムでは、この違いはかなり大きいと感じています。
どこで保証するか
非同期処理を見ると、最初から最後まで通したくなります。
もちろん、それも必要です。
ただ、その前に一度分けてみる。
単純なロジックならUnit Test。
DB、キュー、ワーカーの組み合わせならIntegration Test。
実サービスとの接続なら必要な範囲だけ実通信。
ユーザーから見た一連の流れならE2E。
非同期処理では、その結果として統合テストが多くなることがあります。
でも、それは統合テストを増やしたいからではありません。
壊れる場所に、一番近い保証を置こうとした結果です。