A function can have 100% unit test coverage and the page that calls it can still return a 500 in production. That is not a contradiction. Unit tests, integration tests, and end-to-end tests check different things, and a suite that’s heavy on one and light on another has a specific, predictable hole in it.
What unit, integration, and end-to-end tests each check
Take a sign-up feature: a function validates the input, writes a row to a database, and sends a welcome email. Three tests can cover it, and each one answers a different question.
| Level | Question it answers | What’s real, what’s swapped out |
|---|---|---|
| Unit | Does this one function do what it should? | Everything around the function is swapped out: no real database, no real network |
| Integration | Do this function and a real dependency work together? | The database is real; the parts still outside the boundary you’re testing (the mail server) are swapped out |
| End-to-end | Does the whole request work, from the outside? | Nothing is swapped out. A real HTTP request hits a real running process |
A unit test tells you the email-format check rejects what it should. An integration test tells you the row actually lands in the database with the columns you think it has. An end-to-end test tells you a client can call the endpoint and get a 201 back. None of them can stand in for the others.
Testing one Flask feature at all three levels
Here’s the feature: a sign_up function that validates an email, writes a user, and sends a welcome message.
| |
sign_up takes db and smtp_client as arguments instead of reaching for a global connection. That one choice makes all three tests below possible: each passes something different for those two parameters. get_db() and get_smtp_client() read their target from an environment variable on every call instead of hardcoding it once, which is what lets the end-to-end test below point the running app at a throwaway database and a local test mail server without touching app.py.
Unit test: swap out both dependencies
A unit test isolates sign_up completely. Neither the database nor the SMTP client is real; both are Mock objects from Python’s standard library.
| |
Two kinds of test double are at work here, and the names matter because they set different expectations. db in the first test is close to a dummy: it’s passed in because the signature needs something, and the test only cares that it’s never touched. smtp_client in the second test is a mock in the strict sense. The test defines the expectation (sendmail gets called with this address) and fails if reality doesn’t match. That’s behavior verification: you check that a call happened, not what came back from it.
These tests run in milliseconds and never touch disk or a socket. They also can’t tell you whether INSERT INTO users matches the real table’s columns. db never executes anything real, so a typo in the SQL would pass every unit test you write.
Integration test: a real database, a fake mail server
An integration test closes that gap one dependency at a time. Here the database is real, an in-memory SQLite connection with the actual schema, while the mail server is still swapped out. This time it’s a fake: a small working implementation instead of a scripted stand-in.
| |
FakeSMTP has no expectations scripted into it the way smtp_client.sendmail.assert_called_once() did. It keeps a real, if tiny, list of what was sent, and the test reads that list afterward. That’s the difference between a mock and a fake: a mock is told what to expect before the code runs; a fake behaves like a smaller version of the real thing and you inspect it after.
This test catches a wrong column name or a missing table. It won’t catch a bug in Flask’s request parsing or a wrong URL in the route decorator, because nothing here goes through HTTP.
End-to-end test: a real request against a running process
An end-to-end test drops both substitutions. It starts the actual Flask app as a separate process, sends it a real HTTP request over a real socket, and checks the result the way an outside client would: by calling the API back, not by reaching into the database.
| |
aiosmtpd stands in for the mail-catcher you’d run in a staging environment: something that actually speaks SMTP on a socket, so the app’s real smtplib.SMTP(...) call has somewhere to connect. The test also gives itself a throwaway database file instead of reusing app.db — running this test twice against a shared file would fail the second time on the same PRIMARY KEY that [email protected] already occupies, which is the exact kind of leftover-state flakiness this article gets to further down. Nothing in this test knows that sign_up or create_user exist. It only knows the HTTP contract. Rename a route or change the JSON shape and this is the test that fails, while the unit and integration tests above keep passing.
Install the dependencies with pip install flask requests aiosmtpd pytest and run all three with pytest. When something breaks, which file fails tells you which class of bug you have. That is the reason to write more than one level; coverage percentage isn’t.
Testing pyramid vs. testing trophy
The classic shape for a test suite is Mike Cohn’s testing pyramid: a wide base of fast unit tests, a smaller layer of integration tests, and a thin cap of slow, expensive end-to-end tests. The point is the ratio, not the count. Most of your tests should be the cheap kind.
Kent C. Dodds’ testing trophy disagrees about where the weight goes. His argument: unit tests in isolation give you confidence that’s cheap to earn but doesn’t reflect how the software is used, so a suite made mostly of unit tests can miss real bugs at the seams. The trophy puts static analysis (linting, type checking) at the base, a unit layer above it, integration tests as the widest part, and a thin end-to-end layer on top. test_sign_up_persists_user_and_records_email above is the kind of test the trophy wants more of.
| Pyramid (Cohn) | Trophy (Dodds) | |
|---|---|---|
| Biggest layer | Unit | Integration |
| Bottom layer | Unit tests | Static analysis (lint, types) |
| Guiding question | How cheap is this test to write and run? | How much does this test resemble real usage? |
| Weak spot it targets | Slow, brittle end-to-end suites | Unit tests that pass while the seams are broken |
Don’t follow either shape literally. They agree on the ordering: end-to-end tests are the most expensive and should be the fewest, unit tests are the cheapest. Where the bulk of your effort goes depends on your bug history. If most bugs are components misbehaving on their own, lean toward the pyramid. If most are components disagreeing about how they talk to each other, lean toward the trophy.
Why end-to-end tests go flaky
A suite with a growing pile of end-to-end tests eventually earns a reputation: rerun the failing one and it passes. That’s flakiness, and it comes from the same property that makes end-to-end tests valuable. They touch real infrastructure.
The usual causes are a fixed sleep() that’s long enough on your laptop and too short on a loaded CI runner, test data left behind by a previous run colliding with the next one, a network call to something outside your control, and shared state between tests that only shows up when they run in a different order or in parallel. The time.sleep(0.5) in the test above is the first kind; the temp file the same test creates for DB_PATH is a direct fix for the second. None of these are bugs in the feature. They’re bugs in how the test deals with real infrastructure, a problem unit tests never have.
Retrying until green is not the fix; it hides the next real failure in the noise. Keep the end-to-end layer thin by covering the critical paths, not every branch. Give each test its own data instead of shared fixtures. Replace fixed waits with polling for the condition you actually need, such as the port accepting connections. CI time is the other cost: if you’ve already sped up the pipeline by caching dependencies in GitHub Actions, an end-to-end layer that needs three reruns to go green gives most of that time back.
Which kind of test to write next
If a bug just escaped to production, ask which layer would have caught it before you add any test. A bad calculation or an unhandled edge case in one function calls for a unit test. Two pieces of your own code disagreeing about a shape or a contract, like the API returning a field the frontend doesn’t expect or a query that assumes a column that got renamed, calls for an integration test. A feature that passes every test but fails for a real user is usually missing end-to-end coverage of the path that user took.
Write the unit test first. It’s the cheapest signal and the fastest to tell you when you’ve broken the logic. Add an integration test when two of your own pieces need to prove they agree. Save end-to-end tests for the handful of flows where “it works” only means something from the outside: sign-up, login, checkout, password reset.
Keep that split and you get a suite whose failures mean something. That matters beyond CI. Git bisect with git bisect run needs a test command that fails only when the code is actually broken, and a flaky end-to-end test will send it to the wrong commit.