July 11, 2026
How I Decide Whether to Mock APIs or Use Real Services in End-to-End Tests
A practical first-person framework for choosing mocks, stubs, or real services in end-to-end tests, with tradeoffs, examples, and CI guidance.
When I am designing an end-to-end test suite, one of the first decisions I make is whether to mock APIs in end-to-end tests or let the test talk to real services. I do not treat this as a philosophy debate. I treat it as a risk decision.
Some dependencies are expensive, unstable, slow, or owned by another team. Others are so central to the flow that mocking them hides the very bugs I want the test to catch. Over time, I have found that the real question is not “mocks or real services?” but “what failure mode am I trying to detect, and what cost am I willing to pay to detect it?”
That sounds simple, but in practice it takes a framework. In this article I will walk through the way I think about real services vs mocks, where I draw the line for E2E strategy, and how I keep the test suite maintainable without turning it into a brittle web of fixtures and assumptions.
The first principle: E2E tests should prove the system, not merely the code path
End-to-end tests are valuable because they exercise the system as a user experiences it. In software testing terms, they validate integrated behavior across boundaries, not just isolated functions or modules. That includes UI, API, auth, caching, routing, and sometimes background jobs and queues. If you want a quick refresher on the broader concept, the software testing and test automation pages are useful baseline references.
The problem with over-mocking is that it can make the test suite look healthy while quietly reducing what it proves. A test that passes against a fake payment API does not tell me whether the real payment service accepts my payload, times out correctly, or returns the shape I expect in production.
My rule of thumb is this, if the bug would happen only when real systems are talking to each other, the test should have some path to real interaction.
That does not mean every E2E test must hit every real dependency. It means the suite should be designed to keep the highest-risk integration points real somewhere in the pipeline.
The three dependency modes I use
In practice, I classify dependencies into three modes:
1. Real service
The test talks to a live dependency, either a production-like test environment or a sandbox owned by the same team.
Use this when:
- The integration is business-critical
- The contract changes frequently or is easy to break
- The dependency behavior matters to the user journey
- I need confidence in auth, serialization, retries, or state changes
2. Stub or mock
The test uses a local or controlled fake response, usually at the HTTP layer.
Use this when:
- The real dependency is slow, costly, flaky, rate-limited, or hard to control
- The dependency is outside the team’s ownership
- I need deterministic behavior for rare error conditions
- I want to isolate a UI flow from unrelated noise
3. Hybrid
Some dependencies are real, others are mocked.
This is the mode I use most often. The challenge is to keep the real parts meaningful and the mocked parts explicit. Hybrid setups are easy to abuse, so I want a written reason for each mocked dependency.
My decision framework: risk, speed, and maintainability
I use three questions to decide whether to mock APIs in end-to-end tests.
1. What is the risk if this dependency breaks?
If a dependency breaks and the UI still renders, what would a user notice?
Examples:
- A search suggestions API failing, the user might still search manually, so a mock may be acceptable in most UI E2E tests.
- A checkout or payments API failing, the user experience is directly impacted, so I want a real service in at least one critical path test.
- An identity provider returning a malformed token, that can break the entire app, so I want real integration coverage or a very high-fidelity test harness.
The higher the user-facing risk, the less comfortable I am mocking that dependency in the only test that covers the flow.
2. How much speed do I need, and where does the latency come from?
If a real service adds 300 ms per call and I make 12 calls in a test suite, the cost is not theoretical. Multiply that across a CI run, and the feedback loop degrades quickly.
But I do not chase speed blindly. I ask where the time is spent:
- Network latency
- Environment startup
- Data setup and teardown
- Flaky external dependencies
- Retrying through a failing service
Sometimes mocking one unstable third-party API cuts minutes off a pipeline. Sometimes the better fix is to improve test data management or use a dedicated sandbox environment instead.
3. How expensive will this be to maintain?
Mocks are not free. Every mocked response becomes a contract I must remember to update. If the real API changes shape, the mocked response can drift and give me false confidence.
If maintaining the mock requires detailed knowledge of a third-party system and frequent hand edits, I ask whether the mock is doing more harm than good.
I care about maintenance in terms of:
- The number of fixtures I must keep aligned with reality
- Whether the fake is shared across many tests
- Whether developers understand why the stub exists
- Whether the mock hides bugs caused by schema or behavior drift
Where I usually mock
I am more likely to mock dependencies that are either outside my control or not the point of the test.
Good candidates for mocks in E2E strategy
1. Third-party services that are unreliable in test
Examples include email delivery, SMS providers, analytics endpoints, and some payment sandbox behaviors.
I rarely need the real email provider to verify that a user sees “reset password” success. I do need to know that my app attempted to send the email with the right payload. A mock is enough for that test, while a separate integration check can verify the provider contract.
2. Hard-to-control asynchronous dependencies
If a service writes to a queue, processes jobs later, and updates a record eventually, full real-world verification can become slow and nondeterministic.
Here I often split the concern:
- E2E test checks that the action was initiated from the UI
- API or integration test checks the queue/event contract
- A smaller number of system tests verify the async flow end to end
3. Rare or expensive error paths
I do not want to depend on a live service outage to verify my timeout handling.
For example, if I need to verify how the UI behaves when a dependency times out, a stub that returns 504 or delays the response is the right tool. This is a good use of mock APIs in end-to-end tests because I am testing app behavior under controlled failure, not the vendor’s uptime.
Example with Playwright route interception
I often use Playwright to intercept one API while leaving the rest of the app real. This gives me a hybrid test that is still close to user behavior.
import { test, expect } from '@playwright/test';
test('shows empty state when recommendations API returns no items', async ({ page }) => {
await page.route('**/api/recommendations', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
});
await page.goto(‘https://app.example.com/dashboard’); await expect(page.getByText(‘No recommendations yet’)).toBeVisible(); });
This is useful when the recommendation service is not the subject of the test. I am checking rendering and branching logic in the UI, not the upstream recommender algorithm.
Where I prefer real services
There are some places where mocking makes the test less trustworthy than it appears.
1. Auth and session boundaries
Login, session refresh, SSO, token exchange, and permission checks often deserve real-service coverage. These are failure-prone boundaries where subtle behavior matters.
A stub can tell me that my app sends a /token request. It cannot always tell me whether the identity provider issues a token with the exact claims my app needs, or whether clock skew breaks refresh logic.
2. Core business flows
If a dependency is central to the business value, I want it real somewhere in the suite.
For example, if I work on a marketplace checkout, I may mock tax calculation in some UI tests to avoid test data complexity, but I still need a real end-to-end scenario that exercises the actual checkout path against a realistic environment. Otherwise I am only proving that the UI can render a happy-path response I invented.
3. Schema-sensitive integrations
If my app depends on the response shape of a service, I want early signal when the schema changes.
A mock often lags behind reality unless I actively maintain it. A real call in a narrow smoke test can catch breakage faster than a large mocked suite. This is especially useful when teams deploy independently and contracts evolve.
Contract confidence matters more than perfect isolation
The phrase test isolation gets used a lot, but in E2E work I think contract confidence is the more useful goal.
Isolation is good when I need determinism. Contract confidence is what I need when I care about integration correctness.
If I isolate too aggressively, I can make a UI test that is mechanically stable but strategically blind. If I isolate too little, I get a suite full of slow, noisy, hard-to-debug tests.
The balance I aim for looks like this:
- Unit tests, prove logic in isolation
- API or contract tests, prove interface compatibility
- E2E tests, prove the critical user journey across the actual stack
That layering is what keeps me from forcing E2E tests to do everything. A healthy E2E strategy does not need to re-validate every business rule. It needs to confirm that the system still works as a system.
A practical matrix I use before deciding
Before I mock an API, I ask:
| Question | If the answer is yes, I lean toward real | If the answer is no, I lean toward mock |
|---|---|---|
| Does the user notice if this breaks? | Real | Mock is acceptable |
| Is this dependency owned by my team? | Real | Mock or stub |
| Is the dependency stable and fast? | Real | Mock |
| Do I need a specific failure mode? | Mock | Real |
| Would a fake drift from reality quickly? | Real | Mock only with strong justification |
| Is this the only test covering the path? | Real | Mock only if another test covers the real integration |
This matrix is not a rigid rule, but it is a reliable way to prevent accidental over-mocking.
Selenium and Playwright teams face the same tradeoff
Whether I am using Selenium or Playwright, the underlying decision is the same. The tool affects how I implement the decision, not how I make it.
Selenium often pushes teams toward broader system tests, especially when they already have a central test environment. Playwright makes interception and network control easier in many cases, which is great for selective mocking. But easier mocking is not always better mocking.
If I am using Selenium in Python, I usually rely more on environment-level control, such as test doubles running in a container or a configurable backend endpoint.
from selenium import webdriver
from selenium.webdriver.common.by import By
driver = webdriver.Chrome() driver.get(‘https://app.example.com/login’)
assert ‘Sign in’ in driver.title
This is intentionally simple. The important part is not the browser driver, it is the dependency strategy behind the test environment.
With Playwright, I have more in-test control over request interception, which makes selective mocking cleaner.
When I use stub services instead of inline mocks
For anything beyond a few tests, I prefer a stub service over scattered inline mocks.
Why?
- Centralized behavior is easier to maintain
- The fake can be shared across tests and teams
- I can simulate realistic sequences, not just one response
- I can version the stub separately from the UI tests
A stub service is especially useful if multiple client apps consume the same API. It becomes a single place to model responses and edge cases.
I also like stub services when I need multi-step flows, such as:
- Create resource
- Poll status
- Fetch final result
In those cases, inline route interception can get messy fast.
When I avoid mocks completely
There are situations where I deliberately avoid mocking, even if it costs more.
1. First release of a critical flow
If a flow is new and risky, I want real integration coverage early. This is when the system is most likely to hide mismatches between frontend assumptions and backend behavior.
2. Incidents that came from integration mismatch
If we have already had a production issue caused by a contract mismatch, I do not want to paper over it with more mocks. I want a real test that would have failed before the incident.
3. Tests used as release gates
If a test blocks deployment, I need high confidence in what it proves. A heavily mocked E2E test may pass reliably and still miss the integration failure that matters. For release gates, I prefer fewer tests, but with more real dependencies.
Handling flaky external dependencies
This is where many teams reach for mocks too quickly.
If a dependency is flaky, I do not immediately replace it with a fake. I first ask:
- Is the flakiness in my app, the network, the test environment, or the external service?
- Is the dependency essential to the release gate or just a convenience?
- Can I reduce flakiness with better waiting, retries, or isolated test data?
Sometimes a flaky test is really a synchronization problem. For example, if the UI waits for a record that is eventually written by a backend job, I may need a better polling strategy rather than a mock.
typescript
await expect.poll(async () => {
const response = await page.request.get('https://api.example.com/orders/123');
return response.status();
}).toBe(200);
I use patterns like this carefully. They can reduce false negatives without sacrificing realism, but they are not a substitute for understanding the dependency itself.
A healthy split between test layers
The most maintainable teams I have worked with do not ask E2E tests to prove every dependency. They distribute confidence across layers.
Unit tests
Catch logic errors quickly. These should be fast and isolated.
Contract tests
Verify that the app and service agree on payload shape, status codes, and required fields. These are often the right place to validate API contracts.
Integration tests
Verify that code talks correctly to a live or realistic service boundary, often with test data or containers.
E2E tests
Verify the user journey, with a limited number of real dependencies for the highest-risk paths.
This layered approach means I can mock APIs in end-to-end tests selectively without losing confidence overall.
A decision checklist I actually use
When I am about to mock a dependency in an E2E test, I check the following:
- Is this dependency the point of the test, or just a supporting detail?
- Does a real service add meaningful confidence for this scenario?
- Can I make the dependency deterministic in a realistic environment instead of mocking it?
- Is there already another test that covers the real integration?
- Will the mock likely drift from production behavior?
- Would a failure here be important enough to page someone or block a release?
If I answer yes to most of the first, second, and fourth questions, I lean toward real services. If I answer yes to the third, fifth, or sixth in a way that indicates noise or drift, I lean toward a mock or stub.
Common mistakes I try to avoid
Mocking the same API everywhere
If every test stubs the same service, I usually have a trust problem. The suite may be fast, but it no longer tells me much about integration health.
Mixing real and fake data without naming it
A test should make it obvious whether it is using live services, a stub, or intercepted routes. Hidden magic makes failures hard to diagnose.
Letting mocks become unmaintained production folklore
A stale mock can survive for months and silently differ from reality. I try to keep mock fixtures close to the code or documented in one place.
Using E2E tests to compensate for missing contract tests
If I keep stubbing APIs but never verify the contract, I am shifting confidence out of the wrong layer.
My default strategy in real projects
If I have to summarize my approach in one sentence, it is this: I mock the dependencies that make the test less trustworthy or too expensive to run, and I keep real the dependencies that matter most to user risk or contract confidence.
That usually turns into the following pattern:
- Mock third-party side effects like email, analytics, or SMS
- Use real services for authentication, payments, and critical business workflows
- Use stub services for unstable but necessary dependencies
- Keep a small number of real E2E smoke tests that run against production-like services
That balance gives me a practical E2E strategy. The suite stays fast enough for CI, realistic enough to catch integration failures, and maintainable enough that engineers will still trust it six months later.
CI/CD changes the calculus
In continuous integration, the question is not only “does this test work?” but also “does it work reliably, repeatedly, and at the right cost?” The continuous integration model encourages frequent validation, which makes brittle dependency choices more expensive.
In CI, I am stricter about mocks for tests that run on every commit, but I also make sure the pipeline includes some real integration coverage. A common pattern is:
- Fast PR checks with selective mocking
- Nightly or pre-release runs with more real dependencies
- Smoke tests against a stable environment before deployment
That structure helps me keep developer feedback quick while still preserving confidence in the real stack.
Final rule of thumb
If I am unsure whether to mock an API in an E2E test, I ask one final question: “Will this test still be valuable if the real dependency changes behavior in a way the mock does not model?”
If the answer is no, the mock is probably hiding the most important risk.
If the answer is yes, the mock may be the right choice, especially when I am testing UI behavior, error handling, or other parts of the flow that do not depend on the real downstream service.
That is the balance I keep coming back to. Not purity, not speed for its own sake, but a practical mix of realism, isolation, and maintainability.
If you want to improve your own E2E suite, start by listing the dependencies in your critical paths and labeling each one as real, stubbed, or mocked, with a reason. That exercise alone usually reveals where your current tests are either too fragile or too fake.