はじめに
業務システムでよくある「大量データのバッチ処理」。
今回は、契約番号1500万件を対象にした外部APIとの並列リクエスト処理を実装し、
その中で発生しうるAPIエラーやタイムアウトに応じた分岐処理を設計・検証していきます。
以下のような課題をどう乗り越えるかがテーマです:
- 並列処理で順序が保証されない状況でも、仕様通りの振る舞いができるか?
- socketTimeoutが連続で発生した場合の「途中終了」処理はどうテストする?
- BDD(Cucumber)で仕様ベースのテストをどう設計する?
処理仕様(簡略図)
-
インプットファイル(契約番号1500万件)
-
各契約番号で外部APIにリクエスト(20スレッド)
-
レスポンス内容によって処理を分岐:
- ✅ 200かつ内容OK → DB SELECT → UPDATE or INSERT
- ⚠️ 400かつ「対象外」 → エラーファイルに記録
- 🔁 500 or タイムアウト → リランファイルに記録
- ❌ socketTimeoutが4連続発生 → 残りのデータを全件リランファイルに記録
実装のポイント(抜粋)
Sample.java
import java.util.List;
import java.util.concurrent.*;
import java.net.SocketTimeoutException;
public class BatchExecutor {
private final ExecutorService executor;
private final ApiClient apiClient;
private final ResultWriter resultWriter;
private final DbService dbService;
private final List<ContractRecord> inputRecords;
// timeout発生回数(スレッドセーフ)
private final AtomicInteger socketTimeoutCount = new AtomicInteger(0);
// 処理中断フラグ(全スレッドで共有される)
private volatile boolean abortRemaining = false;
public BatchExecutor(List<ContractRecord> inputRecords, ApiClient apiClient,
ResultWriter resultWriter, DbService dbService, int threadCount) {
this.executor = Executors.newFixedThreadPool(threadCount);
this.inputRecords = inputRecords;
this.apiClient = apiClient;
this.resultWriter = resultWriter;
this.dbService = dbService;
}
public void execute() throws InterruptedException {
for (ContractRecord record : inputRecords) {
if (abortRemaining) {
// 4連続timeout後は全件リラン出力
resultWriter.writeRerun(record);
continue;
}
// 並列で契約番号1件ずつ処理
executor.submit(() -> processContract(record));
}
executor.shutdown();
executor.awaitTermination(30, TimeUnit.MINUTES);
}
private void processContract(ContractRecord record) {
try {
ApiResponse response = apiClient.sendRequest(record);
if (response.status == 200 && response.isValid()) {
dbService.saveOrUpdate(record, response);
} else if (response.status == 400 && response.message.equals("対象外")) {
resultWriter.writeError(record);
} else if (response.status == 500 || response.isTimeout()) {
resultWriter.writeRerun(record);
}
} catch (SocketTimeoutException e) {
int current = socketTimeoutCount.incrementAndGet();
resultWriter.writeRerun(record);
if (current >= 4) {
// 4件連続でsocketTimeoutが発生したらフラグを立てる
abortRemaining = true;
}
} catch (Exception e) {
// その他例外もリランとして記録(業務による)
resultWriter.writeRerun(record);
}
}
}
Cucumberテストシナリオ例(Gherkin)
Feature: バッチ処理の並列実行とレスポンス別の分岐処理
# ✅ 正常系
Scenario: 200 OKでDBに登録または更新される
Given インプットに契約番号が3件ある
And APIはすべて200 OKを返す
When バッチ処理を実行する
Then データベースに3件登録されている
# ⚠️ エラー(400)記録
Scenario: 対象外メッセージでエラーファイルに記録
Given 契約番号が1件あり、APIが400「対象外」を返す
When バッチ処理を実行する
Then エラーファイルにその契約が出力されている
# 🔁 500系・timeout
Scenario: 500エラーでリランファイルに記録
Given APIが500エラーを返す
Then リランファイルにその契約が記録されている
Scenario: すべての契約に対してsocketTimeoutが発生した場合、リランファイルにすべて記録される
Given インプットファイルに契約番号が10件ある
And APIはすべてsocketTimeoutを返す
When バッチ処理を実行する
Then リランファイルに10件すべてが出力されている
テスト観点の整理
このような並列処理・分岐処理をテストする場合、以下の観点を意識しておくと実装やテストの品質が安定します。
観点 | 内容 | 検証方法 |
---|---|---|
並列処理の順序非保証 | スレッドは順不同で実行される | ファイル出力の順番でなく「件数・中身の正確さ」で比較する |
特定条件で処理中断 | socketTimeoutが4回発生したら以降の処理を止める | タイムアウト数をMockで制御し、リランファイルの件数で確認 |
レスポンス別の分岐 | 200 / 400 / 500 / timeout で処理を変える | 各コードに対するAPIスタブを作成し、対応ファイル出力を確認 |
スレッドセーフな状態管理 | カウントや中断フラグを全スレッドから安全に扱う |
AtomicInteger や volatile を使うことを確認する |
シナリオ設計における心構え
テストは「順序通りに動くこと」ではなく、「期待した状態になること」を検証するもの。
並列処理では、レスポンス順序やログ順に頼らず、データの最終状態(件数・内容)を確認するのが原則です。
まとめ
- 並列処理+外部API分岐という業務でありがちなシナリオを実装&検証した
- 仕様どおりの動作を担保するためには、テスト設計時のデータ制御・状態検証の観点が重要
- 順序に頼らず、出力件数・中断条件・エラーハンドリングの網羅性で品質を確保する
このようなパターンは金融・保険・通信などでも頻出するので、仕組みや観点を理解しておくと現場対応力が高まります