Integration tests that hit real external APIs are slow, flaky, and impossible to run in CI without credentials. But completely mocking at the unit level loses confidence — you’re not testing how your code actually serializes requests, handles auth headers, or parses responses.
The sweet spot is a real HTTP server that returns pre-programmed responses. Your application thinks it’s talking to Salesforce. It’s actually talking to a container on localhost.
That’s Mountebank.
What Mountebank is Link to heading
Mountebank is a Node.js service that creates imposters — real TCP/HTTP listeners you configure via a REST API. It exposes two ports:
- 2525 — the admin API, where you
POSTyour mock definitions - Any port you choose — where your application sends requests
The alternative to this is usually a hand-rolled Flask stub, a shared staging environment you don’t control, or hitting the real API and hoping it behaves. Mountebank replaces all three: it’s a real HTTP server you fully control, configured via API with no code to maintain, and it works regardless of what language your application is written in.
This makes Mountebank a great fit for integration tests in Docker Compose: the imposter container starts alongside your app, and tests configure it fresh for each scenario.
Starting Mountebank Link to heading
The simplest setup is a Docker container:
docker run --rm -p 2525:2525 -p 3000:3000 bbyars/mountebank --allowInjection
--allowInjection enables dynamic JavaScript response handlers (more on that below).
Without it, you’re limited to static responses.
In Docker Compose, wire it alongside your application:
# docker-compose.yml
services:
app:
build: .
environment:
SALESFORCE_URL: http://mountebank:3000
depends_on:
mountebank:
condition: service_healthy
mountebank:
image: bbyars/mountebank
command: --allowInjection
ports:
- "2525:2525"
- "3000:3000"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:2525"]
interval: 5s
retries: 5
Your application reads SALESFORCE_URL from the environment. In production, it points at
https://login.salesforce.com. In tests, it points at http://mountebank:3000. No code
changes needed.
Creating an imposter at runtime Link to heading
Imposters are created by POSTing a JSON document to http://localhost:2525/imposters.
This is the step your test setup runs before each test scenario.
An imposter has a port, a protocol, and a list of stubs. Each stub pairs
predicates (match conditions) with responses (what to return).
Here’s a minimal example — a mock that returns a fixed JSON body for one endpoint:
curl -s -X POST http://localhost:2525/imposters \
-H "Content-Type: application/json" \
-d '{
"protocol": "http",
"port": 3000,
"stubs": [
{
"predicates": [
{ "equals": { "method": "GET", "path": "/services/data/v27.0/sobjects/System__c/abc123" } }
],
"responses": [
{
"is": {
"statusCode": 200,
"headers": { "Content-Type": "application/json" },
"body": {
"Id": "abc123",
"Name": "My Battery System",
"Deployment_Status__c": "Ready"
}
}
}
]
}
]
}'
Any GET /services/data/v27.0/sobjects/System__c/abc123 request to port 3000 now returns
that JSON body with a 200. Requests that don’t match any stub get a 404.
Predicate types Link to heading
Predicates are the matching rules. Mountebank evaluates them all — a stub only fires if every predicate matches.
equals — exact match
Link to heading
{ "equals": { "method": "POST", "path": "/services/oauth2/token" } }
contains — substring or partial match
Link to heading
Useful for query parameters when you don’t want to match the full SOQL string:
{ "contains": { "query": { "q": "FROM Performance_Guarantee__c" } } }
A GET /services/data/v27.0/query?q=SELECT+Id+FROM+Performance_Guarantee__c+WHERE+...
will match because the query param contains that substring.
exists — checks presence of a field
Link to heading
{ "exists": { "headers": { "Authorization": true } } }
Returns true if the Authorization header is present, regardless of its value. Useful
for asserting that your application is actually sending auth headers without caring about
the token value.
Combining predicates Link to heading
All predicates in a stub’s array are ANDed together:
{
"predicates": [
{ "equals": { "method": "POST", "path": "/services/oauth2/token" } },
{ "equals": { "headers": { "Content-Type": "application/x-www-form-urlencoded" } } },
{ "exists": { "headers": { "Authorization": true } } }
]
}
This stub only fires if the request is a POST to the right path, with the right Content-Type, and with an Authorization header present.
Response types Link to heading
Static response (is)
Link to heading
The is response returns a fixed status code, headers, and body every time the stub
matches. The body can be a string or a JSON object:
{
"is": {
"statusCode": 200,
"headers": { "Content-Type": "application/json" },
"body": { "totalSize": 1, "done": true, "records": [] }
}
}
Dynamic response (inject)
Link to heading
When you need logic — different responses based on request content, or simulating auth
validation — inject lets you write a JavaScript function that receives the request and
returns the response:
{
"responses": [
{ "inject": "(config) => { ... }" }
]
}
The injected function receives a config object with config.request (method, path,
headers, body, query). It returns an object with statusCode, headers, and body.
Here’s a Salesforce OAuth2 mock that validates the token header and returns 403 if it’s wrong:
(config) => {
if (config.request.headers["Authorization"] !== "Bearer MY_TOKEN") {
return { statusCode: 403 };
}
return {
statusCode: 200,
body: {
access_token: "fake-token-abc123",
instance_url: "https://test.salesforce.com",
token_type: "Bearer"
}
};
}
inject requires --allowInjection at startup. In production test environments where you
don’t want arbitrary JS execution, stick to static responses and use separate imposters
for different test scenarios.
A complete example: Salesforce integration test Link to heading
Here’s how this comes together in a Python test. The fixture creates the imposter before the test and tears it down after:
# tests/conftest.py
import pytest, requests
MOUNTEBANK_URL = "http://localhost:2525"
@pytest.fixture()
def salesforce_mock():
"""Stand up a Salesforce imposter and tear it down after the test."""
imposter = {
"protocol": "http",
"port": 3000,
"stubs": [
# 1. Auth endpoint — validates the Authorization header
{
"predicates": [
{"equals": {"method": "POST", "path": "/services/oauth2/token"}},
{"exists": {"headers": {"Authorization": True}}}
],
"responses": [{
"inject": """(config) => {
if (config.request.headers['Authorization'] !== 'Bearer MY_TOKEN') {
return { statusCode: 403 };
}
return {
statusCode: 200,
body: {
access_token: 'fake-token-abc123',
instance_url: 'http://localhost:3000',
token_type: 'Bearer'
}
};
}"""
}]
},
# 2. Record lookup — returns a fixed system record
{
"predicates": [
{"equals": {"method": "GET", "path": "/services/data/v27.0/sobjects/System__c/abc123"}}
],
"responses": [{
"is": {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": {
"Id": "abc123",
"Name": "Test Battery System",
"Deployment_Status__c": "Ready"
}
}
}]
},
# 3. SOQL query — matches on partial query string content
{
"predicates": [
{"equals": {"method": "GET", "path": "/services/data/v27.0/query"}},
{"contains": {"query": {"q": "FROM System__c"}}}
],
"responses": [{
"is": {
"statusCode": 200,
"body": {
"totalSize": 1,
"done": True,
"records": [{"Id": "abc123", "Name": "Test Battery System"}]
}
}
}]
}
]
}
resp = requests.post(f"{MOUNTEBANK_URL}/imposters", json=imposter)
resp.raise_for_status()
yield # test runs here
requests.delete(f"{MOUNTEBANK_URL}/imposters/3000")
def test_fetches_system_record(salesforce_mock, salesforce_client):
record = salesforce_client.get_system("abc123")
assert record["Deployment_Status__c"] == "Ready"
def test_auth_fails_with_wrong_token(salesforce_mock, salesforce_client):
client = salesforce_client(token="WRONG_TOKEN")
with pytest.raises(Exception, match="403"):
client.authenticate()
The salesforce_client fixture points at http://localhost:3000 instead of the real
Salesforce domain. The application code doesn’t change — just the base URL from the
environment.
Inspecting what was received Link to heading
After a test runs, you can query Mountebank to see every request the imposter received. This is useful for asserting that your application sent the right headers or body without having to add logging:
curl -s http://localhost:2525/imposters/3000 | jq '.stubs[].requests'
Each stub records the full request history. You can assert in the test that the right endpoint was called the right number of times.
Loading stubs from files Link to heading
For a shared integration test environment (a Docker container that stays up across test
runs), managing all stubs via API calls gets unwieldy. Mountebank supports loading stubs
from files at startup with --configfile:
mb --allowInjection --configfile imposters.ejs start
imposters.ejs is an EJS template that composes the JSON config, letting you split large
response bodies into separate files:
{
"imposters": [
{
"protocol": "http",
"port": 3000,
"stubs": [
{
"predicates": [
{ "equals": { "path": "/services/data/v27.0/sobjects/System__c/abc123", "method": "GET" } }
],
"responses": [{ "is": <%- include('system_record.json') %> }]
}
]
}
]
}
The tradeoff: file-based stubs are static and require a restart to update. Runtime API calls are dynamic and don’t. For integration test suites where each test scenario needs different behavior, the runtime API approach is the right one.
Teardown and isolation Link to heading
Each test should own its imposter for the duration it runs and delete it on cleanup. Delete by port:
DELETE http://localhost:2525/imposters/3000
Or delete all imposters at once to reset state between test suites:
DELETE http://localhost:2525/imposters
If you’re running tests in parallel, give each test a different port so imposters don’t collide. The Mountebank admin API accepts any available port in the imposter config.
When to use this approach Link to heading
Mountebank is worth the setup when:
- Your integration tests depend on external APIs (Salesforce, Slack, Jenkins, GitHub)
- You want the test to exercise the full HTTP stack — headers, serialization, auth flow
- You need to simulate error conditions (401, 429, 5xx) that are hard to trigger against a real API
- Your CI environment can’t reach the external API at all
For testing logic that doesn’t touch HTTP, unit tests with in-process mocks are simpler. Mountebank is for the layer where your code meets the network.