ApexTools: IHttpRequestHandler and the pain of HttpCalloutMock
When you test an external integration in Salesforce, you swap the response through the standard HttpCalloutMock. ApexTools' IHttpRequestHandler makes the callout replaceable through DI, and lets you assemble responses by declaration alone.
📌 The platform does ship built-in mocks. With
StaticResourceCalloutMock/MultiStaticResourceCalloutMockyou can return a response without writing an implementation class. But the response body has to exist as a static resource (metadata), and it is one response per endpoint, so an ordered sequence of responses (retries, pagination) cannot be expressed. The moment you need that, you are back to implementingHttpCalloutMock.
What it gives you
- Inject
HttpRequestHandler(a thin wrapper over the standardHttp) in production andMockHttpRequestHandlerin tests - Declare a response in one line:
MockResponse.of('GET').respond(body, 200) - No
HttpCalloutMockimplementation class, even for ordered responses - Inspect the requests you sent, after the fact (Spy)
Three things that make HttpCalloutMock awkward
These are what you hit once you end up implementing HttpCalloutMock yourself.
1: Hand-writing JSON strings
The response body ends up as a string literal, costing you both escaping and readability. (You can push it into a static resource, but then reading the test means opening another file.)
// ❌ falls apart as the structure deepens
String body = '{"records":[{"Id":"001xx","Name":"Acme","Contacts":{"totalSize":2}}]}';
2: if-else branching on URL strings
One mock class carries every endpoint, so the inside of respond() swells with URL checks.
// ❌ the branching grows with every new endpoint
public HttpResponse respond(HttpRequest req) {
if(req.getEndpoint().contains('/accounts')) { ... }
else if(req.getEndpoint().contains('/contacts')) { ... }
...
}
3: Retries and pagination are hard to reproduce
Expressing an ordered set of responses — "500 the first time, 500 again, then 200" — means giving the mock a counter of its own.
ApexTools' answer
Declare responses with MockResponse
MockResponse.of('GET').respond('{"message":"not found"}', 404) // String
MockResponse.of('post').respond(new Map<String, Object>{ ... }, 200) // Map / List get JSON-encoded. Method name is case-insensitive
MockResponse.of('GET').respond(imageBytes, 200).header('Content-Type', 'image/jpeg') // Blob + header
MockResponse.of('GET').respond(ok, 200).repeat() // last in the queue: every call from here on
respond accepts String / Map<String, Object> / List<Object> / Blob. Maps and lists are JSON-encoded automatically, so awkwardness 1 dissolves into "write it as an Apex collection".
⚠️ The method name is not a routing key. It is the contract at delivery time. If the next response in the queue declares a method that does not match the actual request, it fails immediately with an error carrying the expectation, the actual, and the queue state. A different response is never handed over silently.
Two modes
🎯 Rule of thumb: if the test's claim includes ordering, use script mode; if it does not, use label mode. When in doubt, label.
Script mode (no labels): when ordering is the specification
The list you pass to the constructor is the script of the flow. Read it top to bottom and it is the sequence of callouts you expect.
MockHttpRequestHandler mock = new MockHttpRequestHandler(new List<MockResponse>{
MockResponse.of('GET').respond(notFound, 404), // move 1: does it exist?
MockResponse.of('POST').respond(created, 201), // move 2: create
MockResponse.of('GET').respond(found, 200) // move 3: fetch again
});
Deviating from the order or the method produces a detailed error. Awkwardness 3 becomes "list the same method a few times".
// 500, 500, then success. No counter in the mock
new List<MockResponse>{
MockResponse.of('POST').respond(err, 500),
MockResponse.of('POST').respond(err, 500),
MockResponse.of('POST').respond(ok, 200)
}
Label mode: when you do not want to be tied to cross-site ordering
Give each callout site its own named queue (the same feel as MockEloquent's attach / label). Awkwardness 2 dissolves because you sort by the name of the call site, not by inspecting the URL.
// In the 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)); // declaring an untaken branch is fine
new KintoneUpsertUsecase(input, mock).invoke();
Assert.areEqual(1, mock.sentRequestsAt(LBL_CREATE).size()); // the create branch was taken
Assert.areEqual(0, mock.sentRequestsAt(LBL_UPDATE).size()); // update was never consumed
For a branching flow the standard move is to attach both branches and assert which one got consumed.
- Once you use
attach, everysendmust be preceded bylabel()(consumed once per send) - A typo'd label fails with the list of registered labels attached
- Attaching to the same label again appends to its queue (that site's retry sequence)
- Without
attach,label()calls are ignored. Labelled production code can still be tested against a plain script mock
Verification helpers (Spy)
| Method | Purpose |
|---|---|
sentRequestsAt(label) | The requests sent under that label |
countByMethod('POST') | How many were sent per HTTP method |
requestsTo(endpointPart) | Filter by a substring of the endpoint |
lastRequest() | The last request sent |
describe() | The current queue state (for debugging) |
🛡 The Content-Type guard (a habit born from a real incident)
Even on HTTP 200, an unexpected Content-Type usually means you hit the wrong endpoint.
A real case: the code meant to call
/bizCards/{id}/imagebut was calling/bizCards/{id}, base64-encoding the returned JSON and pushing a corrupted image to the UI.
Always guard when fetching binary.
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();
Integrating with Apex Stem: injecting into a Usecase
IHttpRequestHandler slots straight into the Apex Stem Usecase layer and the Layered Constructor Pattern. From v1.0.0 the recommendation is to multiplex a single handler with labels (the same idea as IEloquent's 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('Upsert a record into kintone');
// 🚪 production
public KintoneUpsertUsecase(Input input) {
this(input, null);
}
// 🧪 test (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);
// ...
}
}
Injecting several handlers by role still works, but multiplexing by label keeps the constructor from bloating.
Inject MockEloquent (ApexEloquent) and MockHttpRequestHandler (ApexTools) independently and you can verify side effects (DML) and outbound calls (HTTP) on separate axes.
⚠️ Breaking changes in v1.0.0
Upgrading from a pre-tag main needs three things.
| Change | What to do |
|---|---|
The old constructors (Map / List<Map> / String + Integer) were removed | Rewrite as MockResponse.of(method).respond(body, statusCode) |
label / getBodyAsBlob / getHeader were added to IHttpRequestHandler | Add the methods to any custom implementation |
| The exhaustion error message became a multi-line diagnostic | Loosen exact-match asserts to contains |
Related Documents
- Layered Constructor Pattern: the base pattern behind
IHttpRequestHandler's DI design - TriggerHandler: the other pillar of ApexTools
- ApexTools guide: back to the guide index