Unit vs integration test
Compare test boundaries with a runnable booking example: a fake accepts duplicate slots while SQLite rejects them. See what each check establishes.
Unit tests focus on a small part of a system; integration tests exercise selected parts working together. Their coverage can overlap. Choose the boundary that exposes the mistake you need to detect, and be explicit about which dependencies are real or replaced.
A booking test with a list standing in for the database stores two reservations for one slot. The real database rejects the second. The list is useful for some checks, but it cannot demonstrate a database rule it never implemented.
A test stores one booking successfully. That result says nothing yet about a second booking for the same slot. In this illustrative example, the missing case also reveals a difference between a convenient database substitute and the real SQLite adapter.
Choose the boundary for the question#
To check a deadline calculation, call the function with known times and inspect its results. To check that an insert respects a database constraint, run the adapter against that database. The first question needs little surrounding machinery. The second needs the interaction that could be wrong.
Here we call the function-level check a unit test and the adapter-plus-SQLite check an integration test. Teams draw these boundaries differently. Martin Fowler’s discussion of unit tests explains both solitary tests, which replace collaborators, and sociable tests, which keep some collaborators real. A mock is not a requirement for every unit test.
These categories do not divide all defects into separate boxes. A wrong calculation may fail both a function test and a full booking test. An integration test with no useful assertion may miss a mismatch. The boundary determines what can be observed; the selected inputs and assertions determine what is actually checked.
What the substitute leaves out#
A test double stands in for a collaborator. A stub supplies chosen responses; a fake provides a simplified working implementation; a mock can check expected interactions. Those terms follow Fowler’s summary of Gerard Meszaros’s terminology. Their value depends on what the test is trying to learn.
Our fake stores bookings in a Python list. It deliberately leaves out uniqueness. The real adapter creates a fresh in-memory SQLite database, uses a parameterised insert and lets the schema enforce one stored reservation per slot.
The UNIQUE constraint rejects a second non-null slot with the same value; NOT NULL also excludes a missing slot. These rules are described in SQLite’s CREATE TABLE documentation. Python exposes a constraint violation as sqlite3.IntegrityError, documented in its SQLite interface.
This is actual SQLite, even though its database lives in memory. It is not evidence for PostgreSQL, a deployed database’s migrations or two concurrent network requests. The example has no expiry or release of cancelled reservations; the rule is simply one stored reservation per slot.
A passing test with a missing constraint#
The first check below inserts one row and reads the count. Both implementations pass. The duplicate-slot check adds another row with a different booking ID but the same slot, expects rejection and checks that the original row remains. The last check makes sure different slots are allowed.
Would you accept this change?
A PR claims duplicate bookings are prevented, but demonstrates only test_one_booking_is_stored against the list-backed fake. What evidence is missing?
Reveal the missing check
Try the duplicate with the actual adapter and database. A successful first insert does not establish rejection of the second. Updating the fake to reject duplicates can help application tests, but still does not execute the real schema.
The shared test deliberately expects the SQLite-level error because its subject is this tiny storage adapter. A larger application might translate that error into a domain result such as slot_unavailable. Test that translation at its own boundary rather than leaking database-specific exceptions into every application test.
No deployment failed here. This is a reproducible explanation of one way an isolated test can omit a dependency’s behaviour. The wider lesson is not that substitutes are bad: it is that a substitute’s passing result does not establish details it did not model.
Run the same checks against both stores#
Save the complete Python example. Use Python 3.11 or later with the standard sqlite3 module. Nothing connects to a server or writes a database file. Each database test gets a fresh connection and closes it afterwards.
python3 booking_tests.py –demo fake runs three checks against the list. The duplicate-slot check fails because the expected exception is not raised. Exit code 1 is intentional.
python3 booking_tests.py –demo database runs the same three checks against SQLite. All pass and the process exits with code 0. The tests were executed with Python 3.13.5 and SQLite 3.46.1; no speed comparison is claimed.
python3 booking_tests.py runs all 11 passing checks across the corrected teaching examples. The run notes describe the deliberate failures and their maintenance check.
| Situation | Take | Because |
|---|---|---|
| Does the cancellation deadline comparison match the rule? | Small logic test | Exercise the boundary input without needing storage |
| Does this insert respect the real schema? | Database integration test | Run the actual adapter and database constraint together |
| What does the service do when its dependency fails? | Controlled substitute | Choose the failure deliberately; verify the real boundary separately |
| Can a caller complete the booking journey? | Broader journey test | Exercise the configured path and state which services participate |
- Situation
- Does the cancellation deadline comparison match the rule?
- Take
- Small logic test
- Because
- Exercise the boundary input without needing storage
- Situation
- Does this insert respect the real schema?
- Take
- Database integration test
- Because
- Run the actual adapter and database constraint together
- Situation
- What does the service do when its dependency fails?
- Take
- Controlled substitute
- Because
- Choose the failure deliberately; verify the real boundary separately
- Situation
- Can a caller complete the booking journey?
- Take
- Broader journey test
- Because
- Exercise the configured path and state which services participate
For a deadline mistake that needs no database, continue with the missing-boundary testing exercise. To preserve an existing caller response while changing structure, use characterization tests. These are different reasons to write a test, not competing demands to put every test at one layer.
When the boundary includes retries and external effects, idempotency keys and safe retries explains the additional questions. Choosing the component boundaries themselves belongs to the architecture roadmap. In each case, name the observation you need before selecting the test machinery.
IF YOU REMEMBER ONE THING
Use small checks for local decisions and real collaborations where their behaviour matters. Describe what the test ran and asserted; its category alone is not evidence that a particular defect would be caught.
Questions people also ask
5 QUESTIONSWhat is the difference between integration and end-to-end testing?
An integration test exercises a selected interaction, such as an adapter and its database. An end-to-end test follows a broader user or client journey through the configured system. Neither label means every third-party service is real or every path is covered. Describe the tested boundary and environment.
Do unit tests require mocks?
No. A small function may need no substitute at all, and a unit test may use closely related real objects. Replace a dependency when doing so serves the test's purpose. Do not infer isolation or realism from the label alone.
Should I mock the database?
A substitute can help check how application logic reacts to a result or an error. It cannot demonstrate that your real query, schema and database configuration agree. Exercise that boundary with the intended database where those details matter.
How many of each should I write?
There is no ratio established by this example. Identify important failures, choose checks capable of detecting them, and measure the cost of running and maintaining those checks. Several layers may detect the same defect; avoid assuming their failure coverage is disjoint.
Why do integration tests sometimes become flaky?
Investigate changing shared data, uncontrolled time, asynchronous completion and external dependencies. A rerun may hide the symptom without identifying its cause. Integration tests are not inherently flaky: the SQLite example gives each test a fresh, local database.