Tutorial 3: Write a Useful Integration Test
- Contributor
- Apr 19
- 3 min read
Unit tests with mocks can pass while production fails. Integration tests catch the bugs that live at the boundaries — your code meeting the database, the queue, another service. This tutorial walks through writing one.
What You'll Build
An integration test that exercises your code with a real database (in a test container), verifying actual data flow.
Step 1: Pick the Right Function (5 min)
Good candidates:
A function that writes to and reads from the database
A function that queries multiple tables
A function with transaction logic
Bad candidates:
Pure logic (covered by unit tests)
Code that hits a real external API (use a contract test)
For this tutorial: a create_user function that writes to a users table.
Step 2: Set Up a Test Database (15 min)
Don't use the dev database. Use a fresh test database.
Options:
Testcontainers — spin up a real DB container per test run
Docker Compose — pre-built test environment
SQLite for tests — only if your production DB behaves similarly
Schema-isolated test DB — schema reset between tests
Testcontainers is the cleanest. Sample for Python with Postgres:
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def db_url():
with PostgresContainer("postgres:15") as postgres:
yield postgres.get_connection_url()
The test database is real Postgres. Tests run against the same engine as production.
Step 3: Write the Test Setup (10 min)
Each test should start clean.
import pytest
from sqlalchemy import create_engine
from yourapp.db import Base, User
from yourapp.services import create_user
@pytest.fixture
def db_session(db_url):
engine = create_engine(db_url)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.rollback()
session.close()
Base.metadata.drop_all(engine)
The fixture creates tables, hands a session to the test, then cleans up.
Step 4: Write the Test (10 min)
def test_create_user_persists_to_database(db_session):
# Arrange — no fixtures needed; database is clean
# Act
user = create_user(
db_session,
name="Sam Example",
email="sam@example.com"
)
# Assert — verify by querying the DB
fetched = db_session.query(User).filter_by(id=user.id).first()
assert fetched is not None
assert fetched.name == "Sam Example"
assert fetched.email == "sam@example.com"
assert fetched.created_at is not None
Notice: verification by re-querying, not by trusting the return value.
Step 5: Run It (1 min)
pytest tests/test_create_user.py
First run will be slow (container spin-up). Subsequent runs within the same session reuse the container.
Step 6: Add Edge Cases (15 min)
What if email is duplicate?
def test_create_user_rejects_duplicate_email(db_session):
create_user(db_session, name="First", email="dup@example.com")
with pytest.raises(IntegrityError):
create_user(db_session, name="Second", email="dup@example.com")
What if email is malformed?
def test_create_user_validates_email_format(db_session):
with pytest.raises(ValueError, match="invalid email"):
create_user(db_session, name="Sam", email="not-an-email")
These tests catch issues that unit tests with mocks would miss.
Step 7: Watch for Test Isolation Issues (5 min)
If test_a leaves data that test_b depends on, tests pass when run together and fail when run alone. Or vice versa.
Test the isolation:
pytest tests/test_create_user.py::test_create_user_rejects_duplicate_email
pytest tests/test_create_user.py::test_create_user_persists_to_database
Each should pass alone.
If they don't, your fixtures aren't cleaning up properly. Fix the fixture, not the test.
Step 8: Measure the Speed (5 min)
Integration tests are slower than unit tests. Target:
Single test: 50-500ms
Full suite: under 5 minutes
If you're way over, profile. Common culprits:
Container spin-up per test (use session-scoped fixture)
Full schema recreation per test (use transactions instead)
Test data not cleaned, accumulating
What You Just Did
You wrote a test that exercises the real database. It catches:
SQL issues (typos, missing columns)
Schema mismatches
Constraint violations
Transaction-related bugs
These bugs are silent in unit-tested code. Integration tests surface them.
Common Failure Modes
SQLite for tests, Postgres in production. Different SQL dialects; tests pass, production fails.
Shared state between tests. Order-dependent failures.
Container-per-test slowness. Use session-scoped containers; transaction-scoped reset.
Mocking the database in integration tests. Defeats the purpose. Use the real engine.
No edge case coverage. Happy path only. Most integration bugs live in edges.
Next Tutorial
Test data is becoming complex. Build a factory: Tutorial 4: Build a Test Data Factory.
Related reading
Keep learning. This article is part of the Test Automation path in the ShiftQuality Learning Center. Build test automation that lasts, with ROI you can defend.


