You Can’t Deploy That Callout Without Testing It – Here’s How to Do It Right

Imagine this: you’ve spent two days building a slick Apex integration that calls an external REST API, maybe it’s syncing contacts to a marketing platform, or pulling invoice data from an ERP. Everything works beautifully in your scratch org. You hit Run All Tests before pushing to production and…

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

Or worse, all your tests just skip the callout logic entirely because you never mocked it.

If you’ve been in Salesforce development for more than a few months, you’ve met this wall. And the solution isn’t to wrap your callout in a try-catch and hope for the best. The solution is mock testing, and once you understand it, you’ll never fear deploying an integration again.

Let’s dig in.

Why You Can’t Just “Test” a Callout Normally

Salesforce has a hard rule: Apex tests cannot make real HTTP callouts. Period.

This is actually a good thing. Think about it, if your test suite fired real API calls every time someone ran tests, you’d be:

  • Hitting rate limits on external APIs
  • Polluting production data in third-party systems
  • Making your test suite dependent on external uptime
  • Potentially racking up API costs

So Salesforce forces you to simulate the HTTP response. You tell the test environment: “When this code tries to make an HTTP call, return THIS fake response instead.”

That’s mocking. And Salesforce gives you two clean ways to do it.

The Foundation: HttpCalloutMock Interface

Before we look at the two approaches, let’s understand the backbone of everything, the HttpCalloutMock interface.

public interface HttpCalloutMock {
HTTPResponse respond(HTTPRequest req);
}

Any class that implements this interface becomes a mock responder. When you register it using Test.setMock(), Salesforce intercepts every outgoing HTTP request during that test and routes it through your respond() method instead of actually hitting the internet.

Think of it like this — your callout class asks the internet a question, but your mock intercepts the question and hands back a pre-written answer. The calling code has no idea anything unusual happened.

Approach 1: Implementing HttpCalloutMock Directly in Apex

This is the most common and flexible approach. You write a dedicated Apex class that implements HttpCalloutMock and returns a crafted HttpResponse.

Real-World Scenario

Let’s say you’re integrating with a weather API to fetch temperature data based on a city name, and you store the result on a custom object Weather_Report__c.

Step 1: Your Callout Service Class

public class WeatherService {
public static String getWeatherData(String city) {
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.weatherprovider.com/v1/current?city=' + city);
req.setMethod('GET');
req.setHeader('Authorization', 'Bearer ' + getApiKey());
req.setHeader('Content-Type', 'application/json');
req.setTimeout(10000);
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() == 200) {
return res.getBody();
} else {
throw new WeatherServiceException(
'API call failed with status: ' + res.getStatusCode()
);
}
}
private static String getApiKey() {
// Fetch from Custom Metadata or Named Credential
return 'your-api-key-here';
}
public class WeatherServiceException extends Exception {}
}

Step 2: Create the Mock Class

@isTest
public class WeatherServiceMock implements HttpCalloutMock {
private Integer statusCode;
private String body;
// Constructor to control what the mock returns
public WeatherServiceMock(Integer statusCode, String body) {
this.statusCode = statusCode;
this.body = body;
}
public HTTPResponse respond(HTTPRequest req) {
// You can validate the request here too!
System.assertEquals(
'GET',
req.getMethod(),
'Expected a GET request'
);
System.assert(
req.getEndpoint().contains('weatherprovider.com'),
'Unexpected endpoint called'
);
HttpResponse res = new HttpResponse();
res.setStatusCode(this.statusCode);
res.setBody(this.body);
res.setHeader('Content-Type', 'application/json');
return res;
}
}

Step 3: Write the Test Class

@isTest
private class WeatherServiceTest {
static String successBody = JSON.serialize(new Map<String, Object>{
'city' => 'Pune',
'temperature' => 32,
'unit' => 'Celsius',
'condition' => 'Partly Cloudy'
});
@isTest
static void testGetWeatherData_Success() {
// Arrange: Set up the mock
WeatherServiceMock mock = new WeatherServiceMock(200, successBody);
Test.setMock(HttpCalloutMock.class, mock);
// Act: Call the method
Test.startTest();
String result = WeatherService.getWeatherData('Pune');
Test.stopTest();
// Assert: Verify response
System.assertNotEquals(null, result, 'Response body should not be null');

Map<String, Object> parsed =
(Map<String, Object>) JSON.deserializeUntyped(result);
System.assertEquals('Pune', parsed.get('city'));
System.assertEquals(32, (Integer) parsed.get('temperature'));
}
@isTest
static void testGetWeatherData_APIError() {
// Arrange: Simulate a 500 error
WeatherServiceMock mock = new WeatherServiceMock(
500,
'{"error": "Internal Server Error"}'
);
Test.setMock(HttpCalloutMock.class, mock);
// Act & Assert: Expect exception
Test.startTest();
try {
WeatherService.getWeatherData('Pune');
System.assert(false, 'Should have thrown an exception');
} catch (WeatherService.WeatherServiceException e) {
System.assert(
e.getMessage().contains('500'),
'Exception message should include status code'
);
}
Test.stopTest();
}
}

