Module 7: Test Data And Isolation In Integration

6. Seeding integration data

Description

Up to here each test started from an empty database, and that was a bit of a fiction: in real life, many integrations need to start from a populated state. "Given that the Focus room already has two bookings, when I query find_by_room('focus'), then it returns those two." That "given there are already two bookings" is the initial state, and putting it before the test is called seeding. Seeding well is what separates a readable integration suite from a guessing game. And the principle is a single one, but hard to respect: each test's initial state must be explicit, minimal, and said out loud inside the test. Explicit: not inherited from another test or from a mysterious file, but written where you can see it. Minimal: only the bookings the test really needs, not one more. And said out loud: even when the initial state is "nothing", the test should say "I start empty", so whoever reads it doesn't have to guess what it starts from.

The underlying reason is the one you already know from isolation, seen from the other side. A test that assumes a state it didn't seed is trusting that someone else left it —the neighbor, a wide-scope fixture, the previous run—, and that trust is exactly lesson 2's contamination. Seeding the initial state inside the test (or in a fixture that runs per test) breaks that dependency: the test brings its own world, it doesn't inherit it. That's why seeding and isolating are two faces of the same thing: isolation guarantees the test starts in a clean and known state; seeding decides which that known state is. You're going to learn to seed in a way that makes the test read on its own, to say the empty seed out loud, and to see with real output a suite where each test declares its own starting point.

Connection to the module: lessons 3 to 5 gave you the resource isolation —a clean database per test—; this one puts the right data inside that clean database. It's the step that turns "each test starts empty" into "each test starts in the state it needs, and says so". Lesson 7 will use this seeded data to demonstrate that the suite is independent and repeatable in any order. And here is the module's most important boundary: when the test data gets complex —many fields, many variants, sensible defaults with pointed overrides—, the pattern that solves it elegantly is the Builder, and that's the topic of module 7 of test-doubles-and-test-data-guide, not this one. Here we seed minimal and explicit, with simple functions; when the seeding calls for a real factory, we link you there. This guide's focus is the isolation of the real resource, not the data factory.

Analogy: setting up the scene before filming

Think of how a movie scene is filmed. Before the director says "action", the art team sets up the scene: it puts the half-full coffee cup on the table, the newspaper open to the exact page, the chair slightly pulled out. That setup is deliberate and minimal: only what the scene needs to tell what it wants to tell. They don't leave yesterday's shoot's leftovers on the table —that would show up in frame and confuse—, nor do they fill the table with objects that don't matter. Each element they place is there for a reason, and that reason is visible: if in the scene the character is going to read a news story, the newspaper is open to that story, in plain sight of everyone on the set.

Seeding integration data is setting up the scene before filming the test. You put on the database the bookings the test needs —the two in Focus if the test verifies that find_by_room returns two—, minimal and deliberate, and you put them in plain sight, inside the test or in a clear fixture, not inherited from the previous shoot. A test that starts on data it didn't seed is like a scene filmed on the dirty set from yesterday's shoot: what shows up in frame could be anything, and nobody knows why. A well-seeded test is a scene set up with intention: each initial datum is there for a reason you can read. And when the scene needs nothing on the table, the set dresser confirms it —"empty table"—, they don't leave it to chance.

Seeding explicit and minimal

Let's write a suite where each test declares its initial state. We use lesson 4's fresh-database fixture and a simple seed function that saves the bookings we pass it.

# tests/test_seeding.py — seed a KNOWN, minimal, explicit state, per test
import sqlite3
import pytest
from datetime import datetime
from reservo.models import Booking
from reservo.sqlite_repo import SqliteBookingRepository

def booking(bid, room_id, hour):
    return Booking(id=bid, room_id=room_id, member_id="m-ana",
                   start=datetime(2026, 3, 10, hour),
                   end=datetime(2026, 3, 10, hour + 3),
                   status="confirmed", price_cents=6000)

def seed(repo, *bookings):
    for b in bookings:
        repo.save(b)
    return repo

@pytest.fixture
def repo():
    conn = sqlite3.connect(":memory:")     # fresh DB per test (lesson 4)
    yield SqliteBookingRepository(conn)
    conn.close()

def test_find_by_room_returns_only_focus(repo):
    # Explicit initial state: two in focus, one in studio.
    seed(repo,
         booking("bk-1", "focus", 9),
         booking("bk-2", "focus", 12),
         booking("bk-3", "studio", 9))
    focus = repo.find_by_room("focus")
    assert len(focus) == 2
    assert {b.id for b in focus} == {"bk-1", "bk-2"}

def test_empty_room_returns_nothing(repo):
    # The minimal seed for THIS test is: nothing. And it's said out loud.
    seed(repo)                       # no rows
    assert repo.find_by_room("boardroom") == []

