skip to content

When do you roll back a transaction per test instead of truncating and reseeding the database?

level: middleimportance: should knowfreq 57%

answer

  1. Where does the write actually land?
  2. Nothing committed means nothing to clean
  3. Commit-time behaviour is invisible under rollback
  4. Hand-maintained table lists drift from migrations
  5. Neither mechanism isolates two parallel workers

basics

~20 s

Roll back when everything the test touches happens inside one transaction on one connection, because that reset is nearly free. Truncate and reseed when the code under test commits, spans connections, or when rollback would hide commit-time behaviour.

solid answer

~40 s

A rollback-per-test wrapper opens a transaction before the test, runs the test inside it, and unwinds afterwards, so nothing is ever committed and the reset costs almost nothing. It breaks down as soon as the work leaves that transaction: code that commits on its own, a second connection or a background worker, or an assertion about anything that only happens at commit, such as a deferred constraint, a commit-time trigger, or a generated timestamp. Rollback also quietly swallows a bug where the production path would have committed a partial write. Truncate-and-reseed pays a real per-test cost but exercises the same commit path as production. Most large suites mix them: rollback for the wide base of narrow persistence tests, truncate-and-reseed for the smaller set that genuinely crosses a commit boundary.

code

pseudocode · 15 lines
pseudocode
# fast path: nothing is ever committed
aroundEach(test):
    tx = db.beginTransaction()
    try:
        test.runInside(tx)
    finally:
        tx.rollback()

# honest path: derive the reset from the live schema, never a hand list
KEEP = ["reference_tier", "reference_reason", "migration_history"]

beforeEach:
    tables = db.listTables() - KEEP
    db.emptyAll(tables, restartCounters = true)
    db.insertReferenceRows()

go deeper

for a junior

Know what each option does: a rollback wrapper undoes a test's writes automatically, while truncate-and-reseed empties the tables and puts the reference rows back before the next test starts.

for a middle

Explain the mechanics — the test and the code must share one transaction and connection, what commit-time behaviour a rollback hides, and why emptying tables needs an order, a counter decision and a reseed step.

for a senior

Show judgement about the mixed strategy: which tests must cross a real commit, how you stop the reset list drifting away from migrations, and what the per-test reset costs across a long suite.

for a principal

Own the trade between suite wall-clock time and fidelity, and decide where the guarantee lives: one shared harness every team inherits, versus per-team resets that quietly diverge and rot.

### What rollback-per-test actually requires The pattern is simple to state: before each test the harness opens a transaction, the test runs, and afterwards the harness unwinds it. Nothing is committed, so there is nothing to clean up and the reset costs roughly what an unwind costs, typically a millisecond or two. The requirement hidden inside it is that **the test and the code under test share the same transaction and the same connection**. If the code checks out a second connection from a pool, its writes land outside the wrapper and survive the unwind. If it schedules work on a background worker, that worker cannot see the uncommitted rows and will behave as though the setup never happened. If a parallel worker or an external process is meant to observe the data, it never will. The moment any of these appear, rollback is not an isolation mechanism, it is a source of confusing failures. Nested transactions are the other subtlety. Code that opens its own transaction inside the wrapper usually joins the outer one, so the unwind still works. Code that demands a genuinely independent transaction, or that commits explicitly, does not, and its rows persist. ### What rollback hides Rollback tests the write, not the commit. Anything the engine defers until commit is invisible: deferred constraint checks, commit-time triggers, and any value the engine computes at commit. Worse, a production bug in which a half-finished unit of work would have been committed looks identical to a correct run, because the harness was going to throw the writes away either way. So a test that specifically asserts durability, ordering across commits, or the visibility of committed data to another reader must not use rollback. ### Truncate-and-reseed mechanics The alternative empties the tables between tests and puts the reference rows back. Four details decide whether it works. **Order or cascade.** Emptying a parent table while child rows still reference it is rejected, so you either truncate in dependency order, empty everything in one set-based operation the engine treats atomically, or briefly relax constraint checking around the reset and restore it afterwards. **Sequence counters.** Emptying a table does not by itself reset the identifier counter unless you ask for it. Deciding whether to restart them is a real choice: restarting makes runs reproducible, and not restarting keeps any assertion on a literal identifier honestly fragile. **Reference rows.** Emptying a lookup table the application cannot start without means the reseed step is mandatory, not optional, and it becomes part of the per-test cost. **The table list.** This is where suites rot. A hand-maintained list drifts from the schema: a migration adds a table for expiring loyalty points, nobody adds it to the list, and that one table is never emptied. Rows accumulate across the entire run until some later test counts them and fails, far away from whichever test wrote them, and the symptom reads as a schema-drift mismatch rather than as a cleanup gap. The fix is to derive the list from the live catalogue at run start, minus an explicit allowlist of reference and migration-bookkeeping tables, so a new table is covered the day it appears. ### The cost arithmetic The trade is speed for fidelity, and it is worth doing the arithmetic out loud rather than arguing from taste. A suite of 1,412 persistence tests whose reset empties 42 tables at roughly 34 ms per reset spends about 48 seconds on cleanup; the same suite under rollback spends a few seconds. In a 27-minute suite that difference is noise, and fidelity wins. In a suite people run on every save, it is not noise, and the answer changes. There is a third option worth naming: build a prepared schema once, then restore each test from that template rather than re-running the reseed. It costs more setup but turns the per-test reset into a copy, and it composes with per-worker isolation. ### Parallel workers Neither mechanism isolates two workers that share one schema. Rollback does not, because the workers are separate connections. Truncate is actively hostile, because one worker's reset empties tables the other is mid-test on. Parallelism needs a boundary of its own: a schema or database per worker, or per-run keys unique enough that workers never touch the same rows. Saying this unprompted is what separates a mid-level answer from a rehearsed one. ### Choosing per test, not per suite The mature answer is not a single policy. Use rollback for the wide base of narrow persistence tests that live entirely inside one transaction, and truncate-and-reseed for the smaller set that crosses a commit boundary, involves a second reader, or asserts commit-time behaviour. Making that choice explicit in the harness, rather than leaving each author to guess, is what keeps the fast path fast and the honest path honest.

  • Your reset works from a hand-written table list. What goes wrong as the schema grows?
    It drifts. A migration adds a table, nobody adds it to the list, and that table is never emptied, so rows pile up across the whole run. The first test that counts them fails, usually far from the test that wrote them, and the symptom looks like a data bug rather than a cleanup gap. Derive the list from the live catalogue at run start, minus an explicit allowlist, so new tables are covered automatically.
  • Why does emptying tables in an arbitrary order fail, and what are the options?
    Foreign keys. Emptying a parent while child rows still reference it is rejected. You can work in dependency order, empty everything in one set-based operation the engine treats atomically, or briefly relax constraint checking around the reset and restore it afterwards. Whichever you pick, remember the two follow-on steps: decide whether identifier counters restart, and reinsert the reference rows the application cannot start without.
  • Two workers run in parallel against one schema. Does either mechanism isolate them?
    No. Rollback does not, because the workers hold separate connections and neither unwind touches the other's writes. Truncation is worse, because one worker's reset empties tables the other is mid-test on. Parallelism needs its own boundary: a schema or database per worker, or per-run keys unique enough that two workers never address the same rows.

A rollback-per-test wrapper is a dress rehearsal that never opens the doors: fast, and it never tells you whether the doors open.

saying these in an interview costs you the question

  • Thinks a rollback also resets identifier counters
  • Maintains the reset table list by hand and forgets new tables
  • Uses rollback for tests whose code commits on its own
  • Empties reference tables and never reseeds them
  • Believes rollback isolates parallel workers on one schema
  • Assumes deleting every row costs the same as emptying the table

context