Skip to content

Domain Driven Testing Guide

homedirectory edited this page Jun 8, 2026 · 11 revisions

Domain-Driven Testing Guide

  1. Links
  2. Introduction
  3. Library dependencies
  4. Application-level AbstractDaoTestCase
    1. IoC binding
    2. Worked example
  5. IDomainData and @EnsureData
    1. Why @EnsureData({}) matters
    2. Dependencies and execution order
    3. Sessions
    4. The self-check idiom
    5. Calling populate* from tests
  6. Two population modes
    1. Cached Mode
    2. Uncached Mode
    3. Choosing between modes
  7. Cached Mode setup: two-phase Maven configuration
  8. System properties
  9. Writing a new test
    1. Tests dependent on the continuous flow of time
  10. Adding a new pre-population method
  11. Running a test class in Uncached Mode
  12. Troubleshooting
    1. Entity ID conflict during a test

Links

Introduction

The domain-driven testing framework enables a TG-based application drive tests against a real relational database. Test data is described in Java (a set of populate* methods), recorded once as SQL scripts, then replayed before every test method to give each test a clean, identical starting state.

This page is the developer-facing how-to: what classes to extend, what methods to implement, what annotations to use, how to configure Maven, how to iterate locally. For the framework's internal model — ID layers, the invariant that prevents primary-key conflicts, the rules each mode enforces — see Domain-Driven Testing.

What a TG-based application provides:

  1. An application-level AbstractDaoTestCase extending the platform's AbstractDomainDrivenTestCase.
  2. An IDomainData interface holding populate* methods that seed test data.

The framework provides the rest: the database lifecycle (DDL, truncation), the @EnsureData interceptor that turns populate* calls into recorded scripts, entity ID uniqueness, and support for Cached / Uncached Mode.

Library dependencies

Maven artefacts:

  • platform-dao — the platform itself (AbstractDomainDrivenTestCase, DbUtils, the test runner). A standard dependency of any TG-based application's *-dao module.
  • tglib-testing — a standalone library that applications depend on explicitly. Provides the @EnsureData annotation, EnsureDataInterceptor, and ITestCaseWithEnsureData.
<!-- app-dao -->
<dependency>
    <groupId>fielden</groupId>
    <artifactId>tglib-testing</artifactId>
    <version>${tglib-testing.version}</version>
</dependency>

Application-level AbstractDaoTestCase

Concrete tests extend an application-level AbstractDaoTestCase, which in turn extends AbstractDomainDrivenTestCase and implements both IDomainData (populate* methods) and ITestCaseWithEnsureData (the contract observed by the @EnsureData interceptor).

There are 3 abstract methods to implement:

  • prePopulateDomain() — invoked once per JVM by the framework in Cached Mode. It calls every populate* method in the application that should have a script generated. Each call is intercepted by EnsureDataInterceptor, which records the resulting INSERTs as a script for later replay.

  • afterPrePopulation() — invoked by the framework immediately after prePopulateDomain and before the database is truncated. Typically runs the cleanup routine registered by the @EnsureData interceptor.

  • populateDomain() — invoked before the first, and only the first, test method of every test class. Typically runs base initialisation (a test user, a test clock) and nothing else. Test classes that need extra fixtures override this method.

The following are not required of the application:

IoC binding

The EnsureDataInterceptor is wired by the application's test IoC module. The binding is on the @EnsureData annotation, against any subclass of the application's AbstractDaoTestCase:

@Override
protected void configure() {
    // It is important to register EnsureDataInterceptor before any other inherited interceptors
    // to ensure that it is the first one to intercept the call.
    bindInterceptor(subclassesOf(AbstractDaoTestCase.class),
                    annotatedWith(EnsureData.class),
                    new EnsureDataInterceptor());
    // Only now call super.
    super.configure();
}

The interceptor must be bound before other interceptors so that it sees the call first.

Worked example

A complete application-level AbstractDaoTestCase looks like this:

@RunWith(SqlServerDomainDrivenTestCaseRunner.class)
public abstract class AbstractDaoTestCase extends AbstractDomainDrivenTestCase implements IDomainData, ITestCaseWithEnsureData {

    public static final String PRE_POPULATED_NOW = "2022-02-03 08:01:02";
    public final DateTime prePopulatedNow = dateTime(PRE_POPULATED_NOW);

    /// External clean-up routine gets set in EnsureDataInterceptor and runs in afterPrePopulation().
    private Runnable externalCleanupRoutine;

