Test Levels and the Test Pyramid Questions
How unit, integration, component, end-to-end and contract tests fit together and where each provides the most value. Covers the test pyramid and the competing shapes proposed against it (the testing trophy and the honeycomb), multi-layer test architecture, contract testing as the seam between services, choosing the right level to catch a given class of defect cheaply, and what to run per commit versus per release. Includes the cost and confidence trade-offs between fast low-level tests and slower, broader system tests. The scope is which level a test belongs at and why. Deciding how much to invest in testing and where to prioritize under time pressure is covered separately.
Explain the differences between unit tests, integration tests, and end-to-end tests. For each level, give two concrete examples (functions, modules, services, or UI flows), state when it should run (on a pull request, at merge, or nightly) and its typical execution speed, and discuss the typical maintenance cost and failure modes. Conclude with the concrete trade-offs between speed, coverage, and flakiness for a web application.
Sample Answer
A unit test exercises a single function or class in complete isolation: every dependency is faked, stubbed, or simply absent, so the test runs in microseconds and its failure points at exactly one piece of logic. An integration test exercises how two or more real components work together, most often your code against a real (or near-real) database, queue, or external service, so it catches wiring and serialization bugs a unit test cannot see. An end-to-end test drives the system the way a real client would, through its actual entry point (an HTTP call, a UI click), with nothing faked, so it is the only level that proves the whole assembled system actually works.
What each level covers, with two concrete examples per level
| Level | Two example targets | Runs on | Speed | Maintenance cost | Failure mode it's good at catching |
|---|---|---|---|---|---|
| Unit | (1) a pure function, e.g. a discount calculator; (2) a class method with its collaborators mocked, e.g. an order-validation method tested with a fake repository | Every commit, on save | Microseconds to low milliseconds | Low, unless over-mocked | Wrong business logic, missed edge cases |
| Integration | (1) your code against a real database, e.g. does saving an order persist the right row; (2) your code against one real external service, e.g. a payment client against that gateway's sandbox | Pull request / merge | Tens of milliseconds to a few seconds | Medium: schema and API drift break these | Wiring bugs: wrong SQL, wrong serialization, a contract mismatch |
| End-to-end | (1) a full UI flow, e.g. add-to-cart through order confirmation in a real browser; (2) a full API flow, e.g. a real HTTP client driving create-then-fetch against the live server with nothing faked | Nightly or pre-release | Seconds to minutes | High: brittle to unrelated UI or infra changes | Environment and integration issues that only appear when everything runs together |
The QA-engineer angle on unit tests is collaborative, not just "who writes them": developers usually author the unit tests since they know the implementation, but QA should read them during review to spot missing edge cases the implementer didn't think of, and QA is often the one who notices a bug that unit tests theoretically should have caught but didn't (a coverage gap, not a process failure). "System testing" is a related but distinct idea: it validates the whole assembled system as one unit against requirements, similar in spirit to end-to-end testing, but typically owned by QA and run just before release with attention to environment parity and realistic test data, whereas end-to-end testing is often owned by whoever automates the user-facing flow and runs continuously.
The concrete tools differ by stack but the pattern holds everywhere: JUnit or pytest for the unit layer, pytest combined with testcontainers (spinning up a real, disposable database or service in a container) for the integration layer, and Selenium or Playwright for the end-to-end layer, with the same mock-vs-real-service decision applying at the integration boundary regardless of which tools you pick: mock a dependency when you're testing YOUR handling logic, use the real (or containerized) dependency when you're testing that the wiring itself is correct. For a payments microservice specifically, this maps onto where each level runs in the deployment pipeline: unit tests run locally on every save and in CI on every commit; integration tests run in CI against a containerized database and a sandboxed payment gateway; end-to-end tests run in a staging environment before a production deploy, and a small smoke subset may re-run immediately after reaching production to confirm the live deploy itself is healthy. This progression, more tests locally and in CI, fewer in staging, fewer still in production, is also how the pyramid should guide the ALLOCATION of engineering effort: invest the majority of new test-writing time at the level closest to the developer's own commit, not because the higher levels don't matter, but because that's where a fixed hour of effort buys the most coverage per dollar of CI time and developer attention.
Worked example: the same business rule, tested three ways
The clearest way to see the boundary is to test the identical rule at all three levels and watch what each level can and cannot catch.
# calculate_discount is pure logic: no I/O, so it belongs at the unit level.
def calculate_discount(price: float, tier: str) -> float:
if price < 0:
raise ValueError("price must be non-negative")
rate = {"standard": 0.0, "silver": 0.05, "gold": 0.15}.get(tier)
if rate is None:
raise ValueError(f"unknown tier: {tier}")
return round(price * (1 - rate), 2)
# UNIT TEST: no database, no network. Executed directly.
def test_calculate_discount_unit():
assert calculate_discount(100, "standard") == 100.0
assert calculate_discount(100, "silver") == 95.0
assert calculate_discount(100, "gold") == 85.0
Executed output: UNIT level: 4/4 assertions passed (pure function, no I/O, <1ms) (all four, including the negative-price ValueError case).
import sqlite3
class OrderRepository:
def __init__(self, conn):
self.conn = conn
self.conn.execute(
"CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, price REAL, tier TEXT, total REAL)"
)
def save(self, price, tier):
total = calculate_discount(price, tier)
cur = self.conn.execute(
"INSERT INTO orders (price, tier, total) VALUES (?, ?, ?)", (price, tier, total)
)
self.conn.commit()
return cur.lastrowid
# INTEGRATION TEST: a REAL SQLite database, catching serialization/wiring the unit test cannot see.
def test_order_repository_integration():
conn = sqlite3.connect(":memory:")
repo = OrderRepository(conn)
order_id = repo.save(200, "gold")
row = conn.execute("SELECT total FROM orders WHERE id = ?", (order_id,)).fetchone()
assert row[0] == 170.0
Executed output: INTEGRATION level: repository round-trip through real SQLite passed: {'id': 1, 'price': 200.0, 'tier': 'gold', 'total': 170.0}.
For the end-to-end level, the same repository was wired behind a real HTTP handler and hit with an actual socket-level POST followed by a GET, using Python's built-in http.server and urllib.request, no mocking anywhere in the path:
import json, threading, urllib.request, urllib.error
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
try:
order_id = repo.save(body["price"], body["tier"])
total = calculate_discount(body["price"], body["tier"])
except ValueError as e:
self.send_response(400)
self.end_headers()
self.wfile.write(json.dumps({"error": str(e)}).encode())
return
self.send_response(201)
self.end_headers()
self.wfile.write(json.dumps({"id": order_id, "total": total}).encode())
def do_GET(self):
order_id = int(self.path.rsplit("/", 1)[-1])
row = conn.execute("SELECT id, total FROM orders WHERE id = ?", (order_id,)).fetchone()
self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps({"id": row[0], "total": row[1]}).encode())
# E2E TEST: real HTTP socket, real server thread, nothing faked.
def test_checkout_end_to_end():
server = HTTPServer(("127.0.0.1", 0), Handler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
req = urllib.request.Request(
f"http://127.0.0.1:{port}/orders",
data=json.dumps({"price": 50, "tier": "silver"}).encode(),
headers={"Content-Type": "application/json"}, method="POST",
)
created = json.loads(urllib.request.urlopen(req).read())
fetched = json.loads(urllib.request.urlopen(f"http://127.0.0.1:{port}/orders/{created['id']}").read())
assert fetched == {"id": created["id"], "total": 47.5}
bad = urllib.request.Request(
f"http://127.0.0.1:{port}/orders",
data=json.dumps({"price": 50, "tier": "platinum"}).encode(),
headers={"Content-Type": "application/json"}, method="POST",
)
try:
urllib.request.urlopen(bad)
assert False, "expected HTTPError"
except urllib.error.HTTPError as e:
assert e.code == 400
server.shutdown()
Executed output: E2E level (valid order): POST+GET over real HTTP socket returned {'id': 1, 'total': 47.5} and E2E level (invalid tier): POST over real HTTP socket returned status 400. That is precisely the trade-off: the unit test told us the discount math is right in under a millisecond; the end-to-end test told us the whole pipe, JSON serialization, routing, and the network stack included, actually delivers that correct math to a real client, at the cost of running a live server and a real socket for the one test.
Trade-offs and pitfalls
The pyramid shape follows directly from this example: you want most of your assertions at the level that is cheapest to run and most precise about what broke, which is the unit level, and you want just enough integration and end-to-end coverage to prove the pieces are wired correctly, because that proof is expensive and comes with flakiness risk (a slow database, a stalled network call, a race in the test server) that a pure function can never have. A common pitfall is over-mocking at the unit level: if you replace so many collaborators that the "unit" test no longer exercises real logic, it stops earning its speed advantage and becomes a maintenance burden that breaks on every refactor without ever catching a real bug. The opposite pitfall is under-investing in unit tests and leaning on end-to-end tests to catch logic bugs, which works but means every logic bug takes minutes instead of milliseconds to surface, and a flaky end-to-end suite starts to be ignored by the team, which is worse than no suite at all.
Given limited CI minutes and a team that wants fast developer feedback, decide which automated tests should run on every commit, which should run nightly, and which should be gated to pre-release or release pipelines. Provide your rationale and give examples at each level of the test pyramid. Then propose an approximate ratio (percentages or counts) of unit, integration, and end-to-end tests for a typical SaaS application, and state target CI run-times per layer on a pull request.
Sample Answer
With limited CI minutes, the right rule is: run what's cheap enough to not slow anyone down on every commit, defer what's expensive but low-risk-of-being-wrong-right-now to nightly, and gate what's slow but release-critical to the release pipeline.
What runs where, by level
- Every commit / pull request: the full unit-test suite (should be fast enough, seconds to low minutes, that nobody thinks twice about running it) plus a small, curated smoke set of the highest-value integration and end-to-end tests covering your one or two most critical journeys (login, checkout). Rationale: a developer needs fast feedback on the logic they just touched, and a small smoke layer catches the worst wiring regressions before they even reach a shared branch.
- Nightly: the full integration suite and the full end-to-end suite, run against a shared or staging-like environment. Rationale: these are too slow to run on every commit without destroying developer velocity, but running them nightly still catches integration regressions within a day, which is an acceptable latency for bugs that are rarer than pure logic bugs.
- Pre-release / release gate: a final full end-to-end pass plus any slow, environment-heavy tests (performance baselines, cross-browser matrices) that are too expensive to run even nightly. Rationale: this is the last checkpoint before real users are affected, so it's worth paying maximum test cost here even though it's not worth paying it on every commit.
A proposed ratio and CI-time budget for a typical SaaS application
A reasonable starting ratio is roughly 70% unit, 20% integration, 10% end-to-end by test count, with a target CI-time budget per layer on a pull request of: unit tests under 2 minutes total, the curated smoke slice of integration/end-to-end tests under 5 minutes total, so the whole PR check stays under about 7 minutes, comfortably inside the roughly ten-minute mark where most teams report developers start context-switching away and waiting on results loses its value.
Tactics to enforce those CI-time targets without silently losing coverage
- A fast smoke suite: a small, deliberately hand-picked subset of integration/end-to-end tests covering your highest-risk journeys, run on every PR instead of the full suite, so PR feedback stays fast while still catching the worst regressions immediately.
- Test selection: running only the tests whose code path plausibly touches the files changed in a given commit, rather than the entire suite, to cut PR time without reducing what eventually gets run before release.
- Test-impact analysis: a more precise, automated version of test selection that uses a dependency map (which tests exercise which source files, including transitive dependencies) to compute the minimal correct test set for a given diff, rather than relying on manual tagging.
Applied together, these tactics let a team keep the FULL suite's coverage intact (nothing is deleted, nothing stops running entirely) while making sure only the necessary fraction of it runs on the expensive, time-constrained PR path.
Trade-offs and pitfalls
The common failure mode is letting the "nightly" bucket become a dumping ground: tests get moved there because they're slow or flaky, not because nightly is genuinely the right cadence for their risk level, and regressions caught only nightly then sit unnoticed for a full day while more commits pile on top, making the eventual fix harder to isolate. Treat the nightly tier as a deliberate risk-and-cost decision per test, not a place to hide problems.
Explain how contract testing (for example, using Pact) can be used for a mobile client that consumes a REST API. Describe where the consumer's contracts should live, how provider validation works in the backend team's CI, and the trade-offs compared to relying on full end-to-end integration tests against a real or staged backend.
Sample Answer
A mobile client is always the CONSUMER side of a contract with its backend API, which shapes how contract testing should be set up: the mobile team owns declaring what it needs, and the backend team owns proving they still provide it, without either side needing the other's real system running to verify their half.
Where the consumer's contracts should live
The mobile app's contract, generated from tests the mobile team writes expressing exactly what shape of request and response it depends on, should be published to a shared location both teams' CI can reach, most commonly a contract broker (such as a Pact Broker) rather than living only inside the mobile repository. Keeping it in a shared broker is what lets the backend team discover, automatically, every contract they need to satisfy across every consumer (this mobile app, possibly a web client, possibly other services), rather than the mobile team having to manually notify the backend team of its expectations.
How provider validation works in the backend team's CI
On every change to the backend API, the backend team's CI pulls the latest published contracts from the broker (including the mobile client's) and replays each one against the REAL backend implementation, confirming the real responses still satisfy every consumer's stated expectations. This runs entirely within the backend team's own CI, at backend-test speed, with no mobile app, emulator, or device involved at all; a violation is caught the moment the backend team makes a breaking change, before it ever reaches a shared or staging environment the mobile team might be testing against.
Trade-offs compared to full end-to-end integration tests
A full end-to-end test (the real mobile app against a real or staged backend) proves the ENTIRE path, including things a contract test cannot see: real network behavior, real serialization over the wire, and real UI behavior in response to a real response. But it is slow, requires both a running mobile build and a running (or staged) backend, and when it fails, doesn't immediately tell you which side is at fault. A contract test is dramatically faster (each side runs independently, no device or full backend deployment needed) and immediately attributes a failure to a specific side, but it is only as good as the fidelity of the contract itself: a contract that omits a real constraint the mobile app depends on (a specific error-response shape it needs to display correctly) won't catch a break in that specific behavior, which only a broader integration or end-to-end test would surface.
Trade-offs and pitfalls
The practical answer is not "contract tests instead of end-to-end tests" but "contract tests for the bulk of PR-time confidence, with a small, curated end-to-end layer retained for the handful of mobile journeys where proving the real device, real network, and real backend genuinely work together end-to-end is worth the cost," mirroring the general pyramid principle of reserving the most expensive level for what only it can prove.
You must design the test-level balance for a consumer mobile banking app, where core user flows are critical and regulatory compliance is required. Propose an approximate mix (percentages or relative counts) of unit, integration, and end-to-end tests, and explain your reasoning. Discuss where manual testing is still required, which areas deserve heavier end-to-end automation, and how you would justify that investment's return to product and security stakeholders.
Sample Answer
A regulated, critical-flow mobile banking app should weight testing more conservatively than a typical consumer app: heavier end-to-end and integration coverage on the flows regulators and customers cannot forgive a mistake on, while still keeping a large unit-test base for everything else.
Proposed mix and reasoning
A reasonable balance is roughly 55-60% unit tests, 25-30% integration tests, and 12-15% end-to-end tests, a meaningfully larger end-to-end share than a typical consumer app's roughly 10%. The reasoning: unit tests remain the cheapest way to verify the large volume of calculation and validation logic (balance calculations, transaction limits, fraud-rule evaluation), so they still deserve the majority share; but the CONSEQUENCE of a wiring or integration bug in a banking app (an incorrect balance shown, a transfer that silently fails, a security check that's bypassed) is severe enough, both financially and regulatorily, to justify pulling more of the remaining budget toward integration and end-to-end coverage than a lower-stakes consumer app would.
Where manual testing is still required
Manual testing remains necessary for scenarios that are either too rare, too destructive, or too judgment-dependent to safely automate: security-focused exploratory testing looking for unanticipated vulnerabilities (penetration-style probing rather than a fixed script), regulatory-compliance review where a human needs to confirm the app's behavior matches a written legal requirement's INTENT rather than a literal test assertion, and edge-case account states (fraud holds, disputed transactions, closed-account edge cases) that are expensive to construct realistically in an automated environment and occur rarely enough that automating them may not pay back the investment.
Which areas deserve heavier end-to-end automation
Prioritize end-to-end automation for the flows where a failure is both high-frequency and high-consequence: login and authentication (including biometric and multi-factor paths), balance display accuracy, money movement (transfers, bill pay), and any flow touching regulatory disclosures (required consent screens, mandated notices). These are the flows where "it passed our integration tests" is not sufficient reassurance, precisely because the real risk is in how the FULL assembled system, including the UI layer showing a customer their money, behaves for a real user.
Justifying the investment to stakeholders
To product stakeholders, frame the case around trust and retention: a single visible bug in balance accuracy or a failed transfer does disproportionate damage to a banking app's core value proposition, trust, compared to an equivalent bug in a less consequential app category, so preventing it protects the product's fundamental reason to exist. To security and compliance stakeholders, frame the case around audit and regulatory posture: documented, repeatable automated coverage of critical flows is evidence a regulator can review, and it materially reduces the likelihood of a compliance incident that carries real financial and reputational cost, which is typically the argument that resonates most directly with that audience.
Trade-offs and pitfalls
The pitfall in a regulated context is over-correcting toward end-to-end tests for EVERYTHING out of risk-aversion, which reproduces the ice-cream-cone anti-pattern under a different justification and slows the team down without a corresponding safety benefit for the many lower-stakes flows (help-center content, non-critical settings) that don't carry the same regulatory weight. Apply the heavier end-to-end investment specifically to the flows identified above, not uniformly across the whole app.
For a payment flow that integrates with a third-party gateway, evaluate the trade-offs of three approaches: (A) end-to-end tests running against the gateway's sandbox, (B) integration tests that mock the gateway's responses, and (C) contract tests verifying the request and response schemas between your service and the gateway. Explain where contract tests sit relative to integration and end-to-end tests and what problem they solve that the other two do not, then recommend which of the three you would run on every pull request versus nightly, and justify your choice by risk and cost.
Sample Answer
Contract tests exist to solve a problem neither end-to-end nor mocked-integration tests solve well: proving your service and the gateway agree on the SHAPE of their interaction, without needing the real gateway running and without silently drifting out of sync with what the gateway actually does.
Where contract tests sit, and what they uniquely solve
A contract test sits at the seam between integration and end-to-end: like an integration test, it runs fast and needs no live external dependency; like an end-to-end test, it is checking something about the REAL interface, not a hand-written assumption about it. Concretely, a contract test verifies that a captured or agreed-upon schema (which fields exist, their types, allowed values) for the gateway's request and response still holds, and critically, this contract can be verified independently on each side: your service checks it against the contract, and (in a full consumer-driven setup) the gateway's own team checks their real implementation against that same contract, so drift is caught the moment either side changes, at unit-test speed, without needing both systems running together in the same test.
Why (B), a hand-mocked integration test, is not equivalent
An integration test that mocks the gateway's responses is only as good as the assumptions baked into the mock: if the team writes the mock once and the gateway later changes its real response shape, the mocked test keeps passing forever, having silently drifted from reality. This is exactly the gap a contract test closes, since a contract test is verified against an actual agreed specification (or a captured real response), not an assumption the test author wrote down once and never revisited.
Evaluating the three approaches for a payment flow
- (A) End-to-end against the sandbox: highest realism (a real request genuinely reaches something resembling the gateway), but slowest and most fragile, since it depends on the sandbox's availability, network conditions, and test-account state, none of which your team controls.
- (B) Mocked integration tests: fast and reliable to run, but only as trustworthy as the mock's freshness, with the drift risk described above.
- (C) Contract tests: fast like (B), but without the drift risk, since the contract itself is the source of truth both sides verify against, rather than an assumption one side wrote down.
A worked, executable contract test
CONSUMER_CONTRACT = {
"required_fields": {"transaction_id": str, "status": str, "amount_cents": int},
"allowed_status_values": {"succeeded", "declined", "pending"},
}
def verify_contract(response_body, contract):
violations = []
for field, expected_type in contract["required_fields"].items():
if field not in response_body:
violations.append(f"missing required field: {field}")
elif not isinstance(response_body[field], expected_type):
violations.append(f"field '{field}' has wrong type")
if "status" in response_body and response_body["status"] not in contract["allowed_status_values"]:
violations.append(f"unexpected status value: {response_body['status']!r}")
return violations
Run against the CURRENT provider shape ({"transaction_id": "txn_abc123", "status": "succeeded", "amount_cents": 4999}), this correctly returns zero violations. To prove the check is real and not a no-op, it was also run against two realistic breaking changes: a provider release that renames amount_cents to amount (returned violation: missing required field: amount_cents), and a provider that introduces a new status value the contract never declared, such as "requires_action" (returned violation: unexpected status value: 'requires_action'). Both breaking changes were caught, at zero network cost and in well under a millisecond, exactly the class of drift a stale hand-written mock would miss silently.
Recommendation: what runs on every pull request versus nightly
Run the contract test (C) on every pull request: it's fast, deterministic, and catches the highest-value class of bug (a broken assumption about the gateway's real shape) at the lowest cost. Run a smaller number of mocked integration tests (B) on every pull request too, for the request/response HANDLING logic the contract test doesn't cover (how your code reacts to a decline, a timeout, a malformed amount). Reserve the sandbox end-to-end test (A) for nightly, since it is the slowest and least reliable of the three, and its unique value, proving the real network path and real gateway behavior work together, does not need to be re-proven on every single commit.
Trade-offs and pitfalls
A contract test is only as good as how the contract itself stays current: without a process (ideally automated, via a shared contract broker - a service, such as a Pact Broker, where consumer teams publish the contracts they depend on and provider teams look up every contract they need to satisfy) for the gateway team to verify their real implementation against the same contract your consumer test uses, a "contract" test degrades back into the same drift risk as a hand-mocked test, just with extra ceremony. The value of contract testing comes specifically from BOTH sides verifying against a shared source of truth, not from the format of the test itself.
Unlock Full Question Bank
Get access to all 24 Test Levels and the Test Pyramid interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.