Notice a few things:

  • Test.setMock(HttpCalloutMock.class, mock) – this is the critical registration step. Without this line, your callout will fail at runtime in the test.
  • Test.startTest() / Test.stopTest() – always wrap your callout call here. This resets governor limits and ensures async operations complete.
  • You’re testing both the happy path AND the error path – that’s non-negotiable in good test coverage.

Approach 2: Using a Static Resource File

Sometimes the JSON response from an API is massive – hundreds of fields, nested arrays, complex structures. Embedding that in an Apex string is a maintenance nightmare. That’s where Static Resources shine.

When to Use This Approach

  • Large, complex JSON or XML response bodies
  • You want to version-control API response samples separately
  • Your QA team or API documentation provides sample response files
  • You’re testing response parsing logic heavily

Step 1: Create the Static Resource

  1. Go to Setup → Static Resources → New
  2. Name it something meaningful: WeatherApiResponse
  3. Set Cache Control to Public
  4. Upload a .json file (or .txt file with JSON content):

 

{
"city": "Pune",
"temperature": 32,
"unit": "Celsius",
"condition": "Partly Cloudy",
"humidity": 68,
"wind_speed": 14,
"forecast": [
{ "day": "Monday", "high": 34, "low": 26 },
{ "day": "Tuesday", "high": 33, "low": 25 },
{ "day": "Wednesday", "high": 31, "low": 24 }
]
}

Step 2: Create the Static Resource Mock

@isTest
public class StaticResourceMock implements HttpCalloutMock {
private String staticResourceName;
private Integer statusCode;
public StaticResourceMock(String staticResourceName, Integer statusCode) {
this.staticResourceName = staticResourceName;
this.statusCode = statusCode;
}
public HTTPResponse respond(HTTPRequest req) {
// Load the body from Static Resource
StaticResource sr = [
SELECT Body
FROM StaticResource
WHERE Name = :staticResourceName
LIMIT 1
];
HttpResponse res = new HttpResponse();
res.setStatusCode(this.statusCode);
res.setBody(sr.Body.toString());
res.setHeader('Content-Type', 'application/json');
return res;
}
}

Step 3: Test Using the Static Resource Mock

@isTest
private class WeatherServiceStaticResourceTest {
@isTest
static void testGetWeatherData_WithStaticResource() {
// Arrange
StaticResourceMock mock = new StaticResourceMock('WeatherApiResponse', 200);
Test.setMock(HttpCalloutMock.class, mock);
// Act
Test.startTest();
String result = WeatherService.getWeatherData('Pune');
Test.stopTest();
// Assert: Parse and verify the rich response
Map<String, Object> parsed =
(Map<String, Object>) JSON.deserializeUntyped(result);
System.assertEquals('Pune', parsed.get('city'));
System.assertEquals(32, (Integer) parsed.get('temperature'));

List<Object> forecast = (List<Object>) parsed.get('forecast');
System.assertEquals(3, forecast.size(), 'Should have 3 forecast days');
}
}

The advantage here is obvious, your test stays lean and readable, while the actual response data lives in a properly structured file that can be updated independently.

Salesforce’s Built-In: MultiStaticResourceCalloutMock and StaticResourceCalloutMock

Salesforce also ships two out-of-the-box utility classes that you can use without writing your own mock class.

StaticResourceCalloutMock

@isTest
static void testWithBuiltInStaticMock() {
StaticResourceCalloutMock mock = new StaticResourceCalloutMock();
mock.setStaticResource('WeatherApiResponse');
mock.setStatusCode(200);
mock.setHeader('Content-Type', 'application/json');
    Test.setMock(HttpCalloutMock.class, mock);    Test.startTest();
String result = WeatherService.getWeatherData('Pune');
Test.stopTest();
System.assertNotEquals(null, result);
}

