-
Notifications
You must be signed in to change notification settings - Fork 6
Domain Driven Testing Guide
- Links
- Introduction
- Dependencies
-
Application-level
AbstractDaoTestCase -
IDomainDataand@EnsureData - Two population modes
- Cached Mode setup: two-phase Maven configuration
- System properties
- Writing a new test
- Adding a new pre-population method
- Running a test class in Uncached Mode
- Troubleshooting
The domain-driven testing framework enables a TG-based application to 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:
- An application-level
AbstractDaoTestCaseextending the platform'sAbstractDomainDrivenTestCase. - An
IDomainDatainterface holdingpopulate*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.
Maven artefacts:
-
platform-dao— the platform itself (AbstractDomainDrivenTestCase,DbUtils, the test runner). A standard dependency of any TG-based application's*-daomodule. -
tglib-testing— a standalone library that applications depend on explicitly. Provides the@EnsureDataannotation,EnsureDataInterceptor, andITestCaseWithEnsureData.
<!-- app-dao -->
<dependency>
<groupId>fielden</groupId>
<artifactId>tglib-testing</artifactId>
<version>${tglib-testing.version}</version>
</dependency>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 everypopulate*method in the application that should have a script generated. Each call is intercepted byEnsureDataInterceptor, which records the resulting INSERTs as a script for later replay. -
afterPrePopulation()— invoked by the framework immediately afterprePopulateDomainand before the database is truncated. Typically runs the cleanup routine registered by the@EnsureDatainterceptor. -
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:
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.
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). -
setCleanUpAfterPrepopulationis the hook the interceptor uses to register its state-reset routine;afterPrePopulationcalls it.
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.
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.
@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.
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.
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 anddescis 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 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.
How a test class's dataset is built depends on the mode of operation.
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.
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.
| Situation | Mode |
|---|---|
| Routine integration test that runs the whole suite | Cached Mode |
| A handful of tests for exploration | Uncached Mode |
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=1produces 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).
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.
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.
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));
}- Add a
default void populateX()toIDomainData. - 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. - Annotate with
@SessionRequired(allowNestedScope = false). - Add a self-check as the very first statement.
- Call
populateX()fromprePopulateDomain()in the application-levelAbstractDaoTestCase. 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
}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.
-
Override
saveDataPopulationScriptToFile()to returntrue. Run the test once.DbCreatorwrites the test class's dataset to a per-test-class SQL file. -
Flip — set
saveDataPopulationScriptToFile()back tofalseand overrideuseSavedDataPopulationScript()to returntrue. Subsequent runs load test data from an SQL script. This is the fast inner loop. -
Reset both flags to
falsebefore 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.
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.
Per aspera ad astra
- Web UI Design and Web API
- Safe Communication and User Authentication
- Gitworkflow
- JavaScript: Testing with Maven
- Java Application Profiling
-
TG Development Guidelines
- TG Releases
- Domain-Driven Testing Guide
- TLS and HAProxy for development
- TG Development Checklist
- Entity
- Entities and their validation
- Entity Properties
- Entity Type Enhancement
- EQL
- Tooltip How To
- All about Matchers
- All about Fetch Models
- Streaming data
- Synthetic entities
- Activatable entities
- Save with fetch
- Jasper Reports
- Opening Compound Master from another Compound Master
- Window management test plan
- Multi Time Zone Environment
- GraphQL Web API
- Guice
- Maven
- RichText Data Migration
- Full Text Search
- Deployment recipes
- Application Configuration
- Observability
- JRebel Installation and Integration
- Compile-time mechanisms
- Work in progress