Python Database Testing Anti-Patterns: Mocking vs Real DB, Fixture Isolation, and Test Speed
Most Python database test suites share the same set of mistakes. Tests that mock Session.query(). Fixtures that share state between tests. Integration tests that take 20 minutes because developers tried to be too thorough. This guide identifies the most damaging anti-patterns and shows how to replace them with patterns that actually catch bugs.
Anti-Pattern 1: Mocking the Database
The most common mistake in Python database testing is mocking SQLAlchemy or asyncpg at the session or connection level:
# WRONG: This test proves nothing
@patch("myapp.database.db_session")
def test_get_user(mock_session):
mock_session.query.return_value.filter.return_value.first.return_value = User(
id=1, email="test@example.com"
)
user = get_user(1)
assert user.email == "test@example.com"This test passes regardless of whether get_user has a bug. It will pass even if you completely rewrite the query to use different filters—the mock returns whatever you programmed it to return. You are testing your mock, not your code.
Why mocking fails for database code:
- Query builder chains are complex.
mock.query.return_value.filter.return_value.first.return_valuegets the return value when the exact chain is called—if your code uses.filter_by()instead of.filter(), the mock returns a different Mock object, not your data - SQL-level errors (constraint violations, deadlocks, type mismatches) never appear in mocked tests
- ORM relationship loading behavior can't be tested when sessions are mocked
- Query count regressions (N+1) are invisible
The fix: Use in-memory SQLite or Testcontainers with transactional rollback for speed. A real database test that runs in 50ms is better than a mock test that runs in 5ms but proves nothing.
Anti-Pattern 2: Not Isolating Tests from Each Other
The second most damaging pattern is test contamination—tests that depend on database state left by previous tests:
# WRONG: Tests share a session and commit to it
class TestUserRepository:
def setup_method(self):
self.session = Session() # Same session across all tests
def test_create_user(self):
user = User(email="a@test.com")
self.session.add(user)
self.session.commit() # State persists after test
def test_list_users(self):
# This test passes only if test_create_user ran first
users = self.session.query(User).all()
assert len(users) == 1 # Breaks if test order changesThis creates order-dependent tests that fail non-deterministically when pytest randomizes execution order (pytest-randomly) or when tests are parallelized.
The fix: Every test runs in its own transaction that rolls back after completion:
@pytest.fixture
def db_session(engine):
connection = engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()Each test gets a pristine database. Commits inside the test are rolled back when the fixture tears down.
Anti-Pattern 3: Using the Production Database for Tests
# WRONG: Test suite targets production
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://user:pass@prod-db:5432/app")This belongs in the "obvious but still happens" category. Developers run tests locally against their dev database, which is fine—until CI is configured with the production connection string "just for now." Test data pollution and accidental truncates have ended on-call shifts.
The fix: Enforce a separate test database at the configuration level:
# conftest.py
import pytest
@pytest.fixture(scope="session", autouse=True)
def enforce_test_database():
db_url = os.getenv("DATABASE_URL", "")
assert "test" in db_url or "localhost" in db_url or ":memory:" in db_url, \
f"Tests must use a test database, got: {db_url}"This fixture fails the entire test run if the database URL doesn't contain "test" or "localhost". CI can override this for integration environments with an explicit TEST_DATABASE_URL variable.
Anti-Pattern 4: Over-Seeding with Too Much Data
Loading your full production seed dataset for every test:
# WRONG: 10,000 rows of fixtures for a test that checks one user
@pytest.fixture(autouse=True)
def load_fixtures(db_session):
with open("fixtures/full_seed.json") as f:
data = json.load(f)
for row in data:
db_session.add(User(**row))
db_session.commit()This makes tests slow (10,000 rows × 500 tests = enormous overhead) and creates invisible dependencies. A test that queries User.objects.first() may accidentally depend on which user is first in the fixture file.
The fix: Create only the data each test needs, directly in the test or in a minimal fixture:
def test_user_can_update_email(db_session):
user = User(email="old@example.com", name="Test")
db_session.add(user)
db_session.flush()
update_email(user.id, "new@example.com", session=db_session)
db_session.refresh(user)
assert user.email == "new@example.com"The test creates exactly one user and tests exactly one behavior. No fixture file, no mystery dependencies.
Anti-Pattern 5: Testing at the Wrong Layer
# WRONG: Testing SQL logic via the UI
def test_user_count_displayed_on_dashboard(browser):
browser.get("/admin/dashboard")
count = browser.find_element_by_id("user-count").text
assert count == "1,247" # This number changes every dayThis test:
- Fails every time a new user registers
- Takes 10 seconds to run
- Tests three unrelated things (rendering, routing, and database query)
The fix: Test database logic at the repository layer, not through the browser. Reserve end-to-end tests for user flows, not data counts.
# RIGHT: Test the query logic directly
def test_user_count_query(db_session):
User.objects.bulk_create([User(email=f"u{i}@t.com") for i in range(10)])
assert User.objects.count() == 10
# RIGHT: Test the API endpoint returns a count field
def test_dashboard_api_returns_user_count(client, db_session):
User.objects.create(email="t@t.com")
response = client.get("/api/dashboard/stats/")
assert "user_count" in response.json()
assert response.json()["user_count"] >= 1Anti-Pattern 6: Not Testing Constraint Violations
Most test suites test the happy path and ignore database constraints entirely:
# WRONG: No tests for what happens when constraints are violated
def test_create_user_happy_path(db_session):
user = UserFactory()
assert user.email is not None
# 0 tests for unique constraint, NOT NULL, foreign key, check constraintsConstraint violations are the second-most-common source of production errors in database-backed applications (after N+1 queries). Test them explicitly:
def test_duplicate_email_raises_integrity_error(db_session):
db_session.add(User(email="dupe@test.com", name="Original"))
db_session.flush()
db_session.add(User(email="dupe@test.com", name="Duplicate"))
with pytest.raises(IntegrityError):
db_session.flush()
def test_null_name_raises_integrity_error(db_session):
db_session.add(User(email="null@test.com", name=None))
with pytest.raises(IntegrityError):
db_session.flush()
def test_invalid_user_id_raises_foreign_key_error(db_session):
with pytest.raises(IntegrityError):
db_session.add(Order(user_id=99999, total=100))
db_session.flush()Anti-Pattern 7: Ignoring Test Speed Until It's Too Late
Many teams start with real database tests, watch the suite slow to 20 minutes, then swing to mocking everything. The right approach is staying fast from the beginning.
Speed strategies that don't sacrifice coverage:
| Strategy | Speedup | Trade-off |
|---|---|---|
| SQLite in-memory for unit tests | 10-50× | No PostgreSQL-specific SQL |
| Testcontainers with volume caching | 3-5× | Container startup overhead |
| Transactional rollback (no recreate) | 5-10× | Can't test schema changes |
| pytest-xdist for parallel execution | N× (N cores) | Shared database needs care |
bulk_create instead of individual saves |
5-20× for seeding | Less granular error messages |
Benchmark your fixture creation. If conftest.py takes 30 seconds before a single test runs, you have a seeding problem—not a database problem.
Anti-Pattern 8: Not Testing Migrations
Migrations break in ways that unit tests don't catch:
- Column renames break queries that reference the old name
ALTER TABLE ADD COLUMN NOT NULLwithout a default fails on non-empty tables- Index creation on large tables times out
- Migration reversal (
downgrade) is never tested
The fix: Include a migration test in CI:
def test_migrations_run_cleanly(tmp_path):
"""Verify all migrations apply and reverse cleanly."""
from alembic.config import Config
from alembic import command
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", "sqlite:///test_migration.db")
# Apply all migrations
command.upgrade(alembic_cfg, "head")
# Reverse all migrations
command.downgrade(alembic_cfg, "base")
# Apply again (double-check idempotency)
command.upgrade(alembic_cfg, "head")Balancing Speed and Coverage
The right ratio for a mature Python application:
- Unit tests (mocked external calls, real database, in-memory SQLite): 70% of tests, run in seconds
- Integration tests (real PostgreSQL via Testcontainers, all services): 25% of tests, run in minutes
- End-to-end tests (browser automation, full stack): 5% of tests, run in HelpMeTest on schedule
The mistake is making unit tests slower to add "more coverage." Coverage comes from writing more tests at the right layer—not from adding a Testcontainers PostgreSQL instance to every test function.
Summary
Avoid these database testing mistakes:
- Mocking sessions — proves nothing, miss real SQL bugs
- Shared state between tests — creates non-deterministic failures
- Production database — enforce test DB in configuration
- Over-seeding fixtures — create only the data each test needs
- Testing at the wrong layer — database logic at repo layer, not UI
- Ignoring constraints — test unique, NOT NULL, and FK violations explicitly
- Slow suites — use in-memory SQLite + transactional rollback for speed
- Untested migrations — run upgrade + downgrade in CI
Each of these anti-patterns has cost real teams real production incidents. The patterns above prevent them.