Testing in Practice · Foundations: Strategy, pytest and Test Doubles
Integration Tests Against a Real Database
Test data-access code against a real SQL engine with schema setup, per-test transactions that roll back, and checks that constraints hold.
What you'll build
Integration tests for an orders repository against a real SQL database: the schema is created once, each test runs inside a transaction that's rolled back (fast, isolated), and the tests prove that the SQL, constraints and transactions actually behave as expected, which mocks can't.
The idea
- Data-access code fails in ways mocks hide: SQL typos, wrong joins, missing constraints, transaction bugs.
- Run these tests against the same engine as production where possible (PostgreSQL in a container via Testcontainers or Docker Compose). This example uses SQLite to stay self-contained; the pattern is identical.
- Create the schema once per session; wrap each test in a transaction and roll back, so tests don't see each other's data.
- Test constraints and failure paths, not just inserts.
The code
# file: orders.py
class OrderRepository:
def __init__(self, conn):
self.conn = conn
def place(self, customer: str, sku: str, qty: int) -> int:
cur = self.conn.execute("UPDATE stock SET available = available - ? WHERE sku = ? AND available >= ?", (qty, sku, qty))
if cur.rowcount != 1:
raise ValueError(f"not enough {sku} in stock")
cur = self.conn.execute("INSERT INTO orders (customer, sku, qty) VALUES (?, ?, ?)", (customer, sku, qty))
return cur.lastrowid
def totals_by_customer(self):
return self.conn.execute("SELECT customer, SUM(qty) FROM orders GROUP BY customer ORDER BY customer").fetchall()
# file: conftest.py
import sqlite3
import pytest
SCHEMA = '''
CREATE TABLE stock (sku TEXT PRIMARY KEY, available INTEGER NOT NULL CHECK (available >= 0));
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, sku TEXT NOT NULL REFERENCES stock(sku),
qty INTEGER NOT NULL CHECK (qty > 0));
'''
@pytest.fixture(scope="session")
def database(tmp_path_factory):
conn = sqlite3.connect(tmp_path_factory.mktemp("db") / "test.db", isolation_level=None)
conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(SCHEMA)
conn.execute("INSERT INTO stock VALUES ('mug', 5), ('teapot', 1)")
return conn
@pytest.fixture
def conn(database):
database.execute("BEGIN")
yield database
database.execute("ROLLBACK") # every test starts from the same data
# file: test_orders.py
import sqlite3
import pytest
from orders import OrderRepository
def test_placing_an_order_reduces_stock(conn):
OrderRepository(conn).place("ana", "mug", 2)
assert conn.execute("SELECT available FROM stock WHERE sku = 'mug'").fetchone()[0] == 3
def test_cannot_oversell(conn):
repo = OrderRepository(conn)
repo.place("ana", "teapot", 1)
with pytest.raises(ValueError, match="not enough teapot"):
repo.place("ben", "teapot", 1)
def test_totals_group_by_customer(conn):
repo = OrderRepository(conn)
for customer, qty in [("ana", 1), ("ben", 2), ("ana", 1)]:
repo.place(customer, "mug", qty)
assert repo.totals_by_customer() == [("ana", 2), ("ben", 2)]
def test_unknown_sku_violates_foreign_key(conn):
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO orders (customer, sku, qty) VALUES ('ana', 'lamp', 1)")
def test_previous_tests_left_no_data(conn):
assert conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0] == 0
# file: main.py
import pytest
pytest.main(["-q", "--tb=short", "-p", "no:cacheprovider"])
Output
..... [100%] 5 passed in <time>
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Replacing the database with mocks or an in-memory fake for tests about SQL.
- Tests that depend on data left by other tests (and fail when run alone or in parallel).
- A different database engine in tests than in production when features differ (JSON, locking, collations); use containers for the real engine.
- Slow suites that recreate the whole schema for every test instead of rolling back transactions.
Key points
- Test data-access code against a real database engine.
- Create the schema once; roll back a transaction per test.
- Assert on constraints and failure paths too.
Further reading: Testcontainers for Python · pytest fixture scopes
Practice questions
Why test repositories against a real database?
Answer
SQL errors, constraint behaviour, joins and transactions can only be verified by the database engine; mocks would pass while production fails.
How does per-test transaction rollback keep tests independent and fast?
Answer
Each test runs inside a transaction that's rolled back afterwards, so no data persists between tests and the schema isn't rebuilt each time.
When should tests use a containerised database instead of SQLite?
Answer
When production uses another engine whose features or behaviour (types, JSON, locking, SQL dialect) differ from SQLite.
Open in the app to mark lessons complete, take notes and track your progress.