Domain Driven Testing - fieldenms/tg GitHub Wiki
Domain-Driven Testing
- The system
- Test class lifecycle
- Two population modes
- Layers of IDs
- The invariant that prevents ID conflicts
- How the framework enforces the invariant
- What the model assumes
The system
Tests run against a relational database. Each persistent entity maps onto a table with ID as the primary key, drawn from one database sequence object shared across all entity types.
IDs are assigned by one of two paths:
- API save — method
saveproduces the next sequence value to use as the new entity ID. - Direct INSERT — an SQL script supplies the ID explicitly; the sequence is unaffected.
An ID conflict occurs when two entities of the same type are persisted with the same ID. This is a failure mode the framework takes care to prevent.
Test class lifecycle
Each test class T has its own dataset — the records present in the database at the start of
every one of its test methods.
The framework builds T's dataset once (before the first method of T), records an SQL script
in-memory, then re-applies the script before every subsequent method.
Within a method, tests may save additional entities.
Truncation after each method clears the whole database.
Two population modes
How the dataset for a test class is initially built is determined by configuration.
1. Cached Mode
In Cached Mode, every populate* method is backed by an SQL script that is replayed via direct
INSERTs when the method is invoked.
The script set is established once per JVM, then shared across all Cached Mode test classes.
Before the first test class in Cached Mode runs, there are two branches, chosen by loadDataScriptFromFile:
- Create scripts (
loadDataScriptFromFile = false, the default). The first test class calls everypopulate*method eagerly. Each invocation is intercepted by the@EnsureDatainterceptor, which records the resulting INSERT statements as a script. - Load scripts from disk (
loadDataScriptFromFile = true). The scripts were created on disk by a prior JVM run. No eager bulk pre-population happens in this mode. Eachpopulate*script is loaded lazily from disk by the@EnsureDatainterceptor on first invocation.
Afterwards, all test classes in Cached Mode use the script set: every populate* call from any
test class is intercepted and replayed via JDBC.
2. Uncached Mode
In Uncached Mode, populate* methods are not cached — populate* scripts are not loaded nor created.
Each test class uses one of two options:
saveDataPopulationScriptToFile = true.populateDomainis executed all through Java, callingpopulate*methods eagerly. AfterpopulateDomaincompletes, the framework generates an SQL script that recreates the whole dataset and saves it to a file.useSavedDataPopulationScript = true.populateDomainis effectively not executed — it is called, but only for basic initialisation. Instead, a previously created dataset script is loaded via JDBC.
The choice is per-test-class.
Note 1: In the create-scripts branch of Cached Mode, it is the application developer's
responsibility to ensure that the first test class calls all populate* methods before any of its
test methods are run.
Note 2: In the useSavedDataPopulationScript option of Uncached Mode, it is the application
developer's responsibility to skip data population in populateDomain of each test class.
Note 3: The load-scripts branch of Cached Mode depends on artefacts produced by a prior
create-scripts run (saveScriptsToFile = true for the populate* scripts; the seed script —
described in Phase 1 below — is saved unconditionally during a create-scripts run).
If the seed script is missing, the framework falls back to a safe default seed (10 000 000) —
higher than any plausible L1 ID from on-disk populate* scripts, so R1 holds in practice.
The framework warns in this case to alert the developer that the cache is in a degraded state.
Layers of IDs
In any mode, entity IDs belong to at most three layers:
| Layer | Origin | How IDs are assigned |
|---|---|---|
| L1 — Pre-populated from a script | INSERTs from a script — populate* scripts (Cached Mode) or a dataset script (Uncached Mode with useSavedDataPopulationScript) |
Explicitly, baked into the script |
| L2 — Pre-populated in Java | API entity saves in populateDomain — eager populate* calls (Uncached Mode, saveDataPopulationScriptToFile) and any ad-hoc save() in populateDomain |
Sequence-assigned |
| L3 — Intermediate | API saves inside a test method | Sequence-assigned |
Per mode/option, which layers may be present:
- Cached: L1; L2 if
populateDomaindoes any ad-hoc save; L3. - Uncached,
saveDataPopulationScriptToFile: L2 (allpopulate*plus any ad-hoc save); L3. - Uncached,
useSavedDataPopulationScript: L1; L3.
The invariant that prevents ID conflicts
An ID conflict cannot occur if and only if:
At every entity save through the API, the sequence's next value is strictly greater than every ID already present in the database.
Sequence-assigned IDs increase monotonically, so they never collide with each other. The invariant exists to ensure they also never collide with explicitly-assigned IDs in scripts.
Rules
The invariant can be expressed through the following rules:
- R1 — every L2 ID > every L1 ID.
Ad-hoc saves in
populateDomaindon't collide with data loaded from scripts. - R2 — every L3 ID > every L1 + L2 ID. Intermediate saves in test methods don't collide with the test class dataset.
How the framework enforces the invariant
The framework keeps an idSeed per each test class — a value used to restart the database
sequence before every test method of that class.
idSeed[T] is generally computed as headroom + max(IDs in DB), where headroom is an arbitrary
buffer that creates a safe margin for extra ad-hoc inserts.
Once set, idSeed[T] is preserved across test methods of T.
The value chosen during the first method's setup remains correct for every subsequent method,
because the pre-population dataset doesn't change between methods.
How and when idSeed[T] is computed differs per mode.
Cached Mode
Cached Mode has two phases.
Phase 1 — Establishing the seed ID
The seed is a value greater than every L1 ID that any test class will ever insert.
It is the position at which the sequence must be when populateDomain is entered, so that any
L2 save lands above every L1 ID.
How the seed is established depends on the Phase 1 branch:
-
Create-scripts (
loadDataScriptFromFile = false):- The developer arranges for the first test class in Cached Mode to call every
populate*method eagerly. The@EnsureDatainterceptor records each call as a script. - After all calls return, the framework captures
seed = headroom + max(IDs). - The seed is persisted to disk as an SQL statement that restarts the sequence at
seed. This artefact is used by any future test run with the load-scripts option in Cached Mode. - The database is truncated.
- The developer arranges for the first test class in Cached Mode to call every
-
Load-scripts (
loadDataScriptFromFile = true):- The framework loads the persisted seed script from disk and applies it, restarting the
sequence with the previously captured seed value, which is then stored as
seed. populate*scripts themselves are not executed in this phase — they are executed on demand in Phase 2.
- The framework loads the persisted seed script from disk and applies it, restarting the
sequence with the previously captured seed value, which is then stored as
After Phase 1, seed is established and is greater than every L1 ID.
Phase 2 — Test class data population
L1 is materialised within populateDomain through populate* calls intercepted by
the @EnsureData interceptor, which replays each script via JDBC.
In any populateDomain, calls to populate* (L1) may be interleaved with API saves (L2), but the
framework cannot restart the sequence between these calls at runtime.
Instead, it relies on the seed: when a new instance of a Cached Mode test class T is created and
idSeed[T] is not yet set, it is initialised to seed. This positions the sequence above every
L1 ID before populateDomain runs.
Before the first test method of T:
populateDomainis entered with the sequence atidSeed[T] ≥ seed.- Intercepted
populate*calls replay their scripts via direct INSERTs, materialising L1 records at IDs< seed; the sequence is untouched. - Any API save in
populateDomainadvances the sequence fromidSeed[T]and upward, strictly above every L1 ID. R1 holds. - After
populateDomaincompletes, the framework setsidSeed[T] = headroom + max(IDs), above L1 + L2.
Before every test method of T, the sequence is restarted with idSeed[T].
L3 saves go above L1 + L2. R2 holds.
Uncached Mode
Each of the two options has its own enforcement path.
Option A — saveDataPopulationScriptToFile = true
L1 is empty, so R1 is trivially satisfied.
Before the first test method of T:
populateDomainruns and callspopulate*methods eagerly. All entity saves are sequence-assigned. All inserted records are L2.- After
populateDomainreturns, the framework setsidSeed[T] = headroom + max(IDs), above every L2 ID.
Before every test method of T, the sequence is restarted with idSeed[T].
L3 saves go above L2. R2 holds.
The sequence position when populateDomain is entered is the framework's default id seed.
Uncached Mode does not inherit the Cached Mode seed.
Safe because in Uncached Mode there will be no L1 IDs to collide with.
Option B — useSavedDataPopulationScript = true
L2 is empty by convention, so R2 reduces to L3 > L1.
Before the first test method of T:
- The dataset script is applied via JDBC, materialising L1 records at their captured IDs.
- The framework sets
idSeed[T] = headroom + max(IDs), and the sequence is restarted with this value immediately. populateDomainruns initialisation only. R1 holds. R1 would still hold ifpopulateDomaindid save new entities — see the convention below.
Before every test method of T, the sequence is restarted with idSeed[T].
L3 saves go above L1. R2 holds.
What the model assumes
The invariant — and therefore conflict freedom — depends on:
- Framework responsibility.
The framework computes
idSeed[T]per the rules above for each mode, and preserves it across the methods of any test class T. - Test author responsibility (any mode). Application code does not restart the sequence with a value that breaks R1 or R2.
- Cached mode with load-scripts prerequisite. In the load-scripts branch of Cached Mode, the seed script should be present on disk from a prior create-scripts run. Without it, the framework falls back to a safe default that preserves R1 in practice, but the seed is no longer coherent with the cached scripts and the framework warns.
If all three hold, R1 and R2 hold, the invariant holds, and no primary-key conflict can occur.
A convention for useSavedDataPopulationScript
In Uncached Mode with useSavedDataPopulationScript = true, populateDomain is typically
initialisation-only — no save(...).
This is not strictly necessary: the framework bumps the sequence above every L1 ID between the
script load and populateDomain, so an L2 save would still satisfy R1.
The convention exists because L2 records created in this option are not preserved across test
methods (only the file content is re-applied), so saves there would yield different DB states on
the first method vs. subsequent ones — a logical inconsistency, not an ID conflict.