IHttpRequestHandler: 公式 HttpCalloutMock のやりづらさと ApexTools の答え

Apex Stem ドキュメント
Apex StemApexToolsHttpRequestTestingDISalesforceApex
ApexTools の DI 対応 HTTP リクエストラッパーの詳細ガイド。公式 HttpCalloutMock の 3 つのやりづらさを取り上げ、IHttpRequestHandler と Queue 構造の MockHttpRequestHandler でどう解消するか、Apex Stem の Usecase との統合まで解説します。

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 文字列の手動作成

応答ボディを文字列リテラルで書くことになり、エスケープと可読性の両方が犠牲になります (静的リソースに逃がす手もありますが、今度はテストを読むのにファイルを開く必要が出ます)。

APEX
// ❌ 階層が深いほど破綻する
String body = '{"records":[{"Id":"001xx","Name":"Acme","Contacts":{"totalSize":2}}]}';

やりづらさ 2: URL 文字列に依存した if-else 分岐

1 つのモッククラスが全エンドポイントを引き受けるため、respond() の中が URL 判定の分岐で膨らみます。

APEX
// ❌ エンドポイントが増えるたびに分岐が伸びる
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 で宣言する

APEX
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()                        // キュー末尾に置くと以降ずっとこれ

respondString / Map<String, Object> / List<Object> / Blob を受けます。Map と List は自動で JSON 化されるので、やりづらさ 1 は「Apex のコレクションで書く」だけで解消します。

⚠️ メソッド名はルーティングキーではなく「配信時の契約」です。 キューの次の応答が宣言したメソッドと実リクエストで食い違うと、期待 / 実際 / キュー状態を含むエラーで即座に落ちます。黙って違う応答が配られることはありません。

2 つのモード

🎯 親指ルール: テストの主張に順序が含まれるなら台本モード、含まれないなら label モード。迷ったら label。

台本モード (label なし): 順序が仕様であるとき

コンストラクタに渡したリストが、そのままフローの台本になります。上から読めば期待するコールアウト列そのものです。

APEX
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 は、リトライを「同じメソッドを並べるだけ」で表現できるようになります。

APEX
// 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 モード: サイト間の順序に縛られたくないとき

呼び出しサイトごとに名前付きキューを持たせます (MockEloquentattach / label と同じ操作感)。やりづらさ 2 は、URL 判定ではなく呼び出しサイトの名前で仕分けることで解消します。

APEX
// 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 して壊れた画像を画面に流していた。

バイナリを取得するときは必ずガードを入れてください。

APEX
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 で多重化するのが推奨です (IEloquentlabel と同じ考え方)。

APEX
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) に書き換える
IHttpRequestHandlerlabel / getBodyAsBlob / getHeader を追加独自実装クラスがあればメソッドを追加する
枯渇エラーのメッセージが複数行の診断形式に変更完全一致の assert は contains に緩める

次に読む