MultiStaticResourceCalloutMock

When your code makes multiple callouts to different endpoints in a single transaction, you need this class. It lets you map specific endpoints to specific static resources.

@isTest
static void testMultipleCallouts() {
MultiStaticResourceCalloutMock multiMock = new MultiStaticResourceCalloutMock();

// Map each endpoint URL to a different static resource
multiMock.setStaticResource(
'https://api.weatherprovider.com/v1/current?city=Pune',
'WeatherApiResponse'
);
multiMock.setStaticResource(
'https://api.weatherprovider.com/v1/forecast?city=Pune',
'WeatherForecastResponse'
);
multiMock.setStatusCode(200);
multiMock.setHeader('Content-Type', 'application/json');
    Test.setMock(HttpCalloutMock.class, multiMock);    Test.startTest();
// Call code that internally hits both endpoints
WeatherService.getFullWeatherReport('Pune');
Test.stopTest();
// Your assertions here
}

This is incredibly useful for orchestration services that fan out to multiple third-party endpoints in one Apex transaction.

Quick Comparison: Which Approach to Use When?

ScenarioRecommended ApproachSimple JSON response (under ~30 fields)Custom HttpCalloutMock classLarge or complex response payloadStaticResourceCalloutMock + Static Resource fileMultiple endpoints in one transactionMultiStaticResourceCalloutMockNeed to validate request parameters in the mockCustom HttpCalloutMock classResponse changes frequently or maintained by non-developersStatic Resource fileTesting both success and error pathsCustom HttpCalloutMock with constructor params

Common Mistakes That’ll Cost You Time

1. Forgetting Test.setMock() entirely

Your test will throw System.CalloutException: You have uncommitted work pending or simply fail with a callout attempt error. Always register your mock before the callout.

2. Calling the method outside Test.startTest() / Test.stopTest()

Technically it works, but governor limits aren’t reset and async flows won’t complete correctly. Make it a habit to always wrap callout calls between these.

3. Testing only the 200 path

Real-world APIs fail. They return 400s, 401s, 429s, and 503s. Your code needs to handle them, and your tests need to verify that handling. Write a test for each failure scenario your code branches on.

4. Hardcoding JSON in the mock but not updating it when the API changes

This is a silent killer. The API schema evolves, your hardcoded JSON doesn’t, your tests still pass, but your production integration is broken. If the API is under active development, prefer Static Resources, they’re easier to update.

5. Not asserting anything about the request inside the mock

Your respond() method receives the HTTPRequest object. Use it! Validate that the endpoint, method, headers, and body are exactly what you expect. This turns your mock into a contract validator.

The Real Importance of Testing Callouts

Here’s the honest truth, skipping callout testing doesn’t just affect your code quality. It affects:

Deployability: Salesforce requires 75% code coverage to deploy to production. If your callout methods aren’t covered by tests, you’re blocked.

Reliability: An untested integration is a liability. When the production incident happens at 2 AM, you want tests that tell you exactly which edge case broke.

Collaboration: When another developer touches your integration code, proper tests communicate intent. The mock response is the documentation of what the API returns.

Refactoring confidence: Need to change how you parse the response? If the test is thorough, you’ll know immediately if your refactor breaks something.

CI/CD readiness: Automated pipelines (Bitbucket Pipelines, GitHub Actions with SFDX) run your full test suite on every push. If callout tests are missing or flaky, your pipeline becomes untrustworthy.

Wrapping Up

Testing Apex callouts isn’t optional, it’s the professional standard. Once you internalize the pattern (implement HttpCalloutMock, register with Test.setMock(), wrap in Test.startTest()/stopTest()), it becomes second nature.

To recap what we covered:

  • Custom HttpCalloutMock class gives you full control and is ideal for validating request details and simulating varied responses
  • Static Resource files keep your test classes clean when payloads are large or frequently updated
  • StaticResourceCalloutMock and MultiStaticResourceCalloutMock are Salesforce’s built-in utilities for common scenarios
  • Always test both success and failure paths
  • Use the mock’s respond() method to validate requests, not just return responses

The next time you’re building an integration, write your mock class alongside your service class, not after. Future you (and your teammates) will be grateful.

In this article:
Share on social media: