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 POST your 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.

flowchart LR T["Test suite"] MB["Mountebank :2525 (admin)"] IMP["Imposter :3000 (mock)"] APP["Application under test"] T -->|"POST /imposters (setup)"| MB MB -->|"creates"| IMP APP -->|"GET /services/data/..."| IMP IMP -->|"200 + stub response"| APP T -->|"DELETE /imposters/3000 (teardown)"| MB

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.

References Link to heading