Look at the two tests. The first sets up its scene: it seeds three bookings —two in Focus, one in Studio— and then verifies that find_by_room("focus") returns exactly the two Focus ones, by id. Anyone reading the test sees, in its own lines, what state it starts from: no need to search a distant fixture or guess what another test left. The Studio booking is seeded on purpose, to prove the filter does not include it —it's the object that must not show up in frame—. The second test seeds nothing, and says so: seed(repo) with the empty list and a comment declaring "the minimal seed for this test is: nothing". It could have omitted the line —the database is already empty from the fixture—, but writing it makes it explicit that the empty initial state is a decision, not an oversight.

What to expect. On my machine (Python 3.14.0, pytest 9.1.1):

python3 -m pytest tests/test_seeding.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items

tests/test_seeding.py::test_find_by_room_returns_only_focus PASSED         [ 50%]
tests/test_seeding.py::test_empty_room_returns_nothing PASSED              [100%]

============================== 2 passed in 0.01s ===============================

Both green, and —this is the important part— each one readable on its own: if tomorrow test_find_by_room_returns_only_focus failed, you wouldn't have to reconstruct where its data came from; it's right there, in the three-line seed. That readability isn't a luxury: it's what makes an integration failure diagnosable instead of an archaeological mystery.

Why the seed goes near the test, not far

A natural temptation, as soon as two tests share an initial state, is to move the seed to a shared fixture "so as not to repeat". Sometimes it's fine; sometimes it ruins readability. The rule is about how far from the test the seed can live without the test becoming unreadable.

A seed that is a test's specific initial state —"this test needs exactly these two Focus bookings"— lives best inside the test, because it's part of what the test asserts. If you hide it in a fixture, the reader sees the assertion len(focus) == 2 without seeing where the two come from, and has to go hunt for the fixture. The test stops reading on its own.

A seed that is a common and stable base state —reference data that almost all tests need the same, like the room catalog— can live in a fixture, because it's not what any test in particular is testing; it's the shared scenery. The distinction is the same as movie set dressing: the set's fixed furniture (the base scenery) is placed by the crew once; the objects that change from scene to scene (the specific state) are set up per scene. Applying this distinction wrong —hiding in a fixture the datum the test is testing— is the number-one cause of integration tests that "work but nobody understands".

