IHttpRequestHandler: 公式 HttpCalloutMock のやりづらさと ApexTools の答え
Salesforce の外部連携をテストするとき、応答の差し替えには標準の HttpCalloutMock を使います。ApexTools の IHttpRequestHandler は、コールアウトを DI で差し替え可能にし、応答を宣言だけで組み立てられるようにします。
📌 標準にも組み込みのモックはあります。
StaticResourceCalloutMock/MultiStaticResourceCalloutMockを使えば、実装クラスを書かずに応答を返せます。ただし応答ボディを静的リソース (メタデータ) として用意する必要があり、しかも 1 エンドポイントにつき 1 応答なので、順序を持つ応答 (リトライ・ページネーション) は表現できません。そこに踏み込んだ時点でHttpCalloutMockの実装クラスが必要になります。
何ができるか
- 本番は
HttpRequestHandler(標準Httpの薄いラッパー)、テストはMockHttpRequestHandlerを注入する - 応答は
MockResponse.of('GET').respond(body, 200)の 1 行で宣言する - 順序つきの応答でも、
HttpCalloutMockの実装クラスを書かなくてよい - 送ったリクエストを後から検証できる (Spy)
公式 HttpCalloutMock の 3 つのやりづらさ
以下は、HttpCalloutMock を自分で実装することになったときに踏む 3 つです。
やりづらさ 1: JSON 文字列の手動作成
応答ボディを文字列リテラルで書くことになり、エスケープと可読性の両方が犠牲になります (静的リソースに逃がす手もありますが、今度はテストを読むのにファイルを開く必要が出ます)。
// ❌ 階層が深いほど破綻する
String body = '{"records":[{"Id":"001xx","Name":"Acme","Contacts":{"totalSize":2}}]}';
やりづらさ 2: URL 文字列に依存した if-else 分岐
1 つのモッククラスが全エンドポイントを引き受けるため、respond() の中が URL 判定の分岐で膨らみます。
// ❌ エンドポイントが増えるたびに分岐が伸びる
public HttpResponse respond(HttpRequest req) {
if(req.getEndpoint().contains('/accounts')) { ... }
else if(req.getEndpoint().contains('/contacts')) { ... }
...
}
やりづらさ 3: リトライ・ページネーションの再現が困難
「1 回目は 500、2 回目も 500、3 回目に 200」のような順序を持つ応答を表現するには、モック側にカウンタを持たせることになります。
ApexTools の答え
応答は MockResponse で宣言する
MockResponse.of('GET').respond('{"message":"not found"}', 404) // String
MockResponse.of('post').respond(new Map<String, Object>{ ... }, 200) // Map / List は JSON 化。メソッド名は大小どちらでも
MockResponse.of('GET').respond(imageBytes, 200).header('Content-Type', 'image/jpeg') // Blob + ヘッダ
MockResponse.of('GET').respond(ok, 200).repeat() // キュー末尾に置くと以降ずっとこれ
respond は String / Map<String, Object> / List<Object> / Blob を受けます。Map と List は自動で JSON 化されるので、やりづらさ 1 は「Apex のコレクションで書く」だけで解消します。
⚠️ メソッド名はルーティングキーではなく「配信時の契約」です。 キューの次の応答が宣言したメソッドと実リクエストで食い違うと、期待 / 実際 / キュー状態を含むエラーで即座に落ちます。黙って違う応答が配られることはありません。
2 つのモード
🎯 親指ルール: テストの主張に順序が含まれるなら台本モード、含まれないなら label モード。迷ったら label。
台本モード (label なし): 順序が仕様であるとき
コンストラクタに渡したリストが、そのままフローの台本になります。上から読めば期待するコールアウト列そのものです。
MockHttpRequestHandler mock = new MockHttpRequestHandler(new List<MockResponse>{
MockResponse.of('GET').respond(notFound, 404), // 1 手目: 存在確認
MockResponse.of('POST').respond(created, 201), // 2 手目: 作成
MockResponse.of('GET').respond(found, 200) // 3 手目: 再取得
});
順序やメソッドから逸脱すると詳細なエラーになります。やりづらさ 3 は、リトライを「同じメソッドを並べるだけ」で表現できるようになります。
// 1 回目 500、2 回目 500、3 回目に成功。モック側にカウンタは不要
new List<MockResponse>{
MockResponse.of('POST').respond(err, 500),
MockResponse.of('POST').respond(err, 500),
MockResponse.of('POST').respond(ok, 200)
}
label モード: サイト間の順序に縛られたくないとき
呼び出しサイトごとに名前付きキューを持たせます (MockEloquent の attach / label と同じ操作感)。やりづらさ 2 は、URL 判定ではなく呼び出しサイトの名前で仕分けることで解消します。
// Usecase 側: this.http.label(LBL_EXISTS).send(req);
MockHttpRequestHandler mock = new MockHttpRequestHandler()
.attach(LBL_EXISTS, MockResponse.of('GET').respond(notFound, 404))
.attach(LBL_CREATE, MockResponse.of('POST').respond(created, 201))
.attach(LBL_UPDATE, MockResponse.of('PUT').respond(updated, 200)); // 通らない分岐も宣言してよい
new KintoneUpsertUsecase(input, mock).invoke();
Assert.areEqual(1, mock.sentRequestsAt(LBL_CREATE).size()); // create 分岐を通った
Assert.areEqual(0, mock.sentRequestsAt(LBL_UPDATE).size()); // update は未消費
分岐フローでは、両方の分岐を attach しておき、どちらが消費されたかで通った経路をアサートするのが定石です。
attachを使ったら、sendごとにlabel()が必須です (1 回で消費)- ラベルの typo は、登録済みラベルの一覧つきでエラーになります
- 同一 label への
attachはキューに追記されます (= そのサイトのリトライ系列) attachを使わなければlabel()は無視されます。label 付きの本番コードを、素の台本モックでもテストできます
検証ヘルパー (Spy)
| メソッド | 用途 |
|---|---|
sentRequestsAt(label) | そのラベルで送られたリクエスト |
countByMethod('POST') | メソッド別の送信回数 |
requestsTo(endpointPart) | エンドポイントの部分一致で絞る |
lastRequest() | 最後に送ったリクエスト |
describe() | キューの現在状態 (デバッグ用) |
🛡 Content-Type ガード (実事故由来の定石)
HTTP 200 でも Content-Type が想定外なら、たいていはエンドポイントの間違いです。
実例:
/bizCards/{id}/imageを叩いたつもりが/bizCards/{id}を叩いており、返ってきた JSON を base64 して壊れた画像を画面に流していた。
バイナリを取得するときは必ずガードを入れてください。
this.http.label(LBL_CARD_IMAGE).send(req);
String contentType = this.http.getHeader('Content-Type');
if(this.http.getStatusCode() == 200 &&(contentType == null || !contentType.startsWith('image/'))) {
throw new CalloutException('Expected an image response but got Content-Type=' + contentType);
}
Blob image = this.http.getBodyAsBlob();
Apex Stem との統合: Usecase で DI する
IHttpRequestHandler は、Apex Stem の Usecase 層と Layered Constructor Pattern にそのままはまります。v1.0.0 以降は 1 本の handler を label で多重化するのが推奨です (IEloquent の label と同じ考え方)。
public with sharing class KintoneUpsertUsecase {
@TestVisible static final String LBL_EXISTS = 'kintoneExists';
@TestVisible static final String LBL_CREATE = 'kintoneCreate';
private final Input input;
private final IHttpRequestHandler http;
private Trace t = Trace.of('kintone へレコードを upsert');
// 🚪 本番用
public KintoneUpsertUsecase(Input input) {
this(input, null);
}
// 🧪 テスト用 (DI 対応)
@TestVisible
private KintoneUpsertUsecase(Input input, IHttpRequestHandler http) {
this.input = input;
this.http = http ?? new HttpRequestHandler();
}
public void invoke() {
this.t.start();
this.http.label(LBL_EXISTS).send(existsReq);
// ...
}
}
役割ごとに複数の handler を DI する旧スタイルも引き続き使えますが、ラベル多重化のほうがコンストラクタが太りません。
MockEloquent (ApexEloquent) と MockHttpRequestHandler (ApexTools) を独立して DI すれば、副作用 (DML) と外部呼び出し (HTTP) を別々の軸で検証できます。
⚠️ v1.0.0 の破壊的変更
タグ以前の main から上げる場合は、次の 3 点の対応が必要です。
| 変更 | 対応 |
|---|---|
旧コンストラクタ (Map / List<Map> / String + Integer) を削除 | MockResponse.of(method).respond(body, statusCode) に書き換える |
IHttpRequestHandler に label / getBodyAsBlob / getHeader を追加 | 独自実装クラスがあればメソッドを追加する |
| 枯渇エラーのメッセージが複数行の診断形式に変更 | 完全一致の assert は contains に緩める |
次に読む
- Layered Constructor Pattern:
IHttpRequestHandlerの DI 設計を支える基本パターン - TriggerHandler: ApexTools のもう 1 つの柱
- ApexTools ガイド: ガイド目次に戻る