Domain Driven Testing - fieldenms/tg GitHub Wiki

Domain-Driven Testing

  1. The system
  2. Test class lifecycle
  3. Two population modes
    1. 1. Cached Mode
    2. 2. Uncached Mode
  4. Layers of IDs
  5. The invariant that prevents ID conflicts
    1. Rules
  6. How the framework enforces the invariant
    1. Cached Mode
      1. Phase 1 — Establishing the seed ID
      2. Phase 2 — Test class data population
    2. Uncached Mode
      1. Option A — saveDataPopulationScriptToFile = true
      2. Option B — useSavedDataPopulationScript = true
  7. What the model assumes
    1. A convention for useSavedDataPopulationScript

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 save produces 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 every populate* method eagerly. Each invocation is intercepted by the @EnsureData interceptor, 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. Each populate* script is loaded lazily from disk by the @EnsureData interceptor 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. populateDomain is executed all through Java, calling populate* methods eagerly. After populateDomain completes, the framework generates an SQL script that recreates the whole dataset and saves it to a file.
  • useSavedDataPopulationScript = true. populateDomain is 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 populateDomain does any ad-hoc save; L3.
  • Uncached, saveDataPopulationScriptToFile: L2 (all populate* 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 populateDomain don'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 @EnsureData interceptor 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.
  • 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.

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:

  • populateDomain is entered with the sequence at idSeed[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 populateDomain advances the sequence from idSeed[T] and upward, strictly above every L1 ID. R1 holds.
  • After populateDomain completes, the framework sets idSeed[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:

  • populateDomain runs and calls populate* methods eagerly. All entity saves are sequence-assigned. All inserted records are L2.
  • After populateDomain returns, the framework sets idSeed[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.
  • populateDomain runs initialisation only. R1 holds. R1 would still hold if populateDomain did 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:

  1. 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.
  2. Test author responsibility (any mode). Application code does not restart the sequence with a value that breaks R1 or R2.
  3. 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.