Skip to content

Tentackle PDO Tests — the tentackle-test-pdo Module

Overview

tentackle-test-pdo is the test-support module for writing PDO-based tests. It does three distinct jobs:

  1. Provides test base classes — abstract TestNG and JUnit 5 base classes (AbstractPdoTest, TestApplication) that open a session, populate an in-memory database, manage the per-class/per-method transaction lifecycle, run the modification tracker and tear everything down again — so a test class only has to write the assertions.
  2. Re-exposes the mock delegates — the lightweight Mock* domain/persistence delegates of tentackle-pdo-mock, which let you instantiate PDOs and operations in a unit test without a database, arrive transitively.
  3. Generates the framework's SQL — at build time it runs the SQL Maven plugin over the framework model (number sources, security, preferences, …) and packages the resulting DDL, model dump and MODEL-INDEX.LIST into the jar, so downstream tests can create the TT (Tentackle) tables in an in-memory database from the classpath.

Test scope only. This artifact must be included with <scope>test</scope> in downstream projects — never as a compile/runtime dependency. None of the test frameworks arrives transitively: TestNG and the Jupiter API are optional, and the Jupiter engine and platform launcher are test scope. The downstream project declares the framework it uses itself. The same holds for the JDBC driver (H2 and PostgreSQL are test scope here) and, deliberately (see below), for the framework layers tentackle-persistence and tentackle-domain.

This module is not a JPMS module. It has no module-info.java; the jar carries the Automatic-Module-Name: org.tentackle.pdo.test. It is meant to live on the test classpath, where Tentackle's ServiceFinder SPI discovery still works without an explicit module descriptor.


Package Layout

