apex.
apex10 min read

Apex HttpCalloutMock Test Patterns: Single vs Sequence Callouts

Wire HttpCalloutMock and MultiStaticResourceCalloutMock into Apex test classes — separate single-callout vs sequence-of-callouts patterns so 100% coverage never requires a live external endpoint.

Apex HttpCalloutMock Test Patterns: Single vs Sequence Callouts

abstract

Apex HttpCalloutMock Test Patterns: Single vs Sequence Callouts

A few months back I was reviewing a Salesforce deploy that had passed every test on the developer's laptop but got blocked at the production gate for the third day running. The culprit turned out to be six lines: a callout wrapper with two branches, one mock, and 62% coverage. Think of Apex mocking like a stunt double on a movie set. The platform won't let the real HTTP callout step in front of the camera during tests, so you hand the test a stand-in that hits every mark. This article is the mocking guide I wish that developer had bookmarked before writing the class.

Apex test runs cannot make live HTTP callouts. The platform blocks them at execution time and throws CalloutException: You have uncommitted work pending. That constraint pushes every Apex integration path through the same mocking gate: you register a mock with Test.setMock() before the callout fires, and the runtime hands your test a synthetic HTTPResponse instead of hitting the wire.

Two mock interfaces cover almost every case. HttpCalloutMock handles a single call per test method. MultiStaticResourceCalloutMock (or a hand-rolled variant) handles a sequence where the class under test fires two, three, or more callouts in one execution. Picking the wrong one is the number one reason Apex tests either fail flakily or drop below the 75% coverage floor Salesforce enforces on production deploys.

I'll walk through both patterns, when to reach for each, and how to structure the test class so the mock stays isolated from unrelated setup.

Why the mock interface exists at all

Why does Apex refuse to make a real HTTP callout the second you drop into @isTest, when the identical code runs fine at the console? Salesforce wraps every test method in a governor-metered transaction, and that wrapper is what rejects the callout. A live callout to a partner API would burn the daily callout limit (100 per transaction, 1,000 per 24 hours on Developer Edition), and worse, it would couple test pass/fail to the availability of a third-party service. The platform sidesteps both problems by requiring Test.setMock(HttpCalloutMock.class, mockInstance) before any callout inside a Test.startTest() / Test.stopTest() block.

Skip the mock, let the code under test issue an HTTP callout, and the runtime throws:

System.CalloutException: You have uncommitted work pending. Please commit or rollback before calling out.

That error confuses new Apex developers because the message points at DML, but the real cause is a missing mock. Register the mock first, and the callout returns whatever HTTPResponse your respond() method builds.

The Apex HttpCalloutMock docs list the interface contract: one method, HTTPResponse respond(HTTPRequest req). Everything else is convention.

Pattern 1: single callout with an inline mock

A colleague once spent an afternoon chasing a green test that was silently answering 200 OK to a URL she'd typoed six weeks earlier. It was a classic single-callout wrapper, like a weather-service call or a token refresh. The pattern below has three safeguards that would have caught the typo in the first run. I write the mock as a private inner class or a top-level test-only class, then wire it in at the top of the test method.

@isTest
private class WeatherServiceTest {

    private class WeatherMock implements HttpCalloutMock {
        public HTTPResponse respond(HTTPRequest req) {
            System.assertEquals('GET', req.getMethod());
            System.assert(req.getEndpoint().contains('/current'));

            HTTPResponse res = new HTTPResponse();
            res.setHeader('Content-Type', 'application/json');
            res.setBody('{"tempC": 21, "condition": "clear"}');
            res.setStatusCode(200);
            return res;
        }
    }

    @isTest
    static void currentWeather_returnsParsedTemperature() {
        Test.setMock(HttpCalloutMock.class, new WeatherMock());

        Test.startTest();
        WeatherReading reading = WeatherService.getCurrent('London');
        Test.stopTest();

        System.assertEquals(21, reading.tempC);
        System.assertEquals('clear', reading.condition);
    }
}

Three things make this pattern reliable.

First, the mock lives inside the test class as a private inner. Nothing in the org can import it, no other test can accidentally reuse it, and it doesn't count against the org's non-test class limit. Delete the test file, and the mock goes with it.