    @Override
    public void prePopulateDomain() {
        basicDataAndInitialisation(prePopulatedNow);

        // Call every @EnsureData-annotated populate* method.
        // Order does not matter — declared dependencies drive execution order, and each method runs at most once.
        populateJmcCommon();
        populateCoreCommon();
        populatePersonnel();
        populateEquipmentCommon();
        populateEquipment();
        // ... etc.
    }

    @Override
    public void afterPrePopulation() {
        if (externalCleanupRoutine != null) {
            externalCleanupRoutine.run();
        }
    }

    @Override
    public void setCleanUpAfterPrepopulation(final Runnable clean) {
        externalCleanupRoutine = clean;
    }

    @Override
    protected void populateDomain() {
        basicDataAndInitialisation(prePopulatedNow);
    }

    private void basicDataAndInitialisation(final DateTime now) {
        final IUniversalConstants consts = getInstance(IUniversalConstants.class);
        if (consts instanceof UniversalConstantsForTesting constants) {
            constants.setNow(now);
        }
        startupData();

        // Set the unit test user as the current user.
        final User su = co$(User.class).findUser(User.system_users.UNIT_TEST_USER.name());
        getInstance(IUserProvider.class).setUser(su);
    }

    @EnsureData({})
    @SessionRequired(allowNestedScope = false)
    public void startupData() {
        // check self
        if (co(User.class).entityWithKeyExists(User.system_users.UNIT_TEST_USER)) {
            return;
        }
        setupUser(User.system_users.UNIT_TEST_USER, "app.tg.test");
        setupPerson(User.system_users.UNIT_TEST_USER, "app.tg.test");
    }

}

The non-obvious bits:

  • startupData() carries @EnsureData({}) — an empty value rather than the absence of the annotation, so it is intercepted (see Why @EnsureData({}) matters below).
  • setCleanUpAfterPrepopulation is the hook the interceptor uses to register its state-reset routine; afterPrePopulation calls it.

IDomainData and @EnsureData

IDomainData is a Java interface, declared in applications, holding populate* methods as default methods, plus shared constants used by both the populate methods and the tests that consume their data. The application-level AbstractDaoTestCase implements this interface, so every test class inherits the populate methods and the constants.

Why @EnsureData({}) matters

The interceptor binding is on the annotation. A method without @EnsureData is invisible to the interceptor. It still runs — as a plain Java method — but none of the framework machinery applies: no caching, no script generation, no dependency resolution, etc.

So every populate* method must be annotated, including those with no dependencies. Use @EnsureData({}) — an empty value, not an absent annotation.

Dependencies and execution order

@EnsureData(...) declares the methods that must run before the annotated one:

@EnsureData({ "populateJmcCommon", "populatePersonnel" })

When the interceptor sees an annotated call, it first invokes each declared dependency. Each dependency is itself intercepted, so transitive dependencies are supported as well.

The interceptor remembers every method it calls within the scope of a test class, so calling the same populate* methods more than once is a no-op -- this operation is idempotent.

Sessions

Every populate method runs in its own transaction:

@SessionRequired(allowNestedScope = false)

allowNestedScope = false is intentional — populate methods must start their own transaction. This is necessary for the interceptor to determine which records were created by a particular method.

The self-check idiom

Each populate method begins with a guard that returns immediately if the data appears to already be in place. This is defence-in-depth: although the interceptor ensures idempotence, the self-check protects against unexpected situations.

@EnsureData({ "populateCoreCommon" })
@SessionRequired(allowNestedScope = false)
default void populateEquipment() {
    // check self
    if (co(Equipment.class).entityWithKeyExists(EQUIPMENT_KEY)) {
        return;
    }
    // ... saves
}

Two common ways to implement self-checks:

  • co(entityType).entityWithKeyExists(key) — when the data has a known business key.

  • entityWithDescExists(class, desc) — when the key is a generated identifier and desc is used to capture a unique identifier, such as a UUID.

default <E extends AbstractEntity<?>> boolean entityWithDescExists(final Class<E> entityType, final @Nullable String desc) {
  final var query = desc == null
          ? select(entityType).where().prop(DESC).isNull().model()
          : select(entityType).where().prop(DESC).eq().val(desc).model();
  return co(entityType).exists(query);
}

Calling populate* from tests

Calling a populate method directly from a test method is harmless. If the method has already run (which is typical, since prePopulateDomain calls every populate method during initial pre-population), the interceptor skips it. Otherwise it runs — replaying a cached script or invoking the method itself, depending on the flags.

That said, the typical and recommended practice is to call populate methods from prePopulateDomain and to express any further dependencies via @EnsureData on other populate methods.

Two population modes

