Back to all posts
pytestPythonPostgreSQLDatabase fixturesTest dataCI/CD

Database Fixtures in pytest: Seed State, Not setUp/tearDown

Replace fragile INSERT/DELETE fixtures with named, reproducible database scenarios. A practical guide to seeding PostgreSQL state in pytest with Seedmancer.

pytest fixtures are one of the most powerful parts of the Python testing ecosystem. They handle setup and teardown, they compose cleanly, and they make it easy to share state across tests. But when a fixture's job is to populate a PostgreSQL database, the cracks start to show.

The typical pattern — create objects in setUp, delete them in tearDown — does not scale. It is slow, fragile, and produces a different database state every time depending on which tests ran in which order. A cleaner approach is to seed a known state before the test, not build it row-by-row during it.

This guide shows how to integrate Seedmancer into a pytest workflow so every test starts from a predictable PostgreSQL baseline.


The problem with fixture-based database setup

A pytest fixture that creates test data looks like this:

import pytest
import psycopg2

@pytest.fixture
def user(db):
    cursor = db.cursor()
    cursor.execute(
        "INSERT INTO users (email, role) VALUES (%s, %s) RETURNING id",
        ("test@example.com", "admin")
    )
    user_id = cursor.fetchone()[0]
    db.commit()
    yield {"id": user_id, "email": "test@example.com"}
    cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
    db.commit()

This works for one user in one test. It breaks down when:

  • Tests need complex related data (users with orders, orders with items, items with discounts applied in a specific state).
  • Multiple fixtures compose in ways nobody anticipated, leaving the database in a state nobody designed.
  • A test fails before the yield returns, so cleanup never runs.
  • Parallel test workers create the same rows simultaneously, hitting uniqueness constraints.
  • The schema changes and nobody updates the fixture, so the wrong column names are silently ignored or raise an error on line 47 of a deeply nested fixture chain.

The underlying issue is that fixtures build state incrementally. Incremental state is harder to reason about than a declared starting state.


Seeding a baseline with conftest.py

The cleanest integration point for Seedmancer in pytest is a session-scoped fixture in conftest.py. It runs once for the entire test session, restoring the database to the baseline scenario before any test starts:

import subprocess
import pytest

@pytest.fixture(scope="session", autouse=True)
def seed_database():
    subprocess.run(
        ["seedmancer", "seed", "myapp/baseline", "--yes"],
        check=True
    )

autouse=True means this fixture applies to every test without needing to be declared in each test function. scope="session" means it runs once, not before every individual test.

Now every pytest run starts from the same baseline. Tests can read from the database without worrying about what a previous test left behind.


Scenario-scoped fixtures for different states

Not all tests share the same starting state. An API test suite for billing needs a workspace with a pro subscription. An authentication test needs a locked account. A permissions test needs users with different roles.

Define scenario-scoped fixtures alongside the tests that need them:

import subprocess
import pytest

@pytest.fixture(scope="module")
def billing_pro_state():
    subprocess.run(
        ["seedmancer", "seed", "billing/pro", "--yes"],
        check=True
    )

@pytest.fixture(scope="module")
def locked_account_state():
    subprocess.run(
        ["seedmancer", "seed", "auth/locked", "--yes"],
        check=True
    )

In a billing test module:

def test_pro_workspace_can_invite_members(billing_pro_state, client):
    response = client.post("/api/invites", json={"email": "new@example.com"})
    assert response.status_code == 201

def test_free_workspace_cannot_export(billing_pro_state, client):
    # billing/pro scenario has a pro workspace — downgrade it via API and verify
    response = client.get("/api/export")
    assert response.status_code == 200

Each module seeds the scenario it needs. Tests within the module share that state. If a test writes to the database in a way that would affect another test in the same module, move those tests to a separate module with their own fixture.


Per-test seeding for fully isolated tests

For tests that cannot share state, use a function-scoped fixture:

@pytest.fixture(scope="function")
def clean_db():
    subprocess.run(
        ["seedmancer", "seed", "myapp/baseline", "--yes"],
        check=True
    )

def test_create_user(clean_db, client):
    response = client.post("/api/users", json={"email": "new@example.com"})
    assert response.status_code == 201

def test_duplicate_email_rejected(clean_db, client):
    response = client.post("/api/users", json={"email": "existing@example.com"})
    assert response.status_code == 409

The second test relies on the baseline already containing a user with existing@example.com. That row exists because it is part of the scenario, not because the first test created it. Tests are independent even though they reference the same email.

This is safer than delete-on-teardown patterns because the database state is fully reset, not partially cleaned.


Using pytest-subprocess or environment variables for CI

In CI, the database URL comes from an environment variable. Seedmancer reads SEEDMANCER_DATABASE_URL automatically if set, so no code change is needed between local and CI runs:

- name: Install Seedmancer
  run: go install github.com/KazanKK/seedmancer@latest

- name: Run tests
  run: pytest
  env:
    SEEDMANCER_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}

The scenario lives in .seedmancer/ in the repository. No cloud account is needed.


Replacing existing fixtures gradually

You do not need to rewrite all your fixtures at once. The practical approach is:

  1. Export your current database state as a baseline:
seedmancer export myapp/baseline
  1. Add the session-scoped seed_database fixture to conftest.py.
  2. Remove the INSERT + DELETE boilerplate from fixtures one test module at a time, replacing it with references to the scenario data that now exists in the baseline.
  3. Create named scenarios for complex states that are currently built up across multiple nested fixtures.

The goal is not to eliminate fixtures — pytest fixtures are still useful for computing derived values, initialising clients, or providing database connections. The goal is to stop using fixtures to build database state row-by-row, because that state belongs in a snapshot, not in test code.


Summary

| Pattern | When to use it | |---|---| | Session-scoped autouse baseline | All tests share the same starting state | | Module-scoped scenario fixture | A test module needs a specific application state | | Function-scoped clean fixture | Tests mutate data and cannot safely share state |

The common thread is that the starting state is declared and reproducible, not built incrementally. A one-second restore to a named scenario is more predictable than a chain of fixtures that assembles the same state from scratch on every run.

See the CLI documentation for the full environment configuration reference.