Integration tests
Lesson 2 of 3
2Integration tests
The big pictureYour function builds valid SQL and the database works, but the column is created in the code and created_at in the table. No unit test will catch this. An integration test runs both pieces together.
What the picture shows: An integration test: every part is real, and the environment is set up and cleaned around each test.
Map of this part
The concepts in order, click any one to jump to it:
Two ways into the same idea
You tested the engine alone and it passed, and the wheels alone and they passed. But does the car move? An integration test is assembling the parts and driving in the car park before the road.
The test pyramid: many unit tests (fast), fewer integration tests (database, HTTP), and few end-to-end tests (through the browser). For integration use the same database as production (Postgres in Docker, or Testcontainers), since SQLite may accept what Postgres rejects. Isolation: a transaction rolled back after each test, or a fresh database per session. For HTTP APIs: FastAPI's TestClient, Flask's app.test_client() or supertest in Node, with no real network. External services (payments) are swapped for a fake server or a sandbox.
Before You Start
🧪Unit tests
The previous lesson.
🗄️SQL
You write INSERT and SELECT.
The Core Idea
1Code with a real database
Here an in-memory SQLite database is created clean for each test. The test goes through the code and the database together:
import sqlite3
SCHEMA = "CREATE TABLE orders (id INTEGER PRIMARY KEY, item TEXT NOT NULL, qty INTEGER CHECK (qty > 0))"
def place_order(db, item, qty):
cur = db.execute("INSERT INTO orders (item, qty) VALUES (?, ?)", (item, qty))
return cur.lastrowid
def orders_for(db, item):
return db.execute("SELECT id, qty FROM orders WHERE item = ?", (item,)).fetchall()
def fresh_db():
db = sqlite3.connect(":memory:")
db.execute(SCHEMA)
return db
db = fresh_db()
place_order(db, "latte", 2)
place_order(db, "latte", 1)
assert orders_for(db, "latte") == [(1, 2), (2, 1)]
print("insert + select: ok")
db = fresh_db()
try:
place_order(db, "latte", 0)
except sqlite3.IntegrityError as e:
print("constraint enforced:", e)2Testing a whole HTTP API
The test client sends requests to the app directly with no network. You check the status code and body, then the database:
from fastapi.testclient import TestClient
from app import app, get_db
import pytest
@pytest.fixture
def client(tmp_path):
test_db = make_db(tmp_path / "test.db") # fresh database per test
app.dependency_overrides[get_db] = lambda: test_db
yield TestClient(app)
app.dependency_overrides.clear()
def test_create_and_read_order(client):
r = client.post("/orders", json={"item": "latte", "qty": 2})
assert r.status_code == 201
order_id = r.json()["id"]
assert client.get(f"/orders/{order_id}").json()["qty"] == 2
def test_rejects_zero_qty(client):
assert client.post("/orders", json={"item": "latte", "qty": 0}).status_code == 4223The test pyramid
The wide base: hundreds of unit tests in seconds. The middle: dozens of integration tests in minutes. The top: a few end-to-end browser tests (Playwright) for only the most important user journeys, since they are slower and more brittle.
docker run --rm -p 5433:5432 -e POSTGRES_PASSWORD=test postgres:17, and point the test settings at it.Worked example
Problem: every unit test is green, and production returns 500 on creating an order. Analysis: a migration added a branch_id NOT NULL column, and the code does not send it. The unit tests mock the database, so they never noticed. Fix: an integration test that runs migrations on a real database, then creates an order. It failed at once, then passed after the field was added.
Myth vs Reality
| Myth | Reality |
|---|---|
| Integration tests replace unit tests. | They are slower and pinpoint the problem less precisely. You need both. |
| SQLite in tests is always enough. | It differs from Postgres in types and constraints. Use the production database. |
Test your understanding
What does an integration test catch that a unit test does not?
It joins the real pieces, revealing what lies between them.
Why start each test with a clean database?
Isolation makes order irrelevant and failures reproducible.
Where do end-to-end browser tests sit in the pyramid?
Slow and brittle, so they are kept for what matters most.
Quick review cards
Check Your Understanding
How do you handle the payment gateway in tests?
With a fake server or the provider's sandbox, never the real one.
A test passes alone and fails with the rest. Likely cause?
Shared state not cleaned between tests.
Why not make every test end-to-end?
They are slow, brittle, and do not pinpoint the bug when they fail.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs2
Done with this lesson?
Sign in to save your progress.