API Mocking & Stubbing
Replace external APIs with controlled mocks so tests run fast, offline, and without dependencies. Test error scenarios (500s, timeouts) that are difficult to trigger on real services. A core technique for test isolation and speed.
What is API mocking & stubbing?
Mocking and stubbing are related techniques for replacing real external APIs with test doubles:
- Stub: A fake implementation that returns predetermined responses. Used to isolate code under test from external dependencies.
- Mock: A stub that also records how it was called, allowing you to verify the calling code interacted with it correctly. Used to check behavior.
In practice, the terms are often used interchangeably. The key benefit: your tests run without making actual network calls, without waiting for real services, and without needing test data on external systems.
Why mock APIs? Real API calls are slow (100ms-1s), flaky (network failures, rate limits), and hard to test. Error scenarios (API returns 500, times out, returns malformed JSON) are expensive to set up and risky to trigger on production systems. Mocks solve all three problems.
Common use cases
- Testing without external dependencies: Your code calls a payment processor (Stripe, PayPal). You can't call the real API in tests. Mock it.
- Third-party API unavailability: An API is down for maintenance, or you're developing offline. Tests need to continue. Use mocks.
- Testing error scenarios: What happens if the payment API returns 500? What if the request times out? Mocks let you return these errors reliably.
- Developing faster: No backend implemented yet. Stub the API so frontend tests and development can proceed in parallel.
- Performance and load testing: Real APIs have rate limits and can't handle thousands of concurrent requests. Mocks can.
- Data isolation: You don't want test data polluting real systems. Mocks ensure no writes to production databases.
Tools for API mocking
| Tool | Type | Key strength | Best for |
|---|---|---|---|
| WireMock | Java library + standalone server | Powerful matching (URL, headers, body), request verification, stubbing API, widely used in enterprises | Integration tests, functional tests with external API dependencies, teams using JVM languages |
| Mountebank | Standalone server (JavaScript/Node) | Protocol-agnostic (HTTP, HTTPS, TCP, SMTP), imposters (multiple mocks), cross-platform | Multi-protocol testing, CI/CD pipelines, teams wanting lightweight open source |
| Prism | Standalone server (JavaScript/Node) | Reads OpenAPI/Swagger specs, generates mocks automatically, example-based responses | Frontend teams, contract-driven development, generating mocks before backend is ready |
| json-server | CLI tool (Node.js) | Minimal setup, watches JSON file, CRUD operations, perfect for learning | Quick prototypes, small projects, developers wanting zero configuration |
Mocking approaches: record/playback, rule-based, stateful
Record/playback
Capture real API responses, then replay them in tests. Useful when you already have a real API and want to record example responses.
Tradeoff: If the real API response changes, your recorded response is stale. You need to re-record periodically.
Rule-based mocking
Define rules: "if you see a request with URL matching /orders/[0-9]+, return this response." Most flexible and recommended for testing.
Stateful mocking
The mock remembers state across requests. "POST /orders creates an order, GET /orders/123 returns the order you just created, DELETE /orders/123 removes it." Simulates real API behavior more closely.
Setting up mocks: request matching and response definition
Basic request matching
A mock needs to know: when you see this request, return that response. Matching can be based on:
- URL path and method:
POST /api/payments - Query parameters:
GET /api/users?status=active - Request headers:
Authorization: Bearer token123 - Request body:
{"email": "test@example.com", ...} - Regular expressions or wildcards: match a range of requests
Defining responses
Once a request matches, the mock returns a response:
// WireMock example
stubFor(post(urlEqualTo("/api/payments"))
.withHeader("Content-Type", containing("application/json"))
.withRequestBody(matchingJsonPath("$.amount", greaterThan(0)))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"transactionId\": \"tx-123\", \"status\": \"approved\"}"))
);
Dynamic responses
Response body can include values from the request:
// Mountebank example: echo back the email from request
{
"stubs": [{
"predicates": [{"equals": {"path": "/api/users"}}],
"responses": [{
"is": {
"statusCode": 200,
"body": "{\"id\": 1, \"email\": \"${email}\"}"
}
}]
}]
}
Error scenario testing: 400, 401, 403, 500
The key value of mocking: test what happens when APIs fail.
4xx errors (client errors)
400 Bad Request: Invalid input. Test that your code validates inputs before calling the API and handles the error gracefully.
401 Unauthorized: Missing or invalid authentication. Test that your code re-authenticates or prompts the user to log in again.
403 Forbidden: User lacks permission. Test that your code shows a helpful error message, not a blank screen.
// Test: invalid order amount returns 400
stubFor(post(urlEqualTo("/api/orders"))
.withRequestBody(matchingJsonPath("$.amount", lessThan(0)))
.willReturn(aResponse()
.withStatus(400)
.withBody("{\"error\": \"Amount must be positive\"}")));
// Your test
expect(() => {
chargeOrder({amount: -10});
}).toThrowError("Amount must be positive");
5xx errors (server errors)
500 Internal Server Error: Something broke on the server. Test that your code retries, logs the error, and either falls back or shows a user-friendly error.
503 Service Unavailable: Server is down for maintenance. Test graceful degradation.
Timeout and connection failures
Mock timeouts by delaying the response:
// Mountebank: delay response by 5 seconds
{
"stubs": [{
"responses": [{
"is": {...},
"wait": 5000
}]
}]
}
State management: sequential and conditional responses
Sequential responses
Return different responses on successive calls to the same URL. Useful for simulating pagination or polling.
// First call returns status="pending", second returns status="complete"
stubFor(post(urlEqualTo("/api/jobs/check"))
.inScenario("job-completion")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withBody("{\"status\": \"pending\"}"))
.willSetStateTo("complete"));
stubFor(post(urlEqualTo("/api/jobs/check"))
.inScenario("job-completion")
.whenScenarioStateIs("complete")
.willReturn(aResponse().withBody("{\"status\": \"complete\"}")));
Conditional responses
Return different responses based on request content:
// If user is admin, return all data. Otherwise, return filtered data.
stubFor(get(urlEqualTo("/api/users"))
.withQueryParam("role", equalTo("admin"))
.willReturn(aResponse().withBody("{\"users\": [...]}")));
stubFor(get(urlEqualTo("/api/users"))
.willReturn(aResponse().withBody("{\"users\": [{...limited...}]}")));
Resetting state between tests
If mocks are stateful, reset them before each test:
beforeEach(() => {
WireMock.reset();
// Also reset scenario state if using scenarios
WireMock.resetAllScenarios();
});
Verification: checking requests were made correctly
Beyond stubbing responses, mocks can verify the calling code made the right requests.
Verify a request was made
// Verify that chargeCard called the payment API
verify(post(urlEqualTo("/api/payments")));
// Verify with specific body content
verify(post(urlEqualTo("/api/payments"))
.withRequestBody(matchingJsonPath("$.amount", equalTo(50))));
Verify call count and ordering
// Verify retry logic: API called 3 times
verify(post(urlEqualTo("/api/payment")).count(3));
// Verify ordering: auth call before payment call
verify(post(urlEqualTo("/api/auth")), anyRequestedFor(post(urlEqualTo("/api/payment"))));
Worked example: mocking a payment processor
Scenario: Your checkout flow calls a payment API (Stripe). You want tests to verify correct retry behavior on failure.
// Test setup with WireMock
@BeforeEach
void setup() {
WireMock.reset();
}
@Test
void chargeOrderRetries_onServerError() {
// First call returns 500
stubFor(post(urlEqualTo("/api/charge"))
.inScenario("retry-test")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(500))
.willSetStateTo("retry"));
// Second call returns 200
stubFor(post(urlEqualTo("/api/charge"))
.inScenario("retry-test")
.whenScenarioStateIs("retry")
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"transactionId\": \"tx-456\", \"status\": \"approved\"}")));
// Execute: chargeOrder should retry and succeed
Order order = chargeOrder(100);
assertEquals("approved", order.paymentStatus);
// Verify: payment endpoint was called twice
verify(post(urlEqualTo("/api/charge")).count(2));
}
@Test
void chargeOrder_handlesTimeout() {
// Mock timeout by delaying response
stubFor(post(urlEqualTo("/api/charge"))
.willReturn(aResponse()
.withFixedDelay(10000) // 10 seconds
.withBody("{\"status\": \"timeout\"}")));
// Expect code to throw TimeoutException
assertThrows(TimeoutException.class, () -> {
chargeOrder(100);
});
}
Integration with test frameworks
Most tools integrate seamlessly with test runners:
- WireMock: JUnit 4/5 extension, Testcontainers support for Docker integration
- Mountebank: Can start/stop via CLI in test setup, Docker container support
- Prism: CLI-based, start in CI/CD before test suite runs
Best practices and anti-patterns
Don't over-mock. Mock external services (third-party APIs, payment processors). Don't mock your own code. If you're mocking database calls, something is wrong with your architecture.
- Keep mocks close to reality. If the real API returns 5xx errors 1% of the time, don't mock it returning 500 every time. Mocks should reflect realistic scenarios.
- Use contract testing alongside mocks. Mocks can become stale if the real API changes. Use tools like Pact (contract testing) to verify your mock matches the real API schema.
- Test error handling explicitly. Don't just test the happy path. Have dedicated tests for 400, 401, 403, 500, and timeout scenarios.
- Reset state between tests. If your mock is stateful, reset it before each test. Otherwise, test order affects results (brittle).
- Document mock setup in test comments. When a test uses unusual mocking (e.g. "this call returns 500 on second attempt"), leave a comment explaining why.
- Run integration tests against real APIs periodically. Mocks are great for speed, but periodically (nightly, before release) run a test suite against the real API to catch changes.
Related techniques: Idempotency Testing, API Testing, Contract Testing.