And there's a concrete danger with wide-scope seed fixtures, which connects back to lesson 2: if you put the seed in a module-scope fixture with writes, all the tests share those rows and one's modifications contaminate another. A shared seed is only safe if it's read-only —the tests read that base state but don't alter it— or if it lives in function scope (it's re-seeded fresh per test). Seeding doesn't exempt you from isolation; it leans on it.

The boundary: where the Builder begins

Notice the booking(bid, room_id, hour) function in the example: it fills member_id, start, end, status, and price_cents with reasonable values, and lets you vary only the id, the room, and the hour. It's a minimal helper, enough to seed similar bookings without repeating seven fields each time. And it's, exactly, the point where a bigger pattern emerges.

When the test data grows —you want a cancelled pro booking of three hours, another basic confirmed one of one hour, another with an atypical price— and you start to want "a normal booking, but with the cancelled status" or "a normal booking, but in Studio", that booking(...) with positional parameters falls short and gets awkward. What solves that problem elegantly —sensible defaults, readable pointed overrides, chaining— is the Builder pattern (and its cousin, the Object Mother). Building complex test data with Builders is a topic in its own right, and it's not from this guide: it's module 7 of test-doubles-and-test-data-guide.

The boundary, said precisely: in this guide, the focus is the isolation of the real resource —that the database be clean and the state be known— and seeding is the means to put a known state, solved with minimal functions like seed and booking. When the data factory starts to call for its own design —many variants, defaults with overrides, readability of "a normal X except Y"—, that's the Builder, and the place to learn it thoroughly is the doubles guide. Here we stop at the minimal helper, on purpose: crossing that boundary would be rewriting a module that already exists. If your seeding is getting heavy, that awkwardness is the sign that it's time for the sister guide's Builder.

Common mistakes

Seeding too much "in case the test needs it". What happens: ten bookings are loaded when the test only verifies two. Why it happens: it seems safer to have data to spare. How to detect it: if the reader can't tell which of the seeded rows matter for the assertion, you seeded too much. How to fix it: seed the minimum that makes the assertion true, plus the few deliberate trap-data (like the Studio booking that must not show up in the filter). Every seeded row should have a visible reason.

Hiding in a fixture the datum the test is testing. What happens: a test's specific initial state is moved to a shared fixture, and the test is left with assertions about numbers that aren't seen coming. Why it happens: it's a bid to avoid repetition. How to detect it: if, reading the test, you have to open another part of the file to understand where the data comes from, the fixture hid what should be in plain sight. How to fix it: the state the test asserts lives in the test; only the common and stable base scenery goes to a fixture, and read-only.

Seeding in a wide-scope fixture with writes. What happens: the seed goes to a module-scope fixture, and the tests that modify those rows contaminate each other. Why it happens: you want to seed once to go faster. How to detect it: if order-dependent failures appear after sharing the seed, the tests are writing over shared data. How to fix it: a shared seed is only safe read-only; what the tests modify is seeded fresh per test (function scope). Seeding doesn't replace isolation.

Exercises

Exercise 1 — Inside the test or in a fixture? For each seed, decide whether you'd put it inside the test or in a shared fixture, and why: (a) the two Focus bookings a specific test verifies counting; (b) the catalog of the three rooms (Focus, Studio, Boardroom) that almost all tests use the same and none modifies; (c) a cancelled booking with a specific refund that a single test needs to test cancel.

See solution
  • (a) Inside the test. It's the state that test is testing —it counts those two—, so it must be in plain sight, in the test's lines, so the assertion len == 2 reads with its origin. Hiding it in a fixture would make the test unreadable.
  • (b) In a shared fixture, read-only. It's common and stable base scenery that no test in particular tests and none modifies; it can live in a fixture (even a wide-scope one, being read-only) to avoid repeating it. It's not what any test asserts; it's the set's fixed decor.
  • (c) Inside the test (or in a function-scope fixture specific to that test). It's specific to a single test and it's exactly what that test tests —the booking that's going to be cancelled—, so it goes in plain sight. Since only one uses it, there's nothing to share; and if it goes to a fixture, let it be function scope so it's re-seeded fresh.

The criterion: the datum the test asserts goes inside the test; the common, stable, read-only base scenery can go to a fixture. The guiding question is "does the reader need to see this datum to understand the assertion?": if yes, inside the test.

Exercise 2 — The empty seed said out loud. test_empty_room_returns_nothing includes seed(repo) with an empty list, even though the database is already empty from the fixture. A colleague wants to delete that line "because it does nothing". Give two reasons to keep it.

See solution

Two reasons to keep the empty seed(repo), even though it technically doesn't change the state:

  1. It declares a decision, not an oversight. With the line present, it's clear that the empty initial state is intentional —the test deliberately tests the "room with no bookings" case—, not that someone forgot to seed. Without the line, a future reader doesn't know whether the emptiness is deliberate or a gap; they might "fix it" by adding data and change what the test wanted to test. The explicit line protects the intent.

  2. It makes the suite's reading uniform. If all the tests start with a seed(...) line that declares their initial state —whether it has data or is empty—, the suite reads with a consistent pattern: "first the seed, then the action, then the assertion". A test that skips the seed breaks that rhythm and forces the reader to wonder whether something is missing. The consistency makes the suite easier to read and maintain.

In short: the line doesn't change the state, but it changes the communication. In tests, saying out loud "I start empty" is worth more than saving a line, because the initial state is part of what the test asserts and deserves to be visible.

Exercise 3 — Minimal function or Builder? Your booking(bid, room_id, hour) function fixes member_id, status, and price_cents. A requirement arrives: you need to seed bookings varying also the member's tier, the status (confirmed or cancelled), and the price_cents, in many different combinations, and you want to be able to write "a normal booking except that it's cancelled". Do you keep stretching booking(...) or cross over to another tool? Justify and name the tool and where it's studied.

See solution

I don't keep stretching booking(...). Adding more positional parameters (booking(bid, room_id, hour, tier, status, price_cents, ...)) makes it unreadable —a call with six or seven positional arguments where nobody remembers which is which— and doesn't solve what I really want: expressing "a booking normal except one thing" without repeating all the fields. That's exactly the problem the Builder pattern (and the Object Mother) solves: sensible defaults for all the fields, and pointed, readable overrides for the one or two that change —something like a_booking().cancelled().with_price_cents(3000).build()—.

Building complex test data with Builders is a topic of its own and it's not from this guide: it's studied thoroughly in module 7 of test-doubles-and-test-data-guide. The awkwardness I felt on wanting to stretch booking(...) is precisely the sign that I crossed the boundary: I went from "minimal seeding to isolate a resource" (this guide) to the "test data factory" (the doubles guide). This module's focus is the isolation of the real resource; when the construction of the data becomes the problem, the tool and its place of study are in the sister guide.

Summary and next step

In this lesson you put the right data inside the clean database. You seeded explicit, minimal, said-out-loud initial states: the test that sets up its scene with three bookings and verifies that the filter returns only the two correct ones, and the one that declares its empty seed to make clear the initial state is a decision. You saw why the datum the test asserts lives inside the test —so it reads on its own— and only the common, read-only base scenery goes to a fixture, and why a shared seed with writes reintroduces lesson 2's contamination. And you marked the module's key boundary: when data construction gets complex —variants, defaults with overrides, "a normal X except Y"—, the tool is the Builder pattern, studied in test-doubles-and-test-data-guide, not here; in this guide the focus is the isolation of the real resource.

Before moving on you should be able to: seed a minimal, explicit initial state inside a test; decide which seed goes in the test and which in a read-only fixture; and recognize when the seeding calls for crossing to the Builder's boundary.

What comes next is naming and demonstrating the two principles this whole module was pursuing. In lesson 7 you're going to close with independence and repeatability: each test starts from a known state (independent of its neighbors) and gives the same result always (repeatable, whenever you run it). You're going to see, with real output, the isolated suite passing in normal order, in reversed order, and run twice —the same green always—, and understand why the execution order should never change a verdict. It's the criterion by which you'll judge whether an integration suite is well made, and the antechamber of the mini-project.

Resources