Apex test classes cannot make real HTTP callouts. The platform enforces this: any test that tries to invoke Http.send() against an actual endpoint throws an exception. Tests must register a mock implementation that simulates the response.
This restriction is the right design (tests should not depend on external systems being available), but the mock infrastructure that ships with Apex is bare bones. Production-grade callout testing requires patterns beyond the basic example in the Salesforce documentation. Most of the integration bugs that reach production survived testing because the mock was too simple.
The basic mock pattern
Salesforce provides HttpCalloutMock, an interface with one method: respond(HttpRequest req). Implementing it lets the test return a canned response.
@isTest
public class SimpleMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setBody('{"status": "ok"}');
res.setHeader('Content-Type', 'application/json');
return res;
}
}
// In the test:
@isTest
static void testSuccess() {
Test.setMock(HttpCalloutMock.class, new SimpleMock());
// Now the code under test can make callouts.
// Each one will receive the canned response.
}
This works for the simplest case: one callout, success path. Real production code makes multiple callouts, hits failure paths, and needs to verify the request format. The basic mock cannot cover any of that.
Failure path testing
A timeout, a 503, a malformed response. Each of these can break integration code in ways that success-only testing misses.
A mock that simulates failure:
@isTest
public class FailureMock implements HttpCalloutMock {
private Integer statusCode;
private String body;
public FailureMock(Integer code, String responseBody) {
this.statusCode = code;
this.body = responseBody;
}
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(this.statusCode);
res.setBody(this.body);
return res;
}
}
// In the test:
@isTest
static void test500ErrorHandling() {
Test.setMock(HttpCalloutMock.class, new FailureMock(500, ''));
Test.startTest();
// Code under test should handle the 500 gracefully.
Test.stopTest();
// Assert the error path was exercised: maybe a record marked
// as failed, maybe a retry queued.
}
Parameterize the mock so different tests can simulate different failures: 404, 500, 503, malformed JSON, timeout.
A timeout simulation:
public class TimeoutMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
throw new CalloutException('Read timed out');
}
}
Apex throws CalloutException for timeouts at the HTTP layer; the mock can throw the same exception to exercise the catch path.
Multi-callout sequences
Real integration code often makes multiple callouts: GET to look up data, POST to create, GET again to verify. Each callout needs its own response.
HttpCalloutMock cannot natively handle sequences. The pattern is to implement state inside the mock:
@isTest
public class SequenceMock implements HttpCalloutMock {
private List<HttpResponse> responses;
private Integer index = 0;
public SequenceMock(List<HttpResponse> orderedResponses) {
this.responses = orderedResponses;
}
public HttpResponse respond(HttpRequest req) {
HttpResponse res = this.responses[this.index];
this.index++;
return res;
}
}
The test sets up an ordered list of responses, one per expected callout:
@isTest
static void testMultiCalloutSequence() {
HttpResponse first = new HttpResponse();
first.setStatusCode(200);
first.setBody('{"id": 42}');
HttpResponse second = new HttpResponse();
second.setStatusCode(201);
second.setBody('{"created": true}');
Test.setMock(HttpCalloutMock.class,
new SequenceMock(new List<HttpResponse>{first, second}));
Test.startTest();
IntegrationService.doMultiStepFlow();
Test.stopTest();
// Assertions about the end state.
}
This handles ordered sequences. For more complex patterns (different responses depending on which endpoint is called), use a different approach.
MultiRequestMock by endpoint
When the test exercises code that calls multiple endpoints (/users, /accounts, /charges), each endpoint should return its own response. Implement a router-style mock:
@isTest
public class MultiRequestMock implements HttpCalloutMock {
private Map<String, HttpResponse> responsesByEndpoint;
public MultiRequestMock(Map<String, HttpResponse> map) {
this.responsesByEndpoint = map;
}
public HttpResponse respond(HttpRequest req) {
String endpoint = req.getEndpoint();
for (String key : responsesByEndpoint.keySet()) {
if (endpoint.contains(key)) return responsesByEndpoint.get(key);
}
HttpResponse fallback = new HttpResponse();
fallback.setStatusCode(404);
fallback.setBody('{"error": "endpoint not mocked"}');
return fallback;
}
}
Used in tests:
@isTest
static void testRoutedCallouts() {
HttpResponse userResp = new HttpResponse();
userResp.setStatusCode(200);
userResp.setBody('{"user": "alice"}');
HttpResponse accountResp = new HttpResponse();
accountResp.setStatusCode(200);
accountResp.setBody('{"accounts": []}');
Test.setMock(HttpCalloutMock.class,
new MultiRequestMock(new Map<String, HttpResponse>{
'/users' => userResp,
'/accounts' => accountResp
}));
Test.startTest();
// Code can call either endpoint in any order.
Test.stopTest();
}
This pattern handles realistic multi-endpoint code without ordering brittleness.
Verifying the request
Sometimes the test needs to assert that the request was constructed correctly: the right URL, the right headers, the right body. The mock can capture this:
@isTest
public class RecordingMock implements HttpCalloutMock {
public List<HttpRequest> capturedRequests = new List<HttpRequest>();
private HttpResponse cannedResponse;
public RecordingMock(HttpResponse response) {
this.cannedResponse = response;
}
public HttpResponse respond(HttpRequest req) {
this.capturedRequests.add(req);
return this.cannedResponse;
}
}
// In the test:
@isTest
static void testRequestFormat() {
HttpResponse successResp = new HttpResponse();
successResp.setStatusCode(200);
successResp.setBody('{}');
RecordingMock mock = new RecordingMock(successResp);
Test.setMock(HttpCalloutMock.class, mock);
Test.startTest();
IntegrationService.doSomething();
Test.stopTest();
System.assertEquals(1, mock.capturedRequests.size());
HttpRequest captured = mock.capturedRequests[0];
System.assert(captured.getEndpoint().contains('/expected/path'));
System.assertEquals('POST', captured.getMethod());
System.assertEquals('application/json',
captured.getHeader('Content-Type'));
// Parse and assert the body.
Map<String, Object> body = (Map<String, Object>)
JSON.deserializeUntyped(captured.getBody());
System.assertEquals('expected-value', body.get('field'));
}
This pattern catches bugs where the code calls the right endpoint but sends a malformed request, or sets the wrong header, or omits a required field.
Async callout testing
Callouts inside Queueable, @future, or Batch Apex need slightly different test setup. The mock must be registered before the async work runs:
@isTest
static void testQueueableCallout() {
Test.setMock(HttpCalloutMock.class, new SimpleMock());
Test.startTest();
System.enqueueJob(new MyQueueable(testIds));
Test.stopTest(); // Queueable runs here, with the mock active.
// Assert post-callout state.
}
Test.stopTest forces async execution to complete. The mock registered before Test.startTest is active throughout.
Sapota's callout testing checklist
The patterns that produce reliable callout coverage:
- Success path mock for the happy case. Every callout method has at least one success test.
- Failure path mocks for 4xx, 5xx, and timeout. Each callout method tested against each failure class.
- Sequence or routing mock for multi-endpoint code. No code calling multiple endpoints relies on the basic single-response mock.
- Recording mock for request format verification. Critical callouts (payments, integrations with strict schemas) verify request format explicitly.
- Async callout tests force execution via Test.stopTest. No "test passed but the async work never ran" gaps.
- Mock library in a single test utility class. SimpleMock, FailureMock, SequenceMock, MultiRequestMock, RecordingMock all live in
CalloutMocks or similar. Tests compose them.
Common callout testing mistakes
Five patterns Sapota has seen in audits:
- Only success path tested. Production hits a 503 and the code crashes because nothing exercised the failure path.
- Mock returns one response, code makes multiple callouts. Test passes by accident if the first callout is the one whose response matters. Real flow breaks differently.
- Async callouts tested without Test.stopTest. Async work never runs in the test, coverage reports include lines that were never executed.
- Request format not verified. Code sends a malformed JSON body. Tests pass because the mock does not care what was sent. Production fails.
- Mock not registered before async enqueue. Async job runs after
Test.startTest, but the mock was set up after. Real callout attempted, test fails confusingly.
What good callout testing looks like
A Salesforce org with healthy callout coverage:
- A
CalloutMocks test utility class with reusable mock implementations.
- Every callout method tested for success, 4xx, 5xx, and timeout scenarios.
- Request format verified for integrations with strict schemas (payments, billing, identity).
- Async callouts wrapped in
Test.startTest/stopTest discipline.
- 200-record bulk variants of integration tests for code that processes batches.
Sapota's Salesforce team holds the Platform Developer I credential and treats callout testing as a first-class concern on every integration engagement. The mock infrastructure pays back the first time production hits a callout failure the test suite anticipated.
Building or auditing Apex callout test coverage? Sapota's Salesforce team handles integration testing, mock infrastructure, and async test discipline on production engagements. Get in touch ->
See our full platform services for the stack we cover.