Fixtures and Atomic Seeding
newSeededDB from the last lesson seeds a handful of titles inline, one
INSERT at a time, and that's fine for two or three rows. A larger test
suite eventually wants shared fixture data — a realistic batch of tasks
loaded once per test from a slice, not typed out as SQL in every file. The
naive version of that loader is a loop of INSERTs, same as before. It has
a failure mode worth designing around before it bites: what happens when
row 7 of 12 fails?
With a plain loop, rows 1 through 6 are already committed. The fixture function returns an error, the test presumably fails — but the database now has six rows nobody asked for and expected zero, or twelve and got six. Whatever assertion runs next isn't testing the behavior it was written to test; it's tripping over debris from a fixture that half-loaded. That failure is real, but it points at the wrong function, and whoever's debugging it loses time before realizing the actual bug is upstream in the loader, not in the code the test was supposed to be checking.
The fixture loader is a transaction
func SeedTasks(db *sql.DB, titles []string) ([]int64, error) {
tx, err := db.Begin()
if err != nil {
return nil, fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
var ids []int64
for _, title := range titles {
res, err := tx.Exec(`INSERT INTO tasks (title) VALUES (?)`, title)
if err != nil {
return nil, fmt.Errorf("seed %q: %w", title, err)
}
id, err := res.LastInsertId()
if err != nil {
return nil, fmt.Errorf("seed %q: %w", title, err)
}
ids = append(ids, id)
}
return ids, tx.Commit()
}
This is Chapter 5's idiom doing new work: not protecting a multi-table
domain operation, but protecting test infrastructure from itself. If
titles[6] fails — a duplicate against a UNIQUE constraint, say — the
deferred Rollback undoes the six that already succeeded, and the caller
gets back nil ids and an error that actually names which title failed. The
test that asked for twelve fixture rows either gets all twelve or gets a
clear error and zero — never six and a mystery.
Why this belongs in the book, not just the codebase
It would be easy to treat fixture loading as beneath the transactional
discipline you'd apply to "real" application code — it's test-only, it runs
against a throwaway :memory: database, what's the harm? The harm is that a
flaky-looking test failure is one of the most expensive things to debug,
and "the fixture partially loaded" is a classic source of exactly that
flakiness: it depends on which title in the slice happens to collide, which
depends on test data someone else added later. Atomicity isn't only for
production writes; it's for anything where "partly happened" is a worse
state than either extreme.
Try it
Implement SeedTasks. It inserts every title in one transaction, returning
their assigned ids in order — or, on any failure, returns no ids and leaves
the database exactly as it was before the call.
Practice
1. Seed Fixture Data Atomically
IntermediateImplement
SeedTasks(db *sql.DB, titles []string) ([]int64, error).The table already exists:
tasks (id, title UNIQUE).- Insert every title in
titles, in order, inside a single transaction. - On success, return the assigned ids, in the same order as
titles. - If any insert fails (for example, a duplicate title), the whole batch must
roll back — no rows should remain from this call — and return
nilids with a wrapped error naming the title that failed.
package main import ( "database/sql" "fmt" _ "modernc.org/sqlite" ) // SeedTasks inserts every title in one transaction, returning their // assigned ids in order, or rolling back entirely on any failure. func SeedTasks(db *sql.DB, titles []string) ([]int64, error) { // TODO: implement return nil, fmt.Errorf("not implemented") } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.
- Insert every title in