ENGINEERING NOTE
Replacing Mock-Heavy Tests with Database-Backed pytest Fixtures
A guide to moving persistence and authorization tests toward small database-backed fixtures while retaining fast unit tests at genuinely isolated boundaries.
On this page
Mocks are useful when a test needs to isolate a calculation, force a rare failure, or replace a slow external system. They become risky when they stand in for the database behavior that the code is supposed to integrate with. A mock can accept an impossible query, return an object in a state the ORM would never produce, or confirm that one method was called without proving that the resulting record is correct.
Database-backed pytest fixtures provide a different kind of confidence. They arrange a small, valid graph of records, run application code through the real persistence layer, and assert observable state. The aim is not to turn every test into an end-to-end test. It is to use the database where persistence semantics, authorization relationships, transactions, or query behavior are part of the contract, while keeping pure logic tests fast and isolated.
This article describes a generalized pattern. Names and examples are illustrative and do not reproduce a private schema.
Recognize when a mock is testing an invention
A mock-heavy test often begins reasonably: replace the repository, return one object, and assert that a service calls save. Over time the service gains filters, ownership rules, uniqueness constraints, and transactional behavior. The mock grows a custom simulation of the ORM, but that simulation is maintained separately from the actual schema.
Warning signs include:
query methods chained on generic mock objects;
fixtures built with attributes that bypass model validation;
assertions about calls instead of durable state;
authorization tests where user, role, and resource relationships are all mocks;
transaction tests that cannot roll back or violate a constraint; and
repeated mock setup that approximates the same record graph differently.
These tests can remain green while a migration changes a column, a default behaves differently, or a filter excludes the wrong tenant. The problem is not that mocks are inherently weak. It is that the test double no longer represents a stable, owned boundary.
Choose the right boundary for each test
A practical suite has more than one testing style. Pure functions, formatting rules, and branching calculations normally need no database. A service coordinating a remote API can use a small protocol-shaped fake. Persistence adapters, repository queries, and permission checks usually deserve the real test database.
Classify the behavior before rewriting a test:
| Behavior under test | Preferred dependency | Main assertion |
|---|---|---|
| Pure transformation | Plain values | Returned value |
| Remote service policy | Protocol fake or mock | Request and mapped outcome |
| Repository query | Test database | Selected records and ordering |
| Transactional command | Test database | Committed or rolled-back state |
| Relationship authorization | Test database | Allowed and denied outcomes |
This avoids two extremes: mocking everything and sending every assertion through HTTP. The smallest test that includes the behavior’s real boundary is usually the most maintainable.
Build fixtures around valid domain states
Good fixtures express meaning rather than table mechanics. A test should ask for an active account with an editor and two resources, not repeat twenty model constructors whose irrelevant fields obscure the scenario.
Factory fixtures can return callables so each test creates only what it needs. The following example is simplified and uses generic model names:
import pytest
@pytest.fixture
def make_workspace(db_session):
created = 0
def factory(*, name=None, owner=None):
nonlocal created
created += 1
workspace = Workspace(
name=name or f"workspace-{created}",
owner=owner or UserFactory(),
status="active",
)
db_session.add(workspace)
db_session.flush()
return workspace
return factory
The factory supplies safe defaults but leaves important differences explicit. It flushes when a generated identifier is needed without committing the whole test. A surrounding transaction fixture can roll everything back after the test.
Avoid one enormous fixture that creates the entire product model. Large shared graphs make failures difficult to understand and slow every test, even when only one relationship matters. Compose smaller fixtures: a user, a role assignment, a resource, and an optional historical record. The test body then reads like the state being proved.
Defaults also need restraint. If a permission depends on membership, do not add membership invisibly to every user fixture. Hidden convenience can make a denied test impossible to arrange. Defaults should satisfy structural validity, not grant business capabilities the test did not request.
Isolate tests with transactions
Test isolation must be deterministic. A common strategy begins a transaction for each test, binds the application session to it, and rolls it back afterward. This is normally faster than rebuilding the database and prevents one test’s records from affecting another.
Applications that commit inside a service need additional handling. A nested transaction or savepoint can allow application commits while preserving an outer rollback owned by the test harness. The exact mechanism depends on the framework and database driver, so test it deliberately. A fixture that appears to roll back but leaks committed rows will create order-dependent failures.
Schema creation should happen outside individual tests. Apply migrations to a dedicated test database once per session or use a prepared template that matches the production migration path. Creating tables directly from current models is faster but can hide a broken migration. The right balance depends on suite size, yet migration correctness should be covered somewhere explicit.
Parallel execution introduces another boundary. Workers need separate schemas, databases, or another proven isolation mechanism. Randomly sharing one database and trusting transaction timing is not isolation.
Test authorization with real relationships
Authorization decisions frequently combine identity, membership, role, resource ownership, and state. Replacing each with a mock verifies only the branch structure imagined by the test. Database-backed fixtures can prove that the query used to locate an authorized resource matches the stored relationship.
Build paired cases from the same arrangement: one user with the required relationship and one without it. Assert both allowed and denied outcomes. Add a record in a different workspace or scope so an accidentally broad query has something dangerous to return. Empty-database tests cannot reveal cross-scope leakage.
Security tests should assert the public outcome rather than internal helper calls. Depending on the layer, that may be a not-found result that avoids disclosing existence, a forbidden result, or no database mutation. Preserve whichever semantics the application contract defines.
Keep tokens and authentication cryptography outside a repository test unless they are the subject. It is often enough to pass a verified identity object into the authorization boundary. Conversely, an API integration test should include the real authentication middleware so the handoff is covered.
Migrate incrementally instead of rewriting the suite
Replacing every mock at once creates a long branch and makes failures hard to attribute. Start where false confidence costs the most: authorization queries, transactional writes, constraint handling, and repositories with complex filters.
For each selected test:
State the behavior in terms of inputs and observable outcomes.
Remove assertions that expose implementation calls without contractual value.
Arrange the smallest valid record graph through reusable factories.
Run through the real repository or persistence adapter.
Assert returned records, stored values, and important absence.
Keep a mock only for a genuine external boundary.
The rewritten test may reveal production code that is difficult to call without a framework-global session. That is useful feedback. Passing an explicit unit-of-work or repository dependency can improve both testability and transaction ownership without introducing a mock-only architecture.
Failure modes in database-backed suites
Using a real database does not automatically produce good tests. Common problems include:
fixtures with so much implicit state that the scenario is unreadable;
committing data unnecessarily and defeating rollback isolation;
tests that depend on auto-incremented values or execution order;
direct table cleanup that misses newly added relationships;
using a different database engine whose constraints or query behavior differ materially;
asserting ORM object identity instead of public values;
broad fixtures that make the suite slow enough to discourage local use; and
seed data that hides missing arrangement in individual tests.
Measure suite duration and query counts for representative groups. Slow integration tests can be partitioned without being abandoned. Keep pure units in a fast default loop, run database-backed repository and service tests in a focused layer, and retain a smaller browser or API layer for complete flows.
Validate both correctness and query shape
The first assertion is behavioral: the correct records are returned or changed. For critical list operations, also guard ordering and scope. A query that returns the right set in a tiny fixture may become unstable when two records share a timestamp, so include tie cases and require a deterministic secondary sort.
Query-count assertions can catch N-plus-one regressions, but use them selectively. An exact count on every test becomes brittle across harmless ORM changes. Apply a budget to endpoints or repository methods where repeated relationship loading would materially change cost.
Constraint tests are especially valuable. Attempt duplicate or invalid state through the same application boundary and verify the translated public error plus the unchanged database. This proves that exception handling and rollback work together.
A fixture migration checklist
Before merging a rewritten group, confirm:
Each test names an observable behavior rather than a call sequence.
The database is included only where persistence semantics matter.
Factories create the smallest valid state and expose important choices.
Allowed and denied authorization cases use real relationships.
Cross-scope records exist in tests that protect isolation.
Transactions or savepoints reliably clean up, including application commits.
Parallel workers cannot see one another’s records.
The test engine and schema path are representative enough for the behavior.
External systems remain behind small protocol-shaped doubles.
Failure assertions verify rollback and absence, not only an exception type.
Suite duration stays visible and the fast unit loop remains fast.
Conclusion
Mock-heavy persistence tests often verify a private story about how code ought to call the database. Database-backed pytest fixtures verify the state and relationships the application actually uses. The strongest suite does not choose one technique everywhere. It keeps pure logic isolated, models external boundaries with small doubles, and includes the real database when queries, constraints, transactions, or authorization are the behavior.
That shift makes tests less coupled to implementation choreography and more sensitive to failures users and operators would care about. It also gives refactoring a clearer safety net: internal calls may change while the durable contract stays proved.
