Module 1: From Units To Integration
2. Integration versus unit
Description
In the previous lesson you saw the gap: the same book that passes with a double fails with the real piece. To understand why —and what to do about it— you need two words with an exact, non-fuzzy meaning: unit test and integration test. Many people use them as vague synonyms for "small test" and "big test", and that vagueness is exactly what let the lesson 1 bug through. This lesson gives them a precise edge.
The definition we'll use is about isolation, not size. A unit test tests a unit isolated from its real collaborators: where the unit would touch a database, a payment gateway, or a mail server, you put a double. The unit runs its logic; the doubles fill in the rest; nothing real crosses the seam. An integration test tests two or more real pieces working together, crossing the seam that joins them: BookingService and the real SqliteBookingRepository, with the booking traveling from one to the other and back. The difference isn't in how many lines of code the test has or how long it takes: it's in whether there's a real piece at the seam. With only doubles, it's unit. With at least one real piece crossing its joint, it's integration.
Connection to the module: this lesson is the vocabulary foundation the other seven stand on. Lesson 1 showed you the gap; this one gives you the names to talk about it without ambiguity. Lesson 3 will use these names to build the pyramid (how many of each), lesson 4 to locate the seams (where each type lives), and lesson 5 to explain precisely why the green unit test didn't see what the integration test did. Without these two firm definitions, the rest of the guide would be a dialogue of the deaf.
Analogy: the single-subject exam and the integrative exam
Think of how you were graded at school. There were two kinds of exam. The single-subject exam measured one thing in isolation: the algebra one only asked for algebra, with a calculator allowed so arithmetic wouldn't get in the way. If you got something wrong, you knew exactly what you'd failed: algebra, nothing else. The calculator was your "double": it stood in for one capability (adding) so the exam measured only the one it cared about (solving for a variable). And there was the integrative exam at the end of the year: a physics problem that required setting up the equation (algebra), solving it (arithmetic), and applying the correct physical law, all together, without a calculator. That exam didn't measure one capability: it measured whether the capabilities fit together when working in a chain. You could get an A in algebra, an A in arithmetic, and an A in physics separately, and still stumble on the integrative one because, chaining them, you carried a sign over from one to the other.
A unit test is the single-subject exam: it isolates the unit, doubles the rest, and when it fails you know exactly which piece failed. An integration test is the integrative exam: it connects the real pieces and measures whether they fit in a chain. And the school moral is the same as this guide's: getting an A in each subject separately does not guarantee an A on the integrative one, because the integrative one tests something no isolated exam tests —the join—. You need both. The single-subject exam tells you, with surgical precision, where you're weak; the integrative one tells you whether, together, your pieces actually solve the problem.
The two definitions, side by side
Let's put them face to face, no frills.
| Unit test | Integration test | |
|---|---|---|
| What it tests | A unit isolated from its collaborators | Two or more real pieces, together, crossing their seam |
| The seam | Closed with a double (fake, stub, spy, mock) | Open: the real piece actually crosses |
| The question | "Is my logic correct, assuming the collaborators comply?" | "Do the real pieces comply and fit together?" |
| If it fails | The bug is in this unit (isolated, easy to locate) | The bug is in some piece or in its joint (you have to locate it) |
| Speed | Microseconds: everything in memory | Slower: touches disk, network, processes |
| Determinism | High: doubles have no weather of their own | Lower: depends on real state (files, connections) |
The row that matters most is the question, because all the others derive from it. The unit test asks whether your logic is correct assuming the collaborators behave as you suppose. The integration test asks whether that assumption was true and whether, when the pieces really connect, they fit. They're different questions, and that's why a healthy suite answers both: the first gives you speed and precision when debugging; the second gives you confidence that the assembled system works.
Notice something the table makes clear and intuition tends to erase: speed is a consequence, not the definition. A unit test is fast because it's isolated (the doubles live in memory), not the other way around. If you call something a "unit test" only because it's fast, but inside it opens a connection to SQLite, it isn't a fast unit test: it's an integration test that happened to be fast, with all of integration's fragility hidden under the wrong label. The day that database file gets locked, your "unit test" will fail for a reason no unit test should ever have.
Worked example: the same book, two questions
Nothing clarifies the distinction like seeing it on the same code. Here is Reservo's book tested twice. The first test is a unit test: BookingService is the unit, and its four collaborators are all doubled. It asks whether book orchestrates well —whether it charges the correct amount, saves, and confirms by email—, assuming saving works. The second is an integration test: BookingService connects to the real SqliteBookingRepository. It asks something the first can't: whether what book saves actually stays in the database and can be read back.
# tests/test_unit_vs_integration.py
import sqlite3
from datetime import datetime
from reservo.calendar import Calendar
from reservo.doubles import (FakeBookingRepository, FixedClock,
SpyEmailSender, StubPaymentGateway)
from reservo.models import Member, Room
from reservo.services import BookingService
from reservo.sqlite_repo import SqliteBookingRepository
FOCUS = Room(id="focus", name="Focus", capacity=4, hourly_cents=2500)
ANA = Member(id="m-ana", name="Ana", tier="pro")
START = datetime(2026, 3, 10, 9)
END = datetime(2026, 3, 10, 12) # Focus 3 h
CLOCK = datetime(2026, 3, 1, 9)
# UNIT — BookingService isolated, the repo is a double.
# Question: "does book orchestrate well (charge, save, confirm)?"
def test_book_orchestrates_correctly_unit():
payments = StubPaymentGateway(ok=True)
emails = SpyEmailSender()
repo = FakeBookingRepository()
service = BookingService(Calendar(), FixedClock(CLOCK), payments, emails, repo)
booking = service.book(FOCUS, ANA, START, END)
assert booking.price_cents == 6000 # correct charge
assert payments.charges == [(6000, "m-ana")] # charged once
assert len(emails.sent) == 1 # confirmed by email
assert booking.status == "confirmed"
# INTEGRATION — BookingService + real SqliteBookingRepository.
# Question: "does what book saves actually stay in the database, readable?"
def test_book_persists_to_real_db_integration():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = BookingService(Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo)
booking = service.book(FOCUS, ANA, START, END)
saved = repo.get(booking.id)
assert saved.price_cents == 6000 # the integer crossed the seam
assert saved.status == "confirmed" # the text crossed the seam
assert saved.room_id == "focus"
Look at the differences, because they're the ones from the table, turned into code. The unit test doubles the repo (FakeBookingRepository) and verifies things about the object book returns and about the doubles (payments.charges, emails.sent): the orchestration logic. The integration test uses the real SqliteBookingRepository, and does something the unit test can't: after booking, it reads back from the database (repo.get) and verifies that the booking was stored and is readable. The seam —between BookingService and persistence— is closed in the first and open in the second.
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_unit_vs_integration.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items
tests/test_unit_vs_integration.py::test_book_orchestrates_correctly_unit PASSED [ 50%]
tests/test_unit_vs_integration.py::test_book_persists_to_real_db_integration PASSED [100%]
============================== 2 passed in 0.01s ===============================
Both green. And this is important so we don't caricature: an integration test is not "the one that always fails". Here the integration one passes, because the assertions it makes —about price_cents (an integer), status, and room_id (text)— land on fields that do cross the seam without changing shape. The integration test only goes red when the seam has a real divergence, like the datetime one from lesson 1. Integration isn't permanent suspicion; it's the only test that can confirm that the pieces fit, and sometimes the answer is "yes, they fit", green. What makes it valuable is that it can give either of the two answers truthfully, while the unit test can only answer for half the seam.
Why you need both (and can't pick one)
It's tempting to look for a winner. "If integration tests the real pieces, why the unit tests?" Or the reverse: "if unit tests are fast and precise, why the slowness of integration?" Both questions have the same answer: because each one answers something the other can't.
What only the unit test gives you: precision on failure. When test_book_orchestrates_correctly_unit fails, you know the bug is in BookingService's logic, because everything else is doubled —there's no database that could be broken, no network that could be down—. The failure points to a single place. Besides, it's so fast you can run thousands in the time an integration test takes to open a connection. That speed isn't a luxury: it's what lets you run the suite on every save, keep the red-green-refactor loop tight, and get the failure while the change is still fresh in your head.
What only integration gives you: confidence in the joints. The unit test assumes the repository complies with what BookingService expects. Integration verifies that assumption. Lesson 1 showed what happens when the assumption is false: 200 green unit tests and a broken screen in production. No amount of unit tests covers a joint no unit test touches. Integration is the only test that can say "yes, the real piece really does behave the way your logic assumes" —or "no, it doesn't, and here's the line"—.
Removing either of the two leaves you blind in one eye. Only unit tests: fast and precise, but not seeing the joints —the way lesson 1 broke—. Only integration: you see the joints, but each failure is an investigation (was it my logic, the database, the network?), the suite takes minutes and fails because of the infrastructure's weather. The answer isn't to choose: it's to combine in the right proportion, and that proportion has a shape —the pyramid— which is the topic of lesson 3.
Common mistakes
Defining "unit test" by speed instead of by isolation. What happens: someone measures "does it take less than X ms?" and, if so, calls it a unit test, no matter what it touches inside. Why it happens: speed is measurable and visible; isolation has to be reasoned about. How to detect it: open the test and look for whether any real piece crosses a seam —a connection to SQLite, a file, a socket—. If there is one, it's integration, however long it takes. How to fix it: classify by isolation. "Is there a real piece at the seam?" If not, unit; if so, integration. Speed is the consequence, not the criterion.
Believing an integration test "tests more" and is therefore better. What happens: someone concludes that since integration touches the real thing, it's a superior test, and prefers to always write integration. Why it happens: "more real" feels like "more true". How to detect it: if your suite takes minutes, it's hard to know what failed when it fails, and it sometimes goes red without you changing code, you've overdone integration. How to fix it: it doesn't test more, it tests something else —the joints, not the isolated logic—. An integration failure is ambiguous by design (it could be any of the pieces or their joint); a unit failure is precise. They complement each other; neither is "better".
Doubling a piece in a test advertised as integration. What happens: someone writes an "integration test for book" but doubles the repository with the fake to make it easier. Why it happens: the fake is convenient and the real one asks for setup. How to detect it: if the piece it claims to integrate is doubled, the seam that mattered is still closed, and the test is unit in disguise. How to fix it: an integration test must leave real exactly the piece whose joint you want to verify. You can double others (payment, email) to bound the noise —that's legitimate and we'll see it in lesson 6—, but the seam under test has to be open, or you're not integrating anything.
Exercises
Exercise 1 — Classify and justify. For each test, say whether it's unit or integration, and name the seam that is closed or open: (a) refund_cents(booking, 6000, now) verified directly for 72/36/12 h; (b) cancel with FakeBookingRepository, StubPaymentGateway, and SpyEmailSender; (c) find_by_room of the real SqliteBookingRepository returns the two bookings for the Focus room; (d) book with the real SqliteBookingRepository and a doubled StubPaymentGateway.
See solution
- (a)
refund_centsdirectly — unit. There are no collaborators; it's pure logic. There's no seam to open: the unit talks to no one. The cleanest possible unit test. - (b)
cancelwith three doubles — unit. The seam betweenBookingServiceand each collaborator (repo, payments, email) is closed with a double.cancel's orchestration logic is tested in isolation. - (c)
find_by_roomof the real repo — integration. The seam betweenSqliteBookingRepositoryand its database is open: the real component is tested against its real resource. That only one piece is involved doesn't make it unit; what counts is that the piece is real and crosses its joint. - (d)
bookwith real repo and doubled payment — integration. The key seam —BookingService↔ persistence— is open (real repo), even though the payment seam is closed (stub). It's enough for one relevant seam to be open with a real piece for it to be integration. Doubling the payment only bounds the noise; it doesn't turn it into unit.
The rule: look at the test's seams. If they're all closed with doubles, unit. If at least one is open with a real piece, integration —no matter how many pieces there are or how long it takes—.
Exercise 2 — The question each one answers. Reservo has a bug: SqliteBookingRepository.save forgets a comma in the SQL and doesn't save the status, so every booking reads back as status=None. Which of the two tests in this lesson would have caught it, and why wouldn't the other?
See solution
It would have been caught by the integration test (test_book_persists_to_real_db_integration), specifically by the assertion assert saved.status == "confirmed". That test reads the booking back from the real database, so if save didn't store the status correctly, saved.status would be None and the assertion would fail, flagging the bug.
The unit test (test_book_orchestrates_correctly_unit) would not have caught it, because it uses the FakeBookingRepository, whose save stores the whole object in a dict without running a single line of SQL. The bug lives exclusively in the SQL of SqliteBookingRepository.save, a piece the unit test never touches. Its question —"does book orchestrate well?"— is answered the same with or without the bug, because the bug is below the seam the unit test closes with the fake.
This is the lesson in one sentence: each test answers its question and only its question. The unit test's is "is my orchestration logic correct?"; the integration one's is "does the real piece comply and truly persist?". A SQL bug lives in the territory of the second question, so only the test that asks that question sees it.
Exercise 3 — The integration test that passed green. In the worked example, the integration test passed. A colleague says: "then it was useless, it didn't find any bug". Explain why a green integration test does add value, and what it would have meant if it had failed.
See solution
A green integration test adds confirmed confidence, not a simple absence of news. Before running it, "book persists correctly in SQLite" was an assumption: we believed price_cents, status, and room_id crossed the seam intact, but we hadn't verified it against the real piece. The green test turns that assumption into a proven fact: yes, those fields are saved and read back correctly from the real database. It's exactly the value of the integrative exam that goes well: not "I learned nothing", but "I confirmed that my pieces fit in a chain".
If the test had failed, it would have meant one of those assumptions was false —that some field doesn't cross the seam the way we believed, as happened with the datetime in lesson 1—. And it would have said so before production, with the exact line. That a test can give either of the two answers truthfully is what makes it useful: a test that can only pass proves nothing, but this one could have failed (we saw it fail in lesson 1 with a different assertion) and didn't. Green, here, is information: the seam, for these fields, is healthy.
Summary and next step
In this lesson you gave an edge to the two words that hold up the guide. A unit test tests a unit isolated from its collaborators, with the seam closed by doubles, and answers "is my logic correct, assuming the collaborators comply?". An integration test tests two or more real pieces together, with the seam open, and answers "do the real pieces comply and fit?". The distinction is about isolation, not size or speed; speed is a consequence. And you saw, with the same book tested twice, that you need both: the unit one for its precision and speed, integration for the confidence in the joints no unit test can give.
Before moving on you should be able to: classify any test as unit or integration by looking at whether there's a real piece at the seam; state the question each one answers; and explain why removing either of them leaves you blind in one eye.
Now that you know you want both, the immediate question is in what proportion. How many unit, how many integration? The answer has a famous shape —a pyramid— and a logic behind it that explains why that shape, and no other, is what keeps a suite fast, reliable, and cheap to maintain. That's lesson 3.
Resources
- Martin Fowler — UnitTest — the essay that discusses why "unit test" is a slipperier term than it seems and why isolation (not size) is the useful criterion; the backdrop of the definition we use in this lesson.
- Martin Fowler — IntegrationTest — the counterpart: what testing integration really means, and the distinction between narrow and broad integration we'll pick up again in lesson 6.
- pytest documentation — How to invoke pytest — the reference for running a specific file or test (
-v, selecting by name), which we use to run and separate the unit from the integration test. sqlite3— DB-API for SQLite (Python documentation) — the reference for the real module that plays "the real piece" every time we open an integration seam in the guide.