Which data-access defects stay invisible when a test holds one unit of work open from setup through to its assertions?
answer
- the test never leaves the context
- nothing is ever detached
- no second observer of the state
- boundaries are where these defects live
- arrange, act and assert in three contexts
basics
~20 sEvery defect that needs a boundary crossing: reads of not-yet-loaded data after the context is gone, work done on detached objects, stale reads, and anything the database supplies. One open context keeps every object live and every read answered from memory.
solid answer
~40 sProduction usually spans **two or more units of work**: load in one, hand the data to another layer, write in a second. A test that opens one context in setup and asserts inside it never reproduces that shape. Nothing is ever detached, so code that touches a deferred association after the boundary closes cannot fail; nothing is ever re-read, so a stale value or a wrong copy-in is invisible; every read is answered from the **identity map**, so mapping, defaults and coercion go unchecked. Flush ordering may also be masked, because the changes go out in one batch at the end rather than interleaved with the reads the production path makes. The fix is structural: write in one boundary, close it, then act and assert in fresh ones.
go deeper
Learn the shape first: production usually loads in one unit of work and uses the data in another, while a naive test does everything in one. That difference is why a green test can miss real failures.
Be able to list what the single-context shape removes — no detachment, no re-read, no second observer, no interleaved flush — and describe the three-phase test that puts each back.
Bring a case: a defect that escaped because the suite never closed a boundary, what you changed structurally, and how you kept the suite fast while doing it. Say which classes you deliberately left to production signals.
Own the line between what the suite reproduces and what only production reveals, and make it explicit rather than accidental. Otherwise every escape is followed by a reflex to add slow tests that still do not cross a boundary.
A suite can be green and thorough-looking and still let data-access defects through, because most of those defects only exist at a **boundary** — the moment a unit of work ends, objects stop being tracked, and the next piece of code has to work with what it was given. A test that holds a single context open from setup to assertion removes every boundary from the scenario, and therefore removes the defects with it. ## What "one open unit of work" removes from the scenario - **Nothing is ever detached.** Objects stay tracked for the whole test, so any code that hands an object to a later layer and touches an association loaded on demand keeps working — the stand-in can still fetch, because its context is alive. - **No read is ever repeated in a second context.** The identity map guarantees the same instance comes back, so a value that another writer changed, or that the database itself supplied, is never observed. - **No object is ever brought back in.** Production flows that carry an object out of one context and write it through another are not exercised, so whatever the layer does when it copies that state back in is untested. - **Changes are not interleaved with reads the way production interleaves them.** Statements accumulate and go out in one flush at the end, so an ordering problem — a child written before its parent, a delete and re-insert colliding on a unique key — may be reordered into a shape that happens to work. - **Uncommitted state is visible only to the test.** Anything that depends on another connection seeing the row (a second service, a background reader, a check deferred to commit) is out of reach. ## Why the test still passes convincingly The trap is that the test *does* exercise real code and *does* touch a real database. The assertions are true statements about the state the test can see. What is missing is the perspective change: production reads that state through a different context, at a different time, sometimes on a different connection. Test-time confidence is really confidence about one particular observer. | Production shape | What the single-context test does instead | Defect class it hides | |---|---|---| | Load, close, render or serialise | Keeps the context open through the assertions | A read of data not yet loaded, after the boundary | | Read in one context, write in another | Reads and writes in the same one | Copy-in and staleness on the write path | | Two writers touching one row | One writer, one connection | Lost updates and version-check failures | | Statements emitted as the request proceeds | Everything flushed once at the end | Ordering and constraint-timing problems | | Rows read back after commit | Rows read from the identity map | Defaults, generated values, coercion | ## How to give the test the boundaries back 1. **Split the test into phases with a boundary between them.** Arrange in its own unit of work and close it. Run the code under test in a second, entered the way production enters it, not by borrowing the test's. Assert in a third. 2. **Do not let the test's own transaction wrap the code under test.** If the test's context is the one the production code joins, the boundary can never close during the exercise. 3. **Clear the tracked set when a full boundary is impractical.** Dropping the tracked objects forces the next read to be a real query. It is weaker than closing the boundary — the transaction and its uncommitted view are still there — but it removes the identity map's answer. 4. **Assert through a second reader.** A plain `SELECT` for the columns, or a fresh context, shows what the database holds rather than what the test remembers. 5. **Exercise the hand-off explicitly.** If production carries an object past the boundary, make the test do so too, and let it fail there. ## Where this ends Some defects will not be reproduced by any restructuring of a single-process test: real concurrency, real data distribution, plan changes under a grown table. Recognising that boundary honestly is part of the answer — the goal is to move the *reproducible* classes into the suite, not to pretend the suite can cover everything. What is unreasonable is a suite whose shape makes an entire, cheap-to-reproduce class of defect structurally impossible to see.
- Is clearing the tracked set as good as closing the boundary?It is a useful approximation and much cheaper. Dropping the tracked objects defeats the identity map, so the next read is a real query and stale mapping shows up. What it does not reproduce is the closed context itself: the transaction is still open, uncommitted state is still visible, and code that fails only because its context is gone still cannot fail.
- How can a test tell you that the production path crosses a boundary at all?It cannot infer it — you have to model it. Look at where the production code enters and leaves a unit of work: per request, per message, per scheduled run. Then shape the test so its exercise phase enters and leaves the same way, rather than inheriting the test's own context.
- Which defects survive even a well-bounded single-process test?Anything needing real concurrency (competing writers, lock waits), real volume and data distribution (plan choice, index usefulness), and long-running effects such as growth or fragmentation. Those belong to load tests and production signals, not to the correctness suite.
saying these in an interview costs you the question
- Thinks a test touching a real database therefore covers real behaviour
- Lets the test's own transaction wrap the code under test
- Believes objects behave the same before and after their context ends
- Assumes a re-read by id must be a query
- Says nothing can be reproduced without full production data