How a test class's dataset is built depends on the mode of operation.

Cached Mode

The default mode. Populate methods are recorded as INSERT scripts once per JVM and replayed for every test class via the @EnsureData interceptor. This is the dominant configuration: the cache is built once and consumed many times, so the cost of pre-population is amortised across the whole suite.

Uncached Mode

Two opt-in options, configured by overriding methods on the test class:

Override Effect
saveDataPopulationScriptToFile() returns true Eager Java-driven population. populateDomain runs in full, then the resulting dataset is saved to a per-test-class SQL file.
useSavedDataPopulationScript() returns true Skip Java-driven population. Load the previously saved dataset file via JDBC. populateDomain is expected to be init-only.

Both must not be true simultaneously.

Uncached Mode applies per test class, not globally. A handful of test classes can opt out of Cached Mode while the rest of the suite continues to use it.

Both flags must be false before commit to avoid interfering with other developers' work.

Choosing between modes

Situation Mode
Routine integration test that runs the whole suite Cached Mode
A handful of tests for exploration Uncached Mode

Cached Mode setup: two-phase Maven configuration

Applications drive Cached Mode through a two-phase Maven configuration: a build-environment phase that produces the on-disk cache, then a test phase that uses it. This same configuration is what developers run locally; it is the standard way to invoke the test suite.

The build-environment phase runs a single placeholder test class:

/// A test-case that is responsible for building the test environment for other tests, which includes:
///
/// - Generating DDL for reuse by test cases.
/// - Generating scripts for test data pre-population methods annotated with @EnsureData.
///
public class BuildEnvironment extends AbstractDaoTestCase implements IDomainData {

    private final Logger logger = getLogger();

    public BuildEnvironment() {
        logger.info("BUILD-TEST-ENVIRONMENT... BUILDING...");
    }

    @Test
    public void toKickInTheBuild() {
        logger.info("BUILD-TEST-ENVIRONMENT... DONE!");
    }

    @Override
    protected void populateDomain() {
        // no need to call anything
    }
}

The only purpose of BuildEnvironment is to be an actual JUnit test class so that the test runner instantiates AbstractDaoTestCase, triggers initial pre-population, and lets the framework generate the DDL and pre-population scripts.

The Maven configuration runs BuildEnvironment under Surefire (single fork) and then all other tests under Failsafe (multi-fork):

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <excludes>
            <exclude>**/*Test*.java</exclude>
        </excludes>
        <includes>
            <include>**/BuildEnvironment.java</include>
        </includes>
        <forkCount>1</forkCount>
        <reuseForks>true</reuseForks>
        <argLine>
            -server
            -Xms512m
            -Xmx1024m
            -XX:+UseG1GC
        </argLine>
        <systemPropertyVariables>
            <databaseUri>${databaseUri.prefix}${surefire.forkNumber}</databaseUri>
            <saveScriptsToFile>true</saveScriptsToFile>
            <loadDdlScriptFromFile>false</loadDdlScriptFromFile>
            <loadDataScriptFromFile>false</loadDataScriptFromFile>
        </systemPropertyVariables>
    </configuration>
