Apex leaves much to be desired when it comes to mocking callouts to external services. Use of the HttpCalloutMock interface requires that a test author configure mock responses for ALL callouts made through the course of a test’s processing, and this can be quite unmanageable in large or complex projects. There must be a better way… Enter the HTTP Mock Registry.
Frustrated with messy tests and abstraction leaks, we’ve devised a utility that provides a intuitive interface for mocking callouts declaratively as well as a foundation for more modular test suites whenever callouts are involved.
I’ll first go over how this thing works, but feel free to skip to the code if your name’s Linus or you just don’t feel like following along.
How it works
The HTTP Mock Registry is pretty simple in concept. It provides a simple
interface for mapping mock responses to callout endpoints and paths,
and uses an abstracted HTTPCalloutMock
to route all the registered
mock responses.
Default Mock Response
When you don’t care about the response, mocking a particular endpoint is as simple as:
HttpMockRegistry.mockCallout('https://example.com/do-the-thing');
This will cause HttpMockRegistry.DEFAULT_MOCK_RESPONSE
to be returned
for any attempted call to https://example.com/do-the-thing
Custom response
While the above is the simplest approach, you’ll usually want to
provide a specific mock response for testing. Simply provide an
HttpResponse
instance to mockCallout()
in order to return that
response for the specified endpoint:
String mockResponseBody = '{"animal": "fox", message": "Ring-ding-ding-ding-dingeringeding"}';
HttpMockRegistry.mockCallout('https://what-does-it-say.io/fox', HttpMockRegistry.createSuccessResponse(mockResponseBody));
Named Credentials
If you’re using named credentials, the named credential can be referenced as usual:
String mockResponseBody = '{"animal": "fox", message": "Ring-ding-ding-ding-dingeringeding"}';
HttpMockRegistry.mockCallout(
'callout:WhatDoesItSay', // Named credential identifying endpoint and authentication
HttpMockRegistry.createSuccessResponse(mockResponseBody) // Creates a 200 HttpResponse with provided body
);
Endpoint Paths
Typically, one endpoint will provide a variety of services across different paths. Rather than providing the path as part of the endpoint URL, it can be provided as a separate argument. This is especially useful when using named credentials.
String mockResponseBody = '{"animal": "fox", message": "Ring-ding-ding-ding-dingeringeding"}';
HttpMockRegistry.mockCallout(
'callout:WhatDoesItSay', // Named credential identifying endpoint and authentication
'fox', // Path to specific service being mocked
HttpMockRegistry.createSuccessResponse(mockResponseBody) // Creates a 200 HttpResponse with provided body
);
Path/Response Map
The declarative nature of the HTTP Mock Registry allows you to
invoke mockCallout()
as many times as needed, but it can often
be more concise and readable to declare mock responses for multiple
paths as a map.
Instead of this:
String mockFoxResponseBody = '{"animal": "fox", message": "Ring-ding-ding-ding-dingeringeding"}';
String mockCowResponseBody = '{"animal": "cow", message": "Moo"}';
String mockDogResponseBody = '{"animal": "dog", message": "Woof"}';
HttpMockRegistry.mockCallout('callout:WhatDoesItSay', 'fox', HttpMockRegistry.createSuccessResponse(mockFoxResponseBody));
HttpMockRegistry.mockCallout('callout:WhatDoesItSay', 'cow', HttpMockRegistry.createSuccessResponse(mockCowResponseBody));
HttpMockRegistry.mockCallout('callout:WhatDoesItSay', 'dog', HttpMockRegistry.createSuccessResponse(mockDogResponseBody));
We can write this:
String mockFoxResponseBody = '{"animal": "fox", message": "Ring-ding-ding-ding-dingeringeding"}';
String mockCowResponseBody = '{"animal": "cow", message": "Moo"}';
String mockDogResponseBody = '{"animal": "dog", message": "Woof"}';
HttpMockRegistry.mockCallout('callout:Services', new Map<String, HttpResponse> {
'fox' => HttpMockRegistry.createSuccessResponse(mockFoxResponseBody),
'cow' => HttpMockRegistry.createSuccessResponse(mockCowResponseBody),
'dog' => HttpMockRegistry.createSuccessResponse(mockDogResponseBody)
});
Creating Mock Responses
The HTTP Mock Registry provides convenience methods for constructing
instances of HttpResponse
. If a simple place holder is all that’s
required, you can use HttpMockRegistry.DEFAULT_MOCK_RESPONSE
;
otherwise, the following static methods can be used to construct
success or error responses for your mocks:
// Create a 200 response with a given response body
HttpMockRegistry.createSuccessResponse('{ "message": "Success!" }');
// Create a response with a given status code, status message, and response body
HttpMockRegistry.createResponse(200, 'Ok', { "message": "Success!" }');
HttpMockRegistry.createResponse(500, 'Internal Server Error, '{ "message": "Whoops!" }');
Testing Pattern Example
The convenience of the HTTP Mock Registry’s interface is great, but where it can really make a difference is in simplifying the testing of complex systems with numerous callouts. The goal when mocking our services should be to promote reuse and avoid abstraction leakage. Assuming we’ve employed proper modularization through well-defined service classes, a scalable pattern using the HTTP Mock Registry is pretty straightforward. Here’s one example approach:
- Consolidate and encapsulate the definition of mock responses and registration of HTTP mocks for any given HTTP service in its corresponding test class
- Expose a method (or methods) from the service’s test class that will mock all associated callouts
- Invoke the exposed method wherever needed (from any test class that will result in the service being invoked)
Using the examples from above, we’d likely have a service class called
WhatDoesItSayService
that provides an interface to the external REST services.
Similarly, a test class will be defined called WhatDoesItSayServiceTest
,
responsible for testing the invocation of each service, as well as any
resulting transformations that are applied to responses. WhatDoesItSayServiceTest
should define some method, we’ll call it mockCallouts()
, that
does exactly what you’d expect - invoke HttpMockRegistry.mockCallout()
,
in order to mock all associated endpoints. This method can then be invoked
by any test that will utilize (directly or indirectly) WhatDoesItSayService
,
without needing to know how it works.
Show me the code!
/**
* MIT License
*
* Copyright (c) 2019 Restless Labs, LLC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
/**
* A registry built around the Salesforce Mocking API that allows declarative mocking of HTTP callouts. Mocks responses
* can be registered either for a specific endpoint and path or for all paths on an endpoint, with the former taking
* precedence.
*/
@IsTest
public class HttpMockRegistry {
// Default mock response for HTTP requests
public static final HttpResponse DEFAULT_MOCK_RESPONSE = createSuccessResponse('Default mock response');
// A registry of callout mocks, keyed by endpoint
public static Map<String, CalloutMockConfig> calloutMocks { private get; private set; }
// Static initialization
static {
calloutMocks = new Map<String, CalloutMockConfig>();
Test.setMock(HttpCalloutMock.class, new CalloutResponder());
}
//==================================================================================================================
// Callout mocking
//==================================================================================================================
/**
* Mocks out a callout endpoint to return the default mock response for all requests.
* @param endpoint A callout endpoint
* endpoint and path
*/
public static void mockCallout(String endpoint) {
getCalloutMockConfig(endpoint).setDefaultResponse(DEFAULT_MOCK_RESPONSE);
}
/**
* Mocks out a callout endpoint to return a given mock response by default for all requests.
* @param endpoint A callout endpoint
* @param response An HttpResponse to return whenever an HTTP request is sent to the provided
* endpoint and path
*/
public static void mockCallout(String endpoint, HttpResponse response) {
getCalloutMockConfig(endpoint).setDefaultResponse(response);
}
/**
* Mocks out a callout endpoint to return a specified response for all requests to a particular path.
* @param endpoint A callout endpoint
* @param path The specific path on the given endpoint to mock
* @param response An HttpResponse to return whenever an HTTP request is sent to the provided
* endpoint and path
*/
public static void mockCallout(String endpoint, String path, HttpResponse mockResponse) {
getCalloutMockConfig(endpoint).setResponse(path, mockResponse);
}
/**
* Mocks out a callout endpoint to return a specified responses for particular paths.
* @param endpoint A callout endpoint
* @param mockResponses A map of mock responses, keyed by the path for which they should
* be returned
*/
public static void mockCallout(String endpoint, Map<String, HttpResponse> mockResponses) {
CalloutMockConfig mockConfig = getCalloutMockConfig(endpoint);
for (String path : mockResponses.keySet()) {
mockConfig.setResponse(path, mockResponses.get(path));
}
}
/**
* Creates an HTTP success response with a given body.
* @param body Response body
* @return A 200 response with the given response body
*/
public static HttpResponse createSuccessResponse(String body) {
return createResponse(200, 'OK', body);
}
/**
* Creates an HTTP response with a given status and body.
* @param statusCode Response status code
* @param status Response status message
* @param body Response body
* @return A response with given status and body
*/
public static HttpResponse createResponse(Integer statusCode, String status, String body) {
HttpResponse response = new HttpResponse();
response.setStatusCode(statusCode);
response.setStatus(status);
response.setBody(body);
return response;
}
/**
* Returns the mock configuration for a given callout endpoint. If none has been established, it will be created
* and added to the configuration map.
* @param endpoint The endpoint for which to retrieve/create the mock configuration
*/
private static CalloutMockConfig getCalloutMockConfig(String endpoint) {
CalloutMockConfig mockConfig = calloutMocks.get(endpoint);
if (mockConfig == null) {
mockConfig = new CalloutMockConfig();
calloutMocks.put(endpoint, mockConfig);
}
return mockConfig;
}
//==================================================================================================================
// Mock configs
//==================================================================================================================
/**
* A class to house the configuration of a mocked HTTP callout.
*/
private class CalloutMockConfig {
public Map<String, HttpResponse> responses { get; set; }
public HttpResponse defaultResponse { get; set; }
/**
* Constructor.
*/
public CalloutMockConfig() {
this.responses = new Map<String, HttpResponse>();
this.defaultResponse = DEFAULT_MOCK_RESPONSE;
}
/**
* Configures the response for a specific path.
* @param path An HTTP service request path
* @param An HTTP response to return for the given path
*/
public void setResponse(String path, HttpResponse response) {
responses.put(path, response);
}
/**
* Configures the default response for all paths.
* @param An HTTP response to return for all paths that aren't explicetly configured
*/
public void setDefaultResponse(HttpResponse defaultResponse) {
this.defaultResponse = defaultResponse;
}
/**
* Returns a mock response for the given path.
* @param path An HTTP service request path
* @return A mock HTTP response for the given path
*/
public HttpResponse getResponse(String path) {
HttpResponse response = responses.get(path);
return (response != null) ? response : defaultResponse;
}
}
//==================================================================================================================
// HttpCalloutMock responder
//==================================================================================================================
/**
* An implementation of HttpCalloutMock used to repond to all HTTP requests.
*/
private class CalloutResponder implements HttpCalloutMock {
public HttpResponse respond(HttpRequest request) {
// Split request endpoint into base and path
String base = request.getEndpoint().substringBefore('/');
String path = request.getEndpoint().substringAfter('/');
System.debug(String.format(
'Mocking response for callout endpoint \'\'{0}\'\' on path \'\'{1}\'\'',
new String[] { base, path }
));
// Get mock configuration for endpoint
// If no mock response registered
CalloutMockConfig mockConfig = calloutMocks.get(base);
if (mockConfig == null) {
throw new UnmockedCalloutException(String.format(
'No mock response registered for callout to endpoint \'\'{0}\'\'',
new String[] { base }
));
}
// Find appropriate response in registry
HttpResponse response = mockConfig.getResponse(path);
System.debug('Response: ' + response);
return response;
}
}
//==================================================================================================================
// Exceptions
//==================================================================================================================
// Exception thrown when a callout is intercepted that has not been properly mocked
public class UnmockedCalloutException extends Exception {}
}
Looking for More?
If you found the HTTP Mock Registry useful or interesting, check out our abstraction to simplify the Stub API!