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.
Describe integration testing in depth: its purpose, and the common approaches to structuring it (big-bang, incremental, top-down, and bottom-up). Explain how you would decide whether to run integration tests against real third-party services, mocked responses, or recorded traffic, and the practical trade-offs of each choice.
Sample Answer
Integration testing exists to prove that two or more real components agree on how they interact, which unit tests, by testing each component alone, structurally cannot show.
Four common approaches to structuring it
- Big-bang: integrate and test all components together at once, only after every piece is individually complete. Simple to set up, but when it fails, it gives almost no information about WHICH interaction is broken, since everything is combined at the same time; best suited to small systems where "everything together" is a manageable scope.
- Incremental: integrate and test components a few at a time, growing the tested surface gradually. Failures are much easier to localize than big-bang, since you know which newly-added component caused a new failure, at the cost of more setup and more distinct test configurations to maintain.
- Top-down: start from the highest-level component (an API layer or orchestrator) and integrate downward, using stubs to stand in for lower components not yet integrated. Lets you validate the overall structure and control flow early, before every dependency is ready, at the cost of needing well-maintained stubs that can themselves drift from real behavior.
- Bottom-up: start from the lowest-level components (a data-access layer, a utility library) and integrate upward, using driver code to exercise components not yet wired to their real caller. Validates foundational pieces early and with high confidence, at the cost of not exercising the overall system structure until later in the process.
Deciding: real services, mocked responses, or recorded traffic
Use a REAL third-party service when the service is cheap or free to call, reliably available in a sandbox environment, and the specific behavior you need to verify (a genuine edge case in its real response) can't be faithfully reproduced any other way; the trade-off is speed, reliability, and cost, since your tests now depend on someone else's uptime and rate limits. Use MOCKED responses when you need fast, deterministic tests for your own code's handling logic (how do you react to a success, a specific error code, a timeout) and you're confident about the shape of the real service's responses; the trade-off is drift risk: the mock silently stops matching reality if the real service changes. Use RECORDED traffic (capturing real request/response pairs once, then replaying them) as a middle ground: it gives you realistic response bodies without a live network dependency on every test run, at the cost of the recordings themselves going stale if the real service changes and nobody re-records them.
Trade-offs and pitfalls
The most common mistake is picking one of these three uniformly for an entire integration suite rather than choosing per-test based on what that specific test needs to prove: a test verifying your error-handling logic rarely needs a real service call, while a test verifying your integration still matches the real service's current contract benefits from at least occasional real or recorded traffic, not a hand-maintained mock alone.
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.
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.
Describe the test pyramid and how you would apply it to a modern single-page-application stack (React frontend, Node API, PostgreSQL database). For each layer (unit, integration/component, and end-to-end), give concrete examples of what to test and recommended tooling, propose an approximate test-count ratio across the layers, and describe how you would validate and adjust that ratio over time as the product matures.
Sample Answer
For a React-frontend, Node-API, PostgreSQL-database SPA stack, the pyramid maps onto three layers whose boundary follows the technology seam as much as the logical one.
What to test at each layer, with tooling
- Unit: pure functions and isolated logic on both sides of the stack, for example a price-formatting helper or a validation function on the frontend, and a business-rule function on the Node API. Recommended tooling: Jest (or Vitest) for both the React frontend and the Node backend, since a single test runner across the stack keeps tooling simple.
- Integration/component: on the frontend, rendering a React component with React Testing Library and confirming it correctly calls a mocked API client and updates its own state and DOM in response, which proves the component's own logic and rendering without needing the real backend running; on the backend, hitting the real Node API with Supertest against a real (test) PostgreSQL database, proving the route, the query, and the schema all agree, which no frontend-only or backend-only unit test can show.
- End-to-end: driving the real React app in a real browser against the real API and database (or a close staging equivalent) using Playwright or Cypress, proving the whole assembled stack delivers a correct user-facing outcome, such as a full checkout flow from click to confirmation.
Guidance on test-count ratio
A reasonable starting ratio for this stack is roughly 65-70% unit tests (split across frontend logic and backend logic), 20-25% integration/component tests (split between frontend component tests and backend API-to-database tests), and 5-10% end-to-end tests covering only the handful of journeys where the whole assembled stack matters most (checkout, authentication). The SPA's heavy client-side interaction pushes the integration/component share slightly higher than a pure backend service would need, since a meaningful share of this stack's real risk lives in how React components manage state and respond to user interaction, which a backend-only pyramid wouldn't need to account for.
Validating and adjusting the ratio over time
Track, per release, which layer actually caught each regression found either in code review, staging, or production, and compare that distribution to your current test-count ratio: if end-to-end tests are catching bugs that a component test could have caught more cheaply, that's a signal to push more coverage down a layer; if production bugs keep slipping through despite full coverage lower in the pyramid, that's a signal the end-to-end layer, not the lower layers, needs to grow for that specific journey. Revisit the ratio on a fixed cadence (quarterly is common) rather than continuously, since a ratio that reacts to every single incident tends to overfit to the most recent bug rather than reflecting the system's actual steady-state risk.
Trade-offs and pitfalls
The most common mistake on this specific stack is testing React component behavior primarily through end-to-end browser tests, because it's the most "realistic," when a React Testing Library component test at the integration/component layer can prove the same interaction logic in a small fraction of the time and with far less flakiness. Reserve full end-to-end coverage for the journeys where the point genuinely is proving the whole stack, frontend, API, and database together, works correctly.
Unlock Full Question Bank
Get access to all 19 Test Levels and the Test Pyramid interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.