</plugin>

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-failsafe-plugin</artifactId>
    <executions>
        <execution>
            <id>integration-test</id>
            <phase>test</phase>
            <goals>
                <goal>integration-test</goal>
                <goal>verify</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <includes>
            <include>**/*Test*.java</include>
        </includes>
        <forkCount>${fork.count}</forkCount>
        <reuseForks>true</reuseForks>
        <argLine>
            -server
            -Xms265m
            -Xmx768m
            -XX:+UseG1GC
        </argLine>
        <systemPropertyVariables>
            <databaseUri>${databaseUri.prefix}${surefire.forkNumber}</databaseUri>
            <saveScriptsToFile>false</saveScriptsToFile>
            <loadDdlScriptFromFile>true</loadDdlScriptFromFile>
            <loadDataScriptFromFile>true</loadDataScriptFromFile>
        </systemPropertyVariables>
    </configuration>
</plugin>

Why two phases:

  • The cache must be written by a single JVM run — concurrent forks would race over the same disk artefacts. Surefire with forkCount=1 produces a coherent cache.
  • The actual tests can then run in parallel forks (Failsafe with forkCount=${fork.count}), each reading the same cache from disk and each working against its own per-fork database (${databaseUri.prefix}${surefire.forkNumber}).

The cache lives in src/test/resources/db/ as a set of prePopulate-*.script files (one per populate method), an idSequence.script (the ID seed restart statement), and a create-db-ddl.script (the DDL).

System properties

The flags visible in the Maven configuration:

Property Type Effect
databaseUri string JDBC URI for the test database. Conventionally ${databaseUri.prefix}${forkNumber} so each Maven fork uses an isolated DB.
saveScriptsToFile boolean When true, the framework persists populate* scripts and idSequence.script to disk after pre-population.
loadDataScriptFromFile boolean When true, the framework skips initial pre-population and loads the populate* scripts from disk.
loadDdlScriptFromFile boolean Controls the DDL cache. Set in lockstep with loadDataScriptFromFile.
fork.count int Number of parallel forks during the test phase. Each fork uses its own database instance derived from databaseUri.

Combinations that make sense:

  • Build-environment phase: saveScriptsToFile=true, loadDataScriptFromFile=false, loadDdlScriptFromFile=false.
  • Whole test suite phase: saveScriptsToFile=false, loadDataScriptFromFile=true, loadDdlScriptFromFile=true.
  • One-off rebuild from scratch (no disk artefacts at all): all three false. The framework regenerates everything in memory for that JVM run; nothing is written or read.

Writing a new test

Extend the application-level AbstractDaoTestCase and add JUnit @Test methods. The base class brings the shared dataset, the test user, and the test clock.

public class EquipmentMaintenanceTest extends AbstractDaoTestCase {

    @Test
    public void equipment_can_be_moved_between_workshops() {
        // ... test body
    }
}

If a test class needs fixtures beyond the shared dataset, override populateDomain:

@Override
protected void populateDomain() {
    super.populateDomain();

    if (useSavedDataPopulationScript()) {
        return;
    }

    populatePersonnel();
    populateEquipmentCommon();
}

The early-return guard matters when the test class might switch to Uncached Mode. Without it, populateDomain would create duplicate records when the dataset is being loaded from a saved file.

Tests dependent on the continuous flow of time

If a test requires the time to be continuously flowing (e.g., for uniqueness of createdDate / lastUpdatedDate properties), install the millisecond ticker:

private final UniversalConstantsForTesting constants = (UniversalConstantsForTesting) getInstance(IUniversalConstants.class);

@Before
public void reset() {
    constants.setNow(prePopulatedNow);
    constants.setTimeSupplier(constants.mkMillisTicker(1000));
}

Adding a new pre-population method

  1. Add a default void populateX() to IDomainData.
  2. Annotate with @EnsureData({ ... }) listing any prerequisite populate methods, or @EnsureData({}) if there are none. The annotation is required — without it, the interceptor never fires and the method runs unrecorded.
  3. Annotate with @SessionRequired(allowNestedScope = false).
  4. Add a self-check as the very first statement.
  5. Call populateX() from prePopulateDomain() in the application-level AbstractDaoTestCase. Order does not matter — declared dependencies drive execution order, and the interceptor skips already-executed methods.
@EnsureData({ "populateCoreCommon" })
@SessionRequired(allowNestedScope = false)
default void populateX() {
    // check self
    if (co(Person.class).entityWithKeyExists(PERSON_X)) {
        return;
    }
    // ... saves
}

Running a test class in Uncached Mode

Uncached Mode is useful when iterating on a handful of tests. It skips the pre-population procedure entirely, and instead relies on populateDomain to provide test data for each test class.

  1. Override saveDataPopulationScriptToFile() to return true. Run the test once. DbCreator writes the test class's dataset to a per-test-class SQL file.

  2. Flip — set saveDataPopulationScriptToFile() back to false and override useSavedDataPopulationScript() to return true. Subsequent runs load test data from an SQL script. This is the fast inner loop.

  3. Reset both flags to false before commit.

A test class with both overrides and the early-return guard:

public class ReReliabilityTest extends AbstractDaoTestCase {

    @Test
    public void total_query_returns_results() {
        // ... assertions
    }

    @Override
    public boolean saveDataPopulationScriptToFile() { return false; }

    @Override
    public boolean useSavedDataPopulationScript()   { return false; }

    @Override
    protected void populateDomain() {
        super.populateDomain();

        if (useSavedDataPopulationScript()) {
            return;
        }

        populateMyExtraData();
    }
}

In this example, the SQL script will be generated at src/test/resources/db/data-ReReliabilityTest.script.

Troubleshooting

Entity ID conflict during a test

The framework is designed to prevent entity ID conflicts. If it happens anyway, the cause is likely outside the framework's reach: a stale script, or test code explicitly restarting the ID sequence (via DbUtils.resetSequenceGenerator) to a value that overlaps existing data. See Domain-Driven Testing for the model and the rules each mode enforces.

Clone this wiki locally