Package Role
org.tentackle.pdo.testng TestNG base classes: AbstractPdoTest and TestApplication. The default framework (the module's own tests run under TestNG via src/test/testng.xml).
org.tentackle.pdo.junit JUnit 5 equivalents of the same two base classes, for projects that prefer Jupiter.
org.tentackle.pdo.test DbTestUtilities — low-level helpers that build and run the database-population SQL script from the model. Shared by both the TestNG and JUnit base classes.
org.tentackle.pdo.mock MockDomainObject, MockPersistentObject, MockDomainOperation, MockPersistentOperation — no-op delegates for database-less unit tests. Ships in the separate tentackle-pdo-mock artifact (see below) and reaches downstream projects through this one.

The Test Base Classes

There are two parallel families, one per test framework, with the same shape. Pick the one that matches your project's test runner; the TestNG variant is the default in Tentackle.

AbstractPdoTest

The base class for tests on PDO level — i.e., tests that talk to a live (usually in-memory) database through the PDO API. It implements DomainContextProvider, so the running test is the context source for the PDOs it creates.

Transaction handling is declared per test class through the TransactionType enum, chosen via the constructors:

Constructor Transaction Commit/rollback
AbstractPdoTest() CLASS (one transaction per test class) rollback
AbstractPdoTest(TransactionType) as given rollback
AbstractPdoTest(TransactionType, boolean commit) as given commit if true

TransactionType is CLASS (transaction spans the whole class), METHOD (one per test method) or NONE (no automatic transaction). Tests roll back by default so they leave no trace; pass commit = true only for the rare scenario that must persist.

Lifecycle (TestNG annotations shown; JUnit uses the equivalent @BeforeAll/@AfterAll/…):

  • @BeforeSuite openSessionsAndStartModificationTracker() — opens the session (Pdo.createSession()) and makes it current, builds the DomainContext, and if the backend is in-memory creates the database tables. It then picks a default scripting language if exactly one is available, starts the ModificationTracker on a clone of the session (with a fast 500 ms poll interval suited to tests), and finally — again only for an in-memory backend — invokes populateDatabase().
  • @BeforeClass/@AfterClass — begin/end the transaction when TransactionType.CLASS.
  • @BeforeMethod/@AfterMethod — begin/end the transaction when TransactionType.METHOD; a failed method flips rollbackLogged so the rolled-back statements are logged for diagnosis.
  • @AfterSuite closeSessionsAndTerminateModificationTracker() — terminates the tracker and closes the session.

Once per suite, not per class. The session and the domain context are static: every test class of the suite shares them. Hence, the suite-level hooks run exactly once, on the instance of whichever test class runs first. The same applies to the overridable methods they invoke — openSession(), createDomainContext(), createDatabaseTables(Db) and populateDatabase(). An override in an individual test class takes effect only if that class happens to run first. Put such overrides into a common base class that all tests of the suite extend. Fixtures that only one test class needs belong in a @BeforeClass (JUnit: @BeforeAll) method of that class. It runs after the base class has begun the CLASS transaction, so the fixtures are rolled back along with the test.

JUnit lifecycle. The JUnit variant behaves the same way. JUnit creates a new test instance per method by default, so the base class is annotated with @TestInstance(PER_CLASS) (inherited by subclasses). That makes @BeforeAll beforeClass()/@AfterAll afterClass() instance methods, so a CLASS transaction really does span all methods of the class. JUnit has no suite annotations, so the nested AbstractPdoTest.SuiteExtension stands in for them. Before the first test class runs, it opens the session and starts the tracker. It then registers an AutoCloseable in the root ExtensionContext.Store, which JUnit closes when the whole test run ends. If opening the session fails, the failure is remembered and rethrown for every later class instead of being retried. A test method that throws sets rollbackLogged, just like a failed TestNG ITestResult. When no backend is reachable, the tests are aborted with TestAbortedException, which reports them as skipped. As with TestNG, test classes must not run in parallel.

Extension points a concrete test can override:

Method Purpose
createDatabaseTables(Db) Build the schema. The default loads the model from the classpath via DbTestUtilities and runs the generated script. Override to add extra model sources.
populateDatabase() Insert test fixtures after the tables exist (in-memory backends only). Default does nothing.
createDomainContext() Supply the (thread-local) DomainContext. Default Pdo.createDomainContext().
openSession() Open the session; default throws TestNG SkipException (JUnit: aborts) when no backend is reachable, so the suite is skipped, not failed, in an incomplete environment.

Helpers for richer scenarios: runPreCommits()/runPostCommits() force the pre/post-commit callbacks even though the test will roll back; setRollbackLogged(true) logs the rolled-back statements even on success (the flag is reset when the transaction ends); runClass(...), runInOtherJVM(...) and waitForProcess(...) spawn a class with a main method in a separate JVM with the same classpath (used by the remote/RPC tests — see testng.xml's remote1/remote2 groups). runClass returns the Process immediately. waitForProcess collects the output only after the process has terminated, so use it for short-lived processes only. A long-running or chatty process (such as a server) needs ServerProcessWrapper, which drains its output continuously.

Client-server tests across JVMs: ServerProcessWrapper

A test that needs a real TRIP server runs it in a second JVM, started with runClass(...). Timing that with fixed sleeps is fragile — the server may take longer to start, or the next step may race the previous one. ServerProcessWrapper replaces the sleeps by an explicit handshake over the server's standard streams:

  • Startup. The server prints ServerProcessWrapper.READY_MARKER (@@TRIP-SERVER-READY@@) on a line of its own once it is listening; awaitReady(timeoutMs) blocks until then and fails if the server does not come up in time.
  • Stepping. sendCommand(line) writes a command to the server's standard input, and awaitOutput(marker, timeoutMs) waits for the server to acknowledge it with a line of its own. Markers are matched by exact line equality and are one-shot, so each should be printed only once.
  • Shutdown. stopAndWait() (or close()) closes the server's standard input; the server treats the EOF as its signal to shut down. The output of both streams is drained continuously and forwarded to the TestNG Reporter log once the process has terminated.
try (ServerProcessWrapper server = new ServerProcessWrapper(AbstractPdoTest.runClass(MyTestServer.class))) {
  server.awaitReady(30_000);
  // ... connect as a client and prepare ...
  server.sendCommand("ADD");
  server.awaitOutput("ADDED", 10_000);
  // ... verify what the client sees ...
}

On the server side, print the marker after the transport is bound, then read commands from System.in until readLine() returns null. RemotePreferencesTest with AddAndRemovePrefsInServer in this module's tests is a complete example.

TestApplication

The base class for tests that must run as a full AbstractApplication — i.e., with the complete application bootstrap (SessionInfo, property application, registration, scripting) rather than just a bare session. Same in-memory table creation/population, but driven through the application lifecycle. setUpClass() and tearDownClass() run once per test class: TestNG's @BeforeClass/@AfterClass there, JUnit's @BeforeAll/@AfterAll instance methods here (via @TestInstance(PER_CLASS)). startup() is not invoked, and getUser(...) returns null.

It differs from AbstractPdoTest in a few ways:

  • Per class, not per suite. Each test class opens its own session and, for an in-memory backend, creates the tables again. The in-memory database therefore must not outlive the session. The URL must not carry DB_CLOSE_DELAY=-1, and no other session may keep the database open — in particular no AbstractPdoTest in the same JVM. Otherwise, the second class fails with the CREATE SEQUENCE of the schema script. This is why this module's tests do not include a TestApplication. ApplicationTest in tentackle-test-fx-rdc covers it.
  • No transactions. There is no automatic transaction handling.
  • Tracker not started. The ModificationTracker is given the session (unless the notracker property is set) but is not started.

DbTestUtilities — model → DDL → database

DbTestUtilities (in org.tentackle.pdo.test, an @Service defaulting to itself, so it is replaceable) is the low-level bridge from the model to a live database used by the base classes:

  • createPopulateScript(Db) — loads the default model from every META-INF/MODEL-INDEX.LIST on the classpath (Model.loadFromResources) and renders a complete DDL script for the session's backend. Despite its name, it creates the schema and inserts no data: first the object sequence (if the backend supports sequences), then, for each table-providing entity not marked as provided, its database schema (if any and not yet created), its CREATE TABLE and its indexes, and finally the foreign keys.
  • runScript(Db, String) — executes that script against the low-level session.

Because the script is built from the same tentackle-model object graph the code generators use, the in-memory test schema can never disagree with the generated persistence layer.

The application's own model is included automatically, provided its PDO module packages the model like this module does (the SQL plugin's dumpModelSource + dumpModelIndex, as the project archetype sets up). Override createDatabaseTables(Db) only for model sources that are not on the classpath that way.


The Mock Delegates

The org.tentackle.pdo.mock package lets a unit test work with PDOs and operations without any persistence backend. Recall the PDO pattern: a PDO is a dynamic proxy backed by a domain delegate and a persistence delegate. The mocks supply trivial stand-ins for those delegates:

Where they live. The package is its own artifact, tentackle-pdo-mock, which depends on nothing but tentackle-pdo. It sits below the domain- and persistence layer, so those modules can use the mocks in their own unit tests — a dependency on tentackle-test-pdo would be a cycle, because this module depends on them. Downstream projects need not care: tentackle-test-pdo depends on tentackle-pdo-mock, so the classes arrive transitively under their unchanged package and class names. See Tentackle PDO Mocks for the full description of the mocks — what they implement, how to write and generate one, and the Mockito alternative.

Mock Stands in for
MockDomainObject<T,D> the domain delegate of a PersistentDomainObject
MockPersistentObject<T,P> the persistence delegate of a PersistentDomainObject (also a PdoMethodCacheProvider)
MockDomainOperation<T,D> the domain delegate of an Operation
MockPersistentOperation<T> the persistence delegate of an Operation (also an OperationMethodCacheProvider)

Most methods throw "not implemented" — the mocks deliberately implement only what a no-database test touches (id, session/context wiring, method caches), so you can exercise domain logic in isolation and get a loud failure the moment a test reaches for real persistence.


Build-Time SQL Generation

Besides shipping Java, this module's POM generates the framework's own schema and packages it as resources so downstream tests (and this module's tests) can stand up the TT tables in memory. The relevant build steps:

  • tentackle-maven-plugin (analyze, test-analyze) — the standard resource-index / analysis step. test-analyze generates the service descriptors of the test sources (e.g. the @Service of TestAuthenticationProvider) into target/generated-test-resources. Because the POM overrides <testResources>, it lists that directory (${tentackle.testServiceDir}) explicitly, just like the parent POM does.
  • tentackle-sql-maven-plugin (create, in generate-resources) — runs over the framework model on the classpath and writes into target/generated-resources/META-INF/:
  • sql/createttmodel.sql — the DDL for all framework tables, for all backends (backendNames=all), prepended with src/sql/ttheader.sql;
  • model/… + MODEL-INDEX.LIST — a dump of the model source so Model.loadFromResources(...) can reload it from the jar;
  • an object_sequence_id sequence definition (the PDO id generator).

These land under META-INF, are declared as a <resource> directory, and are therefore baked into the jar. - maven-jar-plugin — sets Automatic-Module-Name: org.tentackle.pdo.test. - maven-surefire-plugin — declares both the TestNG provider (running the src/test/testng.xml suite) and the JUnit platform provider (running the JUnit tests). Surefire runs each provider in its own forked JVM, so the two flavors never share the static session, the modification tracker or the in-memory database.

Why the framework dependencies are optional

tentackle-persistence and tentackle-domain are declared optional with the comment "don't mix up classpath order". The PDO runtime resolves the persistence and domain implementation layers via the ServiceFinder SPI, and the order in which those layers appear on the classpath matters. Marking them optional keeps this test artifact from forcing them transitively into a downstream project's classpath in the wrong position; the downstream project declares the layers it actually wants itself.


The Module's Own Tests

Although primarily a support library, the module also contains PDO tests (under src/test/java) that exercise the framework end-to-end against the in-memory H2 database — including the multi-process ones, whose database lives inside the forked server. They double as worked examples of the base classes above. Coverage spans, among others:

Area Example test(s)
PDO factory & proxy wiring PdoFactoryTest, PdoInvocationTest, PdoUtilitiesTest, IllegalModelNamesTest
Snapshots SnapshotTest (see snapshot.md)
TRIP serialization PdoComponentTripFilterTest
Model & build support ModelTest, AnalyzeClassNamesTest
Database / DBMS BatchingTest, ResultSetTest, H2ConnectionTest, SqlScriptTest, PostgresScriptRunnerTest (postgres group)
Sessions & remoting SessionPoolExecutorTest, RemoteTransactionTest, RemoteDelegatePreloadTest, RemoteAuthenticationTest, plus the out-of-JVM server in persist/server
Security & number sources SecurityTest, DefaultSecurityManagerDbTest, NumberSourceTest
Preferences RemotePreferencesTest

The pdofactory test package (Invoice/SingleInvoice/CollectiveInvoice with handwritten domain and persistence impls, an Operation and a LogIt interceptor) is a self-contained miniature PDO model used to test the factory and interceptor machinery directly, without running the code generator.

The testng.xml suite runs three groups — local, remote1, remote2 — single-threaded, over the whole org.tentackle.* package tree; the remote* groups drive the out-of-process server. A fourth group, postgres, is excluded by default and added by testng-postgres.xml under mvn -Ppostgres test.

A test method without a group never runs. Because the suite selects methods by group, a plain @Test is silently skipped. Annotate new tests with groups = "local" unless they belong in one of the others.

The JUnit base classes are covered by ClassTransactionTest, MethodTransactionTest and NoTransactionTest in org.tentackle.pdo.junit. Together they have several methods across several classes, which exercises the suite-wide session, the per-class and per-method transactions, and the rollback between methods. They are run by the JUnit platform provider, not by the TestNG suite.

Test configuration

backend.properties — the file every test picks up by default — selects the in-memory H2 database (jdbc:h2:mem:default;DB_CLOSE_DELAY=-1), so the suite is self-contained and needs no database setup. DB_CLOSE_DELAY=-1 keeps the database alive while no connection is held, which matters for the pool tests. client.properties holds the TRIP URL of the forked server (${dbService}), and backend.properties also carries the matching service= entry the server listens on.

The forked server JVMs get their own empty in-memory database, so TestServer and AddAndRemovePrefsInServer build the schema themselves in login() via InMemoryServerSchema (before configure() initializes the modification tracker, which expects the tables) — the counterpart of what AbstractPdoTest does for the test JVM.

postgres.properties (filtered from dbUrl/dbUser/dbPasswd) points at a real PostgreSQL server. Only PostgresScriptRunnerTest uses it: cancelling a running script needs a statement that blocks (PG_SLEEP) and asserts PostgreSQL's own abort message, and it is the only coverage of PostgresScriptRunner. It belongs to the postgres group, which testng.xml excludes; run it with mvn -Ppostgres test, which switches the suite to testng-postgres.xml.

Logging is routed to target/logs/test.log via tentackle-log-log4j2v because the console is contended by parallel processes on Windows.


Using It in a Downstream Project

<dependency>
  <groupId>org.tentackle</groupId>
  <artifactId>tentackle-test-pdo</artifactId>
  <version>${tentackle.version}</version>
  <scope>test</scope>
</dependency>

A typical TestNG test then looks like:

// common base class of all tests in the suite
public abstract class MyAppTest extends org.tentackle.pdo.testng.AbstractPdoTest {

  // default: one rolled-back transaction per class against the in-memory DB
  public MyAppTest() {
    super();
  }

  @Override
  protected void populateDatabase() {
    // shared fixtures, inserted once per suite after createDatabaseTables() built the schema
  }
}

public class CustomerTest extends MyAppTest {

  @Test(groups = "local")
  public void createAndReload() {
    Customer c = on(Customer.class);
    c.setName("Acme");
    c.save();
    Assert.assertNotNull(c.getId());
    // rolled back automatically at end of class
  }
}

The suite-level hooks such as populateDatabase() belong in the common base class (see Once per suite, not per class above). Choose TransactionType.METHOD (or commit = true) where the default per-class rollback doesn't fit.

The JUnit flavor looks the same, minus the groups (with MyAppTest extending org.tentackle.pdo.junit.AbstractPdoTest):

public class CustomerTest extends MyAppTest {

  @Test
  public void createAndReload() {
    Customer c = on(Customer.class);
    c.setName("Acme");
    c.save();
    Assertions.assertNotEquals(0, c.getId());
  }
}

Align the JUnit platform launcher. A JUnit project declares junit-jupiter-api and junit-jupiter-engine in test scope. Surefire's JUnit platform provider falls back to its own junit-platform-launcher. If that version differs from the Jupiter engine, discovery fails with "OutputDirectoryCreator not available; probably due to unaligned versions". Declare org.junit.platform:junit-platform-launcher with the same version as junit-jupiter-engine in test scope, too. A TestNG project declares testng. As soon as JUnit is on the test classpath as well, surefire picks only the JUnit provider. Then pin the providers to run as plugin dependencies (surefire-testng, surefire-junit-platform), as this module's POM does.

Generating Fixture Code with the PDO Browser

Writing the fixture code by hand gets tedious once tests need realistic object graphs. The PDO Browser — the browse goal of the wizard maven plugin — automates it: it connects to a live backend (typically a development or staging database), lets you navigate the persisted PDOs along their relations, and turns selected records into ready-to-paste Java code that recreates them. The recipe:

  1. Start the PDO browser from the aggregator directory: mvn tentackle-wizard:browse (the backend url and user are either configured in the POM or passed via -D).
  2. Select the PDO type in the entity combo box and load records via load, find by ID, or use finder.
  3. In the table, select the PDOs to generate Java code for.
  4. Click generate — the browser builds the Java code (on(...), the attribute setters, the component relations, a final save()) for the selected objects and copies it to the clipboard.
  5. Paste the code into the populateDatabase() override of the suite's common base class: it is invoked once, right after createDatabaseTables(Db) has built the in-memory schema, so the pasted code runs against a fresh database.
  6. Repeat for all PDOs that should be available to all tests, and then for the PDOs needed by individual test classes — those go into a @BeforeClass (JUnit: @BeforeAll) method of the respective class.

Because the generated code recreates the objects through the plain PDO API, the tests need no backend at run time — the fixtures populate the in-memory H2 database like any other test code. This makes creating CI tests with realistic data super easy: browse, select, generate, paste.