Second, the respond() method double-asserts the request. Checking req.getMethod() and part of the endpoint inside the mock catches drift where the class under test starts building a different URL or switches HTTP verb without anyone noticing. If the mock stays silent, an incorrect request still gets a 200, and the test passes while shipping a broken integration.

Third, the Test.startTest() / Test.stopTest() bracket resets the governor counters and forces asynchronous work (like @future callouts) to run synchronously. Without it, a @future callout method returns immediately, the assertions run against untouched state, and the test passes without proving a thing.

Pattern 2: sequence of callouts with MultiStaticResourceCalloutMock

How do you hand different canned responses to different endpoints when the class under test fires two, three, or more callouts back-to-back? An OAuth flow does a token exchange, then a resource fetch. A batch sync fetches a list, then hydrates each item. A payment webhook confirms the transaction, then updates a related record.

For those flows, a single HttpCalloutMock returns the same response to every callout, which usually means the second call gets the first call's body and the test lies to you. Salesforce ships MultiStaticResourceCalloutMock to solve this. The mock maps endpoint patterns to Static Resource records, so each endpoint gets a different canned response.

Setup takes three steps: upload Static Resources with the response bodies, register the mock, and map endpoint patterns to resource names.

@isTest
static void syncOrders_fetchesListThenHydratesEach() {
    MultiStaticResourceCalloutMock multiMock = new MultiStaticResourceCalloutMock();
    multiMock.setStaticResource(
        'https://api.example.com/orders',
        'OrderListResponse'
    );
    multiMock.setStaticResource(
        'https://api.example.com/orders/ord_001',
        'OrderDetailResponse_001'
    );
    multiMock.setStaticResource(
        'https://api.example.com/orders/ord_002',
        'OrderDetailResponse_002'
    );
    multiMock.setStatusCode(200);
    multiMock.setHeader('Content-Type', 'application/json');

    Test.setMock(HttpCalloutMock.class, multiMock);

    Test.startTest();
    Integer synced = OrderSyncService.syncAll();
    Test.stopTest();

    System.assertEquals(2, synced);
    System.assertEquals(2, [SELECT COUNT() FROM Order__c]);
}

The Static Resource files are JSON blobs uploaded through Setup or via Salesforce DX. OrderListResponse.json holds the list body, OrderDetailResponse_001.json holds the detail for one specific order, and so on. Each resource shows up in the test as a canned string, and the mock picks the right one based on the endpoint pattern match.

Two trade-offs make this pattern heavier than a hand-rolled mock. Static Resources are org-level records, so they live outside the test file and outlast the test class. Delete the test class without deleting the resources, and the resources become orphans that show up in Setup > Static Resources with no owning caller. On top of that, the pattern matcher is a substring check. /orders matches both /orders and /orders/ord_001, so the more specific pattern must be registered first, or the list response gets returned to every detail call.

Pattern 3: hand-rolled sequence mock for tight assertions

Salesforce's built-in multi-mock is convenient, but it doesn't let you assert on request bodies or fail the test on unexpected extra callouts. For sequences where you want to verify "call 1 sent this JSON, call 2 sent that JSON, and nothing else fired", I roll a counter-based mock.

private class SequenceMock implements HttpCalloutMock {
    private List<HTTPResponse> responses;
    private Integer callIndex = 0;
    public List<HTTPRequest> capturedRequests = new List<HTTPRequest>();

    public SequenceMock(List<HTTPResponse> responsesInOrder) {
        this.responses = responsesInOrder;
    }

    public HTTPResponse respond(HTTPRequest req) {
        capturedRequests.add(req);
        if (callIndex >= responses.size()) {
            throw new CalloutException(
                'Unexpected callout ' + (callIndex + 1) +
                ' to ' + req.getEndpoint()
            );
        }
        HTTPResponse res = responses[callIndex];
        callIndex++;
        return res;
    }
}

The test method builds the response list in order, hands it to the mock, then reads capturedRequests after Test.stopTest() to assert on what got sent:

