When all units pass and the app still breaks
Here is the trap that makes integration tests necessary. Component A is tested with doubles and is green. Component B is tested with hand-built inputs and is green. Yet A's real output and B's expected input disagree, perhaps only in how a dictionary key is spelled. Every unit test passes and the assembled program crashes on its first real run.
An integration test closes that gap by running the real pieces chained together, with no doubles at the seam:
order = parse_order(line) # real A total = order_total(order) # real B, fed A's real output
You need far fewer of these than unit tests, roughly one per important seam, because the units already cover the edge cases inside each piece.
The example below feeds the seam a CSV line, short for comma-separated values, the plain-text format where each record is one line and commas separate the fields, as in notebook,3,4.50. CSV is the most common way tabular data moves between programs, which makes a CSV parser feeding a calculator a very realistic seam.
A healthy seam
A raw CSV line flows through the real parser into the real total function, and the seam holds.
def parse_order(line): name, qty, price = line.split(",") return {"name": name, "qty": int(qty), "unit_price": float(price)} def order_total(order): return round(order["qty"] * order["unit_price"], 2) def test_integration_line_to_total(): order = parse_order("notebook,3,4.50") assert order_total(order) == 13.5 test_integration_line_to_total() print("PASS test_integration_line_to_total")
Output
PASS test_integration_line_to_total
The test's input is a raw string and its assertion is a final number, with nothing hand-built in between. That is the shape of an integration test: give the chain the data a real caller would give it, and check what comes out the far end.
Three separate contracts are being verified at once here. parse_order produces a key named unit_price, it converts the quantity to an int and the price to a float, and order_total reads those exact names and types. A unit test of either function alone verifies none of the three.
The expected 13.5 was computed by hand, since 3 times 4.50 is 13.50. Note that Python prints that as 13.5 and compares equal to it, so asserting == 13.5 is correct even though the currency has two decimal places.
What makes it an integration test
test_integration_line_to_total earns the name because it exercises two real components chained together, with no double at the seam.
The defining feature is real pieces meeting at a real boundary, so parse_order's genuine output feeds order_total. Nothing in the test constructs the intermediate dictionary.
A unit test of order_total would hand-build that dictionary instead, writing something like {"qty": 2, "unit_price": 3.0} in the arrange phase. That is a perfectly good unit test, and it is precisely how key-name mismatches slip through, because the hand-built input is a guess about what the parser produces rather than a fact about it.
Two things do not make a test an integration test, and both are common confusions. Length is irrelevant, since a long unit test with many asserts is still a unit test. So is calling more than one function, because a unit test freely calls helpers that belong to the unit itself. The question is only whether a real collaborator was replaced by a double.
A renamed key at the seam
Someone renamed a key in parse_order, and the unit test still passes while the integration test crashes with KeyError. Before the fix, the parser returned "price" where order_total reads "unit_price".
def parse_order(line): name, qty, price = line.split(",") return {"name": name, "qty": int(qty), "unit_price": float(price)} def order_total(order): return round(order["qty"] * order["unit_price"], 2) def test_order_total_alone(): assert order_total({"qty": 2, "unit_price": 3.0}) == 6.0 def test_integration_line_to_total(): order = parse_order("notebook,3,4.50") assert order_total(order) == 13.5 test_order_total_alone() print("PASS test_order_total_alone") test_integration_line_to_total() print("PASS test_integration_line_to_total")
Output
PASS test_order_total_alone PASS test_integration_line_to_total
The KeyError names the missing key directly, so the message reads KeyError: 'unit_price'. Reading it tells you order_total asks for unit_price while the parser now produces price, which is the whole diagnosis in one line.
The unit test passed throughout because it hand-built its dictionary with the right key and never touched the real parser. That is the doubles blind spot from lesson 6-1 in its most concrete form, and it is the reason the integration test exists.
The fix restores the contract by making parse_order return unit_price again. One key name, two green tests, and the choice of which side to change is a real decision: renaming the reader instead would work equally well, and the team's agreed contract is what settles it.
Notice how loud the failure is compared with how quiet the cause was. A single character difference in a string literal, invisible to both unit tests, takes the whole feature down, which is the argument for spending one integration test per seam.
Where twelve boundary probes go
Given a suite of 900 unit tests, 90 integration tests, and 9 end-to-end tests, twelve numeric edge cases around a new boundary belong at unit level.
Boundary probes are pure logic checks that cost microseconds at the base, so all twelve are affordable. The lesson 6-1 rule applies directly, since edge cases and boundary trios live at the wide base.
The other two levels barely move:
- integration gets one test to prove the new rule is wired into the flow at all
- end-to-end does not change, because its few journeys already smoke-test the assembled system
Doing the arithmetic makes the reasoning concrete. Twelve unit tests add roughly nothing to a run that already executes 900, while twelve end-to-end journeys at ten seconds each add two minutes to every run forever, and buy no information the unit tests do not already have.
Placing a parser error case
The requirement that parse_order must raise ValueError on a line with a missing quantity, such as "notebook,,4.50", belongs in a unit test of parse_order alone.
The behavior lives entirely inside one component, so it is unit territory, meaning cheap to write, precise about what broke, and pointing straight at parse_order when it fails.
The seam already has its one integration test proving that real output feeds real input. Re-running every parser edge case through the seam would slow the suite without adding information, because a malformed line never reaches order_total at all.
Worth noting that int("") already raises ValueError in Python, so the requirement may be satisfied by the existing code rather than needing new logic. Writing the test first is how you find out, and if it passes immediately, you have learned the behavior was accidental and is now locked in on purpose.
The general rule this illustrates: put a test at the lowest level that can observe the behavior. Anything higher costs more and tells you less about the cause.