The answer, briefly: Apex tests cannot make HTTP callouts, so you register a mock that returns a response you construct. Test.setMock(HttpCalloutMock.class, new YourMock()) before Test.startTest(), and every callout in the test gets your response instead of the network. The part people get wrong is testing only the happy path.
The minimum that works
@IsTest
private class ErpClientTest {
private class OkMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setHeader('Content-Type', 'application/json');
res.setBody('{"orderId":"ERP-1001","status":"accepted"}');
return res;
}
}
@IsTest
static void acceptsOrder() {
Test.setMock(HttpCalloutMock.class, new OkMock());
Test.startTest();
ErpClient.Result r = ErpClient.send(new Order(ref = 'A1'));
Test.stopTest();
System.assertEquals('ERP-1001', r.externalId, 'external id should be read from the body');
}
}
Two rules the compiler will not enforce. The mock must be registered before the callout happens, which in practice means before Test.startTest(). And a callout made from a queueable or future method only runs at Test.stopTest(), so the mock has to be registered before that too.
Assert on the request, not just the response
The mock receives the HttpRequest. Most tests throw it away, which means they never verify that you sent the right thing. That is half the value of the test:
public HttpResponse respond(HttpRequest req) {
System.assertEquals('POST', req.getMethod());
System.assert(req.getEndpoint().endsWith('/api/orders'),
'endpoint was ' + req.getEndpoint());
Map<String, Object> body =
(Map<String, Object>) JSON.deserializeUntyped(req.getBody());
System.assertEquals('A1', body.get('reference'));
...
}
An assertion inside the mock fails the test with a useful message, and it catches the class of bug where you refactor the payload and every test still passes because they only check the response you hard-coded.
The cases that actually break in production
A single 200 mock tests almost nothing. Write one test per failure mode, because each has a different correct behaviour:
- 500 from the server. Should retry, or record and move on. Should not throw an unhandled exception at a user.
- 401 or 403. Should not retry — the credentials will not fix themselves on a timer.
- A timeout. Throw
new CalloutException('Read timed out')from the mock. This is the one people skip and it is the one that happens most, and a timeout does not mean nothing happened at the far end. - A 200 with a body you did not expect — empty, HTML from a proxy, or valid JSON with a missing field. Real integrations receive all three.
private class TimeoutMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
throw new CalloutException('Read timed out');
}
}
Several callouts in one test
A single mock class handles this by branching on the endpoint, which keeps the routing visible:
public HttpResponse respond(HttpRequest req) {
if (req.getEndpoint().contains('/token')) { return tokenResponse(); }
if (req.getEndpoint().contains('/orders')) { return orderResponse(); }
throw new CalloutException('unmocked endpoint: ' + req.getEndpoint());
}
The final throw is the useful line. Without it, an unexpected callout gets a null response and fails somewhere confusing.
Static resources, and why they age badly
Test.setMock(HttpCalloutMock.class, new StaticResourceCalloutMock()) serves a response body from a static resource. It is convenient for a large payload and it puts the fixture somewhere nobody looks. A body that lives in the test class is read by whoever is debugging the test. A static resource is not.
Frequently asked
Can I test without a mock? No. An unmocked callout in a test throws “You have uncommitted work pending” or a methods-defined-as-TestMethod error, depending on the path.
Does the mock apply to Named Credential callouts? Yes. The endpoint arrives as the callout: string, so assert against that rather than a full URL.
What about WebServiceMock? Same idea for SOAP calls generated from WSDL. Different interface, same registration through Test.setMock.
