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.
Design an experiment, and the metrics you would use, to empirically validate whether a proposed test-pyramid ratio (for example, 70% unit, 20% integration, 10% end-to-end) actually improves delivery cadence and defect detection for your product. Include how you would form control versus experiment groups, the duration and sample size or statistical considerations involved, your success criteria, and how you would account for confounding variables.
Sample Answer
Empirically validating a pyramid ratio means treating it as a genuine hypothesis, not just an assertion, and the choice of WHAT metric you measure changes whether that validation is even practically feasible.
Experiment design: control versus experiment groups
Since you can't run two versions of the same team simultaneously, the practical design is a staggered rollout: apply the current ratio (control) to one set of comparable services or feature teams, and the proposed 70/20/10 ratio (experiment) to a matched set of comparable services or teams, matched as closely as possible on size, domain complexity, and current release cadence, since an unmatched comparison would confound the ratio's effect with pre-existing differences between the groups.
Metrics, duration, and required sample size, computed two ways
Option A: a binary per-release "escaped defect" metric. Suppose your baseline escaped-defect rate is 8% of releases, and you want to detect whether the new ratio meaningfully reduces it to 4%. In plain language before the numbers: alpha is the false-positive risk you're willing to accept (5% here, meaning a 5% chance of concluding the new ratio helped when it actually didn't), power is the chance of correctly detecting a real effect if one truly exists (80% here), and Cohen's d (used below in Option B) is a standardized way to measure how big the gap between two groups is relative to how spread out the underlying data is. A standard two-proportion power calculation (alpha=0.05, power=0.80) gives:
from scipy import stats
import math
def sample_size_two_proportions(p1, p2, alpha=0.05, power=0.8):
z_alpha = stats.norm.ppf(1 - alpha / 2)
z_beta = stats.norm.ppf(power)
p_bar = (p1 + p2) / 2
numerator = (z_alpha * math.sqrt(2 * p_bar * (1 - p_bar)) +
z_beta * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2
return math.ceil(numerator / (p1 - p2) ** 2)
n = sample_size_two_proportions(0.08, 0.04)
Executed result: n = 553 releases per group.
Rather than trusting that formula blindly, validate it with a Monte Carlo simulation that draws releases as random pass/fail outcomes at the two true rates and runs the actual two-proportion z-test at n=553, tallying how often it correctly rejects the null:
import numpy as np
rng = np.random.default_rng(12345)
def simulate_power(n, p1, p2, alpha=0.05, trials=2000):
rejections = 0
for _ in range(trials):
x1 = rng.binomial(n, p1)
x2 = rng.binomial(n, p2)
phat1, phat2 = x1 / n, x2 / n
p_pool = (x1 + x2) / (2 * n)
se = math.sqrt(p_pool * (1 - p_pool) * (2 / n))
z = (phat1 - phat2) / se
if abs(z) > stats.norm.ppf(1 - alpha / 2):
rejections += 1
return rejections / trials
empirical_power = simulate_power(553, 0.08, 0.04)
Executed result: empirical power = 0.8145 over 2,000 trials (seed 12345), consistent with the target of 0.80 and validating the formula's answer rather than trusting it blindly. 553 releases per group is not practically achievable for most teams within any reasonable timeframe, which is itself an important, honest finding: a binary per-release metric is usually the wrong choice for this experiment.
Option B: a continuous, higher-frequency metric. Using weekly escaped-defect COUNT instead of a binary per-release outcome (baseline mean 3.0/week, target mean 1.8/week, standard deviation 2.0, a standardized effect size of Cohen's d = 0.6), the same normal-approximation approach applied to a continuous outcome gives:
def sample_size_continuous(d, alpha=0.05, power=0.8):
z_alpha = stats.norm.ppf(1 - alpha / 2)
z_beta = stats.norm.ppf(power)
n = 2 * ((z_alpha + z_beta) ** 2) / (d ** 2)
return math.ceil(n)
n_b = sample_size_continuous(0.6)
Executed result: n = 44 weeks per group, roughly a year total once both groups run concurrently over the same calendar period (see confounding-variable handling below), a dramatically more feasible design than Option A purely because of the metric choice, not the underlying effect size.
Success criteria
Pre-register the specific metric (weekly escaped-defect count, per Option B) and the specific improvement threshold (a reduction from a mean of 3.0 to 1.8 per week or better) BEFORE the experiment starts, along with the significance threshold (p < 0.05) and the practical-significance bar (the observed reduction must also be large enough to justify the ratio change's real cost, not just statistically distinguishable from zero).
Confounding variables
The biggest confounds in a real organization are: team composition changes during the measurement window (a team gaining or losing senior engineers independent of the ratio change), product complexity changes (a team shipping a harder feature set during the experiment than during the baseline period), and seasonal effects (release cadence and defect rates both often shift around major company events or holidays). Mitigate by choosing matched comparison groups from teams with stable composition over the measurement window, running both groups over the SAME calendar period rather than sequentially (so seasonal effects hit both equally), and tracking a secondary complexity metric (such as story points shipped) to confirm the two groups' workload stayed comparable throughout.
Trade-offs and pitfalls
The core lesson from the computation above generalizes: teams that try to empirically validate a testing-strategy change using a binary, rare-event, per-release metric are very often choosing an infeasible measurement design without realizing it, since a several-hundred-releases-per-group requirement is invisible until you actually run the power calculation. Doing that calculation FIRST, before committing to an experiment design, is what separates a real empirical validation from an experiment that will never reach a conclusive answer.
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.
You are adding a small new feature to an existing web app: a per-user settings toggle that changes UI behavior. Which tests would you write first, and why? Specify the concrete unit, integration, and end-to-end tests you would create, which tests could reasonably be postponed or executed manually instead, and how you would weigh risk, regression likelihood, and return on investment in that decision.
Sample Answer
For a small, contained change like a per-user settings toggle, the order you write tests in should mirror how cheaply each level can rule out a class of bug, not an arbitrary checklist.
Which tests to write first, and why
- Unit test first: does the function that decides the toggle's effective value (given the stored setting, any default, and any override) return the right value for on, off, and unset states. This is the cheapest possible check on the actual logic and should exist before anything else, since if the logic itself is wrong, no amount of higher-level testing will reliably catch every case.
- Integration test second: does saving the toggle's new value actually persist it correctly (a real database write and read-back), and does the API endpoint that exposes the setting return the correct value after a save. This proves the logic from step 1 is correctly wired to real storage, which the unit test cannot show.
- One end-to-end test: a single test that toggles the setting through the real UI and confirms the resulting UI behavior actually changes, proving the whole path (UI action, API call, storage, and the resulting render) is wired together correctly for a real user. One is enough here: additional UI-level variations (different starting states, different pages) are better covered at the unit or integration level instead.
What to postpone or handle manually
Visual polish around the toggle (exact spacing, animation) is better handled by manual or exploratory testing rather than an automated test, since automating a purely visual detail for a low-risk, easily-reverted feature rarely pays back its maintenance cost. Similarly, exhaustive combinations of the toggle with every other unrelated setting are not worth automating up front for a small, isolated feature; a manual spot-check covers that risk more cheaply until real usage shows a combination actually matters.
Weighing risk, regression likelihood, and ROI
The toggle's blast radius (does it interact with billing, security, or just cosmetic UI behavior) should set how much automated coverage it earns: a purely cosmetic toggle justifies exactly the three tests above and nothing more, while a toggle that gates paid functionality would justify more integration-level coverage of its interaction with the billing system specifically. Regression likelihood also matters: an isolated, rarely-touched feature is unlikely to be broken by unrelated future changes, so its test investment can stay minimal; a setting that many other features read is worth more integration coverage precisely because future unrelated changes are more likely to break it.
A harder version of the same judgment: a runtime, per-tenant feature flag
The same reasoning scales up for a runtime feature flag that can be toggled per tenant and rolled out to a percentage of users, but the stakes and the required levels both grow. Here you need coverage at four levels rather than three: unit tests for the flag-evaluation logic itself (given a tenant and a rollout percentage, does it correctly decide on or off); integration tests confirming flag state changes are correctly read and cached; a canary-level check confirming a partial rollout percentage is honored in practice (roughly the right proportion of requests see the new behavior, no more); and end-to-end tests confirming no leakage occurs between tenants (a flag enabled for tenant A never leaks its effect to tenant B) and that a rollback of the flag takes effect promptly. The extra canary level and the explicit tenant-isolation and rollback checks exist because the blast radius of getting a runtime, partial rollout wrong (accidentally exposing new behavior to the wrong users, or being unable to roll it back quickly) is categorically larger than a simple settings toggle's.
Trade-offs and pitfalls
The pitfall for the small-toggle case is over-testing a low-risk feature out of habit, writing UI-level tests for every state combination when a single end-to-end test plus solid unit coverage would give equivalent confidence at a fraction of the cost. The pitfall for the feature-flag case is the opposite: under-testing because "it's just a flag," when in practice a flag with per-tenant, partial-rollout semantics is closer in risk to a small distributed system than to a simple toggle, and deserves the fuller four-level treatment above.
A production bug in a critical API path slipped through despite your integration tests passing. Analyze the possible weaknesses across test-pyramid levels, environment parity, test selection, and CI gating that could explain how this happened, and propose a concrete set of improvements and guardrails to prevent similar escapes.
Sample Answer
Integration tests passing while a bug still reaches production tells you the bug lives in a gap the integration suite structurally cannot see, and the diagnosis needs to check four distinct places, not just "add more tests."
Weaknesses across pyramid levels
The bug might be a pure logic error that a unit test would catch far more precisely than an integration test ever could; if no unit test exists for the function that actually contains the bug, the integration test that exercises it indirectly may pass just by luck, testing a code path that happens not to trigger the specific edge case. Alternatively, the bug might be something ONLY an end-to-end test can see, such as a UI or client-side issue in how a correct API response gets rendered or handled, which no amount of API-level integration testing would ever exercise.
Environment parity
Integration tests commonly run against a test database or test configuration that differs from production in ways that matter: different data volume (a query that's fast on a small test dataset but times out on production scale), different configuration (a feature flag or environment variable set differently), or a downstream dependency's test double behaving more forgivingly than the real production service does. Any of these can produce a passing integration test that tells you nothing about production behavior.
Test selection
If the CI pipeline uses test-impact analysis or tagging to run only a subset of tests per change (to keep PR feedback fast), an imprecise dependency map can silently skip a test that would have caught this specific bug, because the tooling didn't correctly recognize that the changed code affected that test's path. This is invisible in the CI output, since the skipped test doesn't fail, it simply never runs.
CI gating
Even if the right test exists and would have failed, a gating policy gap can let a bug through anyway: for example, if a specific integration test is in a "monitored but non-blocking" tier (perhaps because it was historically flaky and got demoted), its failure might have been logged but not treated as a merge blocker, and the team missed the signal.
Concrete improvements and guardrails
- Once the specific missing coverage is identified, add a UNIT test for the exact logic bug first (fastest, most precise regression protection), not just another integration test, unless the bug is genuinely about wiring rather than logic.
- Audit environment parity specifically for the dimension that caused this bug (data volume, config, a lenient test double) and either close that gap or add an explicit test that exercises the production-like condition.
- If test selection is in use, audit whether its dependency map correctly captured this bug's code path, and tighten or add an explicit tag if the automated mapping missed it.
- Review the gating policy for any test tier that's "monitored but non-blocking" and confirm each one is there by a deliberate, current decision rather than institutional inertia from a past flakiness problem.
Trade-offs and pitfalls
The instinctive response to an escaped bug is "add a test for exactly this case," which is necessary but insufficient if the root cause is one of the systemic gaps above (environment parity, test selection, or gating): a single new test closes the specific hole discovered this time but leaves the same category of bug able to escape again through the same systemic gap. Treat the specific bug as a symptom that should prompt an audit of the four areas above, not just a checklist item to close.
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 21 Test Levels and the Test Pyramid interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.