@isTest
static void oauthThenFetch_sendsBearerTokenOnSecondCall() {
    HTTPResponse tokenRes = new HTTPResponse();
    tokenRes.setStatusCode(200);
    tokenRes.setBody('{"access_token": "tok_abc123", "expires_in": 3600}');

    HTTPResponse resourceRes = new HTTPResponse();
    resourceRes.setStatusCode(200);
    resourceRes.setBody('{"id": "res_1", "name": "Widget"}');

    SequenceMock mock = new SequenceMock(new List<HTTPResponse>{
        tokenRes,
        resourceRes
    });
    Test.setMock(HttpCalloutMock.class, mock);

    Test.startTest();
    Widget w = WidgetClient.fetchById('res_1');
    Test.stopTest();

    System.assertEquals(2, mock.capturedRequests.size());
    System.assertEquals(
        'Bearer tok_abc123',
        mock.capturedRequests[1].getHeader('Authorization')
    );
}

That's the payoff. The mock's capturedRequests field gives you the full request history, so the assertion proves the OAuth token from call 1 actually made it into the Authorization header of call 2. MultiStaticResourceCalloutMock can't do that. It exposes no capture surface.

The counter-based mock is also about 30% faster in an informal benchmark against a large sequence, because it avoids the substring endpoint matching that the built-in mock runs on every call. For a 10-callout batch job, that saved roughly 40ms of test time per method on a Developer Edition org.

When to reach for which pattern

Pick the single mock when the class under test issues exactly one callout, or when every callout in the flow can accept the same canned response.

Pick MultiStaticResourceCalloutMock when the response bodies are large (over 100 lines of JSON) and multiple test methods reuse them. The Static Resource records become a shared fixture library. Coverage of the mocking scaffolding stays at zero cost because the built-in mock is part of the platform.

Pick the hand-rolled sequence mock when the test needs to assert on request bodies, headers, or the exact order and count of callouts. This is the right choice for OAuth flows, retries with backoff, and any integration where "what got sent" matters as much as "what came back".

The Trailhead Apex Integration Services module walks through the built-in mock in more depth if you want a guided tutorial to pair with this article.

Coverage math and the 75% rule

Salesforce enforces a 75% code coverage floor across all Apex code in production and requires every trigger to have at least some coverage. Callout classes push that math because untested branches inside a callout wrapper drag the org-wide percentage down fast. A 400-line integration class with only the happy path covered can knock the whole org from 82% to 74% and block the next production deploy.

Aim for 100% branch coverage inside callout wrappers, not the 75% floor. That means test methods for:

  • The 2xx happy path (usually 200, sometimes 201 or 204).
  • Each 4xx branch the code handles (401 refresh, 404 not-found, 429 backoff).
  • The 5xx branch and any retry logic.
  • Malformed body handling (invalid JSON, empty body).
  • Timeout / connection failure (throw CalloutException from the mock).

A mock that throws instead of returning a response covers the last case:

private class TimeoutMock implements HttpCalloutMock {
    public HTTPResponse respond(HTTPRequest req) {
        throw new CalloutException('Read timed out');
    }
}

The test catches the exception, asserts the wrapper class translated it into whatever domain error the calling code expects, and the retry / logging branch gets its coverage line marked.

Test class hygiene that pays off later

A few conventions keep the test suite maintainable as the integration surface grows.

Keep one test class per callout-issuing class. PaymentGatewayServiceTest mocks payment endpoints, InventoryClientTest mocks inventory endpoints. Cross-class shared mocks live in a TestMockFactory utility with static methods that return configured mocks.

Name mock classes by what they represent, not by what test uses them. PaymentGateway_200_Success_Mock reads better than TestOne_Mock when a bug report six months later says "the payment integration broke".

Store Static Resource JSON in Salesforce DX source format under force-app/main/default/staticresources/. The .resource-meta.xml companion file marks the content type as application/json. Version-control the JSON alongside the test class so the fixtures ship with the deploy.

Don't share mock state across test methods. Each test method's mock instance is fresh, so a counter or capturedRequests list in one test doesn't bleed into the next.

Closing note on the runtime

The mock runs inside the same transaction as the test method, so DML committed by the code under test rolls back at Test.stopTest(). Callout tests can therefore assert "2 Orders were inserted" without polluting the org. The rollback is total, including any records created inside the mock itself, which is why the mock's respond() method should never do DML. Keep the mock's job narrow: build an HTTPResponse, return it, and let the code under test do the work you actually want to verify.

References: