One Bug, Four Doors: Unit, Integration, System, and Acceptance
A price-discount bug reaches production. The unit test for the discount function passes. The integration test with the pricing service passes. The system test of the cart passes. Acceptance testing signed off last Thursday. Where was the test that should have caught it? Almost always, the answer is a boundary between levels — and that is why levels matter more than any individual test.
A test level is a set of tests with a specific scope, objectives, and position in the lifecycle. The four canonical levels:
| Level | Scope | Typical environment | Owner | Speed |
|---|---|---|---|---|
| Unit | One function, class, or module in isolation | In-process, no network (mocks/stubs) | Developers | Milliseconds each; full suite in CI per commit |
| Integration | Interactions between modules or with real dependencies | Test env with databases, queues, or contract mocks | Developers + QA | Seconds to minutes |
| System | The assembled application end to end | Dedicated system-test environment | QA engineers | Minutes to hours per pass |
| Acceptance | Whole system against business needs | Production-like staging | QA + product owner + sometimes users | Hours; per release or per sprint |
Unit tests verify the smallest testable pieces. They are fast and deterministic because everything external — the database, the clock, HTTP calls — is replaced by fakes. Their contract: if the function's logic is wrong, a unit test fails. Their blind spot: the function can be perfectly correct while wiring it to the wrong neighbor breaks the product.
Integration tests cover exactly that wiring. Does the order service persist rows the way the database schema actually expects? Do serialized fields match between producer and consumer? The modern refinement is the split: contract integration tests (consumer-driven pacts, schema checks) run fast in CI against lightweight fakes, while a smaller suite runs against a real containerized Postgres or a staging Kafka, because production-like state exposes behaviors mocks hide — nullability, timeouts, transaction isolation.
System tests treat the deployed application as one black box through its real interfaces — UI, API, message consumers — and verify complete features: register, add to cart, apply coupon, pay, receive confirmation email. Only here do you see the assembled behavior, which is why environment drift (a test environment missing the email service) makes system testing silently skip what it claims to verify.
Acceptance testing asks whether the built system satisfies the business rule the feature was funded for: can a premium customer actually use the negotiated contract pricing in the demo environment? In agile teams this becomes product-owner walkthroughs of acceptance criteria on the sprint increment; in BDD setups, acceptance criteria are executable scenarios that double as the tests.
The interview-tested fact: defects get cheaper as the level drops and rarer as the level rises. A logic error caught by a unit test costs a fix inside one file; the same error escaping to production drags integration data, system test cycles, and a hotfix release behind it. When production shows a discount bug that every level missed, the productive question is which boundary it slipped through — often an untested contract between two services that both had green suites.
Functional, Regression, Smoke, Sanity: Sort by Intent, Not by Tool
Junior testers classify tests by tool — 'the Selenium tests', 'the Postman tests'. Interviewers and test strategists classify by intent: what question does this suite answer? Four intent categories cover most of what you will meet.
Functional testing verifies that a feature behaves according to its specification: the login API returns 200 with valid credentials and 401 with an invalid token; the export produces the correct CSV columns. Functional tests can live at any level — a unit test of a discount function is functional; so is a system test of checkout.
Regression testing answers a different question: did recent changes break existing behavior? It re-runs previously executed tests — ideally the automated ones — against the new build. Regression is an intent, not a suite: after a fix to the discount engine, re-running the full checkout flow is regression testing even though the flow is also functional. The economics argue for automation here more than anywhere else: regression runs repeatedly (every build, every release), so its cost is multiplied by every future sprint, while manual regression is the number one source of 'we don't have time to test' crises.
Smoke testing is the shallow, wide pass that answers: is this build stable enough to deserve deeper testing? A good smoke suite is 10 to 20 checks taking under 30 minutes — the app starts, login works, the three core journeys complete at happy-path level, the database connects, the payment sandbox responds. Smoke tests run on every candidate build, often as the last CI stage before a deploy to the test environment. The classic anti-pattern is launching a six-hour system test cycle on a build where the login API is down; forty minutes in, you learn what smoke testing would have told you immediately.
Sanity testing is the narrow, deep follow-up after a small change: the team changed only the tax calculation, so you verify tax scenarios — correct rate per region, rounding, invoice totals — and skip everything untouched. Sanity is what you run when a hotfix or a localized change needs targeted confirmation, not a full regression. The distinction interviewers probe: smoke asks 'is the build sane at all?', sanity asks 'does this specific change work, given the build is otherwise known-good'?
| Suite | Question answered | Depth | When it runs |
|---|---|---|---|
| Smoke | Can we test this build at all? | Shallow + wide (10-20 checks) | Every candidate build, before deep cycles |
| Sanity | Does the recent change work? | Narrow + targeted | After hotfixes or localized changes |
| Regression | Did anything old break? | Broad, mostly automated | Every build/nightly; full pass per release |
| Functional | Does the feature meet spec? | Deep per feature | During feature testing at any level |
A practical sequence for release day: smoke first; on green, full regression; targeted functional testing for changed features throughout; sanity passes after each hotfix. Note that none of these names a tool — the same Playwright spec can be serving smoke or regression intent depending on which suite it is placed in, and putting a broken-world test in the smoke suite because 'it's automated' defeats the purpose. Intent decides suite membership; automation is an implementation detail.
When a Defect Slips Through Every Level: Reading the Boundary
Consider the discount bug again with forensics instead of blame. The unit test passed because it tested the discount function in isolation — with a mocked tax service that always returned 200. The integration test passed because it exercised the discount service against a real database but never called the tax service at all. System testing passed because the environment's tax service stub returns a flat rate for every region. Acceptance passed because the demo customer has no regional tax rules configured. Every level was green, and the product was broken.
This is the standard failure shape: each level tested its slice correctly while the seams between slices went untested. Reading escapes this way produces concrete repairs:
- Map the defect to the smallest level that could have caught it. For the discount bug, that was integration: discount service calling the tax service with a regional rule. That level had a gap.
- Ask why the gap existed. The integration suite mocked the tax response — a decision made for speed. Legitimate, but it silently defined the seam as out of scope.
- Close the gap at the cheapest level that sees it. Add a contract test asserting the tax API's regional-rule behavior, plus one system test exercising a regional tax end to end. Do not fix the escape by adding system tests everywhere — that raises the cost of every future run.
- Check the level's charter, not just its cases. Gaps hide in definitions: if 'integration' means only service-to-database, the service-to-service seam is homeless. Give every seam an owning level in the test strategy.
Two quantitative habits make this repeatable. First, track escape level: for each production defect, record the earliest level that could have detected it and whether that level actually did. A quarterly review showing five escapes from the integration boundary makes the case for contract-testing investment better than any argument. Second, watch for environment gaps: a system suite that runs against a stubbed payment gateway verifies the stub as much as the product; listing every stub in your test environment is a five-minute audit that frequently finds untested seams.
Interviewers love this scenario because it tests process maturity, not trivia. The answer skeleton is: identify the smallest level that could have caught it → treat the miss as a level-boundary or charter problem → close it with tests at that level → feed the data back into the strategy. Mentioning that you would not respond by inflating the slowest, most expensive suite is the differentiator, because it shows you understand why levels exist: each one buys specific information at a specific cost, and a defect that crosses all of them means the buying plan had a hole, not that you needed to buy everything.
A worked exercise makes the loop concrete: take the last quarter's production defects and build a three-column retrospective — defect, earliest level that could have caught it, level that actually did. The gap column is your backlog. Teams that run this exercise once usually find the same distribution: a cluster of requirement defects that no test level could catch (those route to refinement practice), a cluster owned by the integration boundary (those route to contract tests), and one or two genuinely unforeseeable events (those become documented residual risk). The exercise takes an hour and typically re-prioritizes the next quarter of test investment more accurately than any strategy debate.
For how these levels are deliberately balanced against business-facing testing, see Agile Testing and Shift-Left.
Quiz
1. Which pairing of test level with its defining characteristic is correct?
2. A team hotfixed the tax rounding logic and wants a quick check that the change works without running the full nightly suite. What type of testing is this?
3. Why should smoke tests be kept small (10-20 checks, under 30 minutes) rather than expanded to cover most features?
4. A discount bug reaches production although unit, integration, system, and acceptance tests all passed. What is the most likely explanation?
Flashcards
Question
What distinguishes the four test levels by scope and speed?
Click to reveal answer
Answer
Unit: one component in isolation with mocks, milliseconds, owned by developers. Integration: module interactions or real dependencies (containerized DB, queues), seconds-minutes. System: whole deployed app via UI/API, minutes-hours, QA-owned in a dedicated env. Acceptance: entire system against business needs, production-like staging, product owner involved.
Question
Smoke vs sanity testing — the one-sentence distinction.
Click to reveal answer
Answer
Smoke testing is a shallow, wide pass (10-20 checks) asking 'is this build stable enough to test at all?' — run on every candidate build. Sanity testing is a narrow, targeted pass asking 'does this specific change work?' — run after hotfixes or localized changes on an otherwise known-good build.
Question
Why is regression testing primarily an automation argument?
Click to reveal answer
Answer
Regression re-runs existing tests after changes and executes repeatedly — every build, every release, multiplied across all future sprints. Automating it drives the per-run cost toward zero; manual regression is the classic cause of 'no time to test' release crises. The intent (did anything old break?) is independent of the tool.
Question
What is the standard cause of a defect that passes all four test levels, and how do you repair it?
Click to reveal answer
Answer
Cause: each level tested its slice while the seam between levels (e.g., service-to-service contract) had no owning test — mocks and stubs hide the gap. Repair: identify the smallest level that could have caught it, add a contract/integration test at that seam (plus one end-to-end confirmation), record the escape level in metrics, and update the strategy so every seam names its owning level.
Question
Give the standard release-day test sequence and what each stage answers.
Click to reveal answer
Answer
1) Smoke on the candidate build — 'can we test this at all?'; 2) on green, full regression — 'did anything break?'; 3) targeted functional testing of changed features throughout — 'does the new behavior meet spec?'; 4) sanity passes after each hotfix — 'does the fix work without breaking the immediate area?'
Revision Notes
Key Takeaways
- 1. Levels differ by scope, environment, owner, and speed — unit tests are fast and blind to wiring; system tests see everything but cost the most to run
- 2. Smoke and sanity are intents, not tools: smoke gates whether to test a build at all, sanity spot-checks a specific change on a known-good build
- 3. Regression is the automation argument: a suite run every build forever must be automated or it becomes the release bottleneck
- 4. Defects crossing all four levels usually escaped through a seam no level owned — repair by placing a contract test at that boundary
- 5. Track escape level per production defect; quarterly trends justify investment in integration and contract testing better than arguments do
Interview Tips
- • "Explain the four test levels." — answer with a comparison (scope, environment, owner, speed) rather than definitions in isolation, and finish with the economics: cheaper defects at lower levels, better visibility at higher ones
- • "What's the difference between smoke and regression?" — one sentence each: smoke = fast gate on build stability (shallow, wide, every build); regression = broad re-verification that old behavior survived (mostly automated, every build/nightly, full pass per release)
- • "A bug reached production with all suites green — what do you do?" — walk the forensics: identify the smallest level that could have caught it, name the unowned seam, add a contract/integration test there, and feed the escape into metrics and the strategy
- • "Would you automate all regression tests?" — say you automate the stable, high-repetition core, keep exploratory and volatile areas manual, and justify with cost-per-run math and flakiness risk rather than ideology
Cheat Sheet
Test Levels and Test Types
Levels (scope × owner × speed):
| Level | Scope | Env | Owner |
|---|---|---|---|
| Unit | 1 component, mocks | In-process | Developers |
| Integration | Module seams, real deps | Test env / containers | Devs + QA |
| System | Whole app, E2E | System-test env | QA |
| Acceptance | Business needs | Production-like staging | QA + PO + users |
Suites by intent:
| Suite | Question | Shape |
|---|---|---|
| Functional | Does it meet spec? | Deep per feature |
| Regression | Did old behavior break? | Broad, automated, repeated |
| Smoke | Is the build testable? | Shallow+wide, 10-20 checks, <30 min |
| Sanity | Does the change work? | Narrow, post-hotfix |
Release-day sequence: smoke → regression → targeted functional (ongoing) → sanity after hotfixes.
Escape forensics: map defect → smallest level that could have caught it → fix the seam at that level (contract test) → track escape-level metric → ensure every seam has an owning level in the strategy.
Economics: defects cheapest at unit level, most visible at system level; do not fix escapes by inflating the slowest suite.