Database cleanup between tests
Integration tests that share a container-backed database — the common pattern behind
projection and saga integration tests — leave
rows behind once a test method finishes. Left alone, those rows leak into the next test: a second
BankAccount opened with the same aggregate id sees stale events, or a projection query returns rows
a previous test wrote. spring-ddd-jdbc-test provides a JUnit 5 extension that sweeps the framework’s
own tables and sequences between tests, so integration tests can run in any order against a single
reused container.
Dependency
testImplementation("de.dwittkoetter:spring-ddd-starter-jdbc-test:0.0.1-SNAPSHOT") (1)
| 1 | Re-exports spring-ddd-jdbc-test — the cleanup extension and the ManagedRelationalSchema SPI —
behind one test dependency. Package de.dwittkoetter.ddd.jdbc.test. |
Enabling cleanup
Annotate the test class with @EnableDatabaseCleanup, choosing when the sweep runs:
import de.dwittkoetter.ddd.jdbc.test.CleanupMode
import de.dwittkoetter.ddd.jdbc.test.EnableDatabaseCleanup
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
@EnableDatabaseCleanup(CleanupMode.PER_METHOD) (1)
class BankAccountIntegrationTest(
@Autowired private val repository: BankAccountRepository,
) {
@Test
fun `opens an account`() {
// ...
}
}
| 1 | PER_METHOD sweeps after every test method; PER_CLASS sweeps once after the last method in the
class. There is no third mode — a test class that is not annotated is not swept at all. |
There is no configuration property to opt in globally: cleanup is annotation-only, per class, so a test suite can mix classes that need isolation with classes that do not.
Why the extension exists
Cleaning the database between tests looks trivial — truncate the tables and move on — so it is fair to ask why a dedicated extension is needed at all. You could truncate (or drop and recreate) the framework’s tables yourself. The catch is that the event store is more than tables, and the part that is easy to overlook is the part that bites.
Between tests the extension does two things for every relational object the framework owns:
-
Truncates the tables — the event store and its snapshots, the saga and projection tables, the read models.
-
Resets the sequences — notably the standalone
EVENT_GLOBAL_POSITIONsequence.
The second step is the one manual cleanup usually forgets. Truncating a table empties its rows but leaves
a standalone sequence climbing. On the next test the event store then hands out high global positions
while a freshly seeded projection cursor sits near zero. The catch-up frontier resolves the resulting
leading gap promptly — the missing positions belong to already-ended transactions — but letting the
sequence climb across tests leaves global positions unbounded and each test’s state harder to reason
about. Resetting the sequence alongside the truncation is what keeps each test starting from a clean,
consistent baseline. The reset also has to follow each database’s own syntax (a real ALTER SEQUENCE on PostgreSQL,
H2, HSQLDB and SQL Server, but a counter-row update on MySQL and MariaDB, where the global position is an
allocator table rather than a sequence).
So if you choose to clean the database yourself instead of enabling the extension, you must reproduce everything it does — truncate every framework table and reset every framework sequence, in the right dialect — not just the tables you can see. The extension already knows the full set of objects each persistence module owns and how to reset them on every supported database, and keeps that in sync as the framework evolves.
The extension is additive and defensive, so it also cooperates with cleanup you run yourself. It is
a JUnit 5 extension, so your own @ExtendWith, @BeforeEach/@AfterEach, or @Sql cleanup runs
alongside it, not instead of it; its truncate and reset statements are idempotent (re-running them on
already-empty objects is harmless); and if one of its reported objects is currently absent — for example
a table your own cleanup just dropped and has not yet recreated — that object is skipped and the sweep
continues. It is recreated and swept normally on the next run.
|
Consumer-safe by construction
The sweep only ever touches relational objects the framework itself reports owning: the event store and
its snapshot tables, the shared EVENT_GLOBAL_POSITION sequence, saga tables together with the
deadline/lock/checkpoint tables, projection bookkeeping, the JSON PROJECTION_STORE plus any dedicated
read-model tables, and @JpaProjection entity tables (including @SecondaryTable mappings). It never
truncates a table your application defines — there is no name pattern or heuristic involved, only an
explicit report of ownership (see the next section).
| Because the framework never touches unreported tables, it is safe to enable cleanup on a test class that also writes to your own application tables — only the rows the framework put there are swept. |
Background workers are paused for the sweep
Projections, partition coordinators and saga workers poll on their own threads, so without care a sweep
would truncate tables out from under a poll that is mid-pass. That is not a harmless collision: a
truncate holds an exclusive lock, and a drain that fails against it records a durable halt under the
default OnError.HALT policy — which stops that projection for the rest of the process. The damage then
shows up in a later test, as a read model that quietly stopped updating, with nothing pointing back at
the cleanup that caused it.
The extension therefore stops the framework’s background workers before it truncates and restarts them afterwards. Workers a test deliberately stopped stay stopped, and so do any that fail to stop — restarting one that is still running would leave two of them competing for the same lease.
If your application has its own scheduled component that writes to framework tables during tests, let it
implement BackgroundWorker and it is quiesced alongside the framework’s own:
@Component
class NightlyReconciliation : BackgroundWorker { (1)
@Volatile private var running = false
override fun start() { running = true }
override fun stop() { running = false }
override fun isRunning(): Boolean = running
}
| 1 | BackgroundWorker extends SmartLifecycle; pause() and resume() default to stop() and
start(), so implementing the lifecycle is all that is required. |
Pausing is best-effort quiescence rather than a barrier: it stops a worker taking on new work, and how
much in-flight work it waits for is that worker’s own stop(). Override pause() if yours can offer a
stronger guarantee. Note also that the cycle is not state-preserving — a worker may rebuild itself on
restart, and the framework’s consumer and coordinator managers do.
Reporting custom or ad-hoc tables
Cleanup is driven entirely by ManagedRelationalSchema beans on the Spring context: each JDBC
persistence module the application actually uses contributes one, reporting its own tables() and
sequences(). A module the application does not use contributes none, so cleanup automatically scopes
itself to what the test context actually assembles.
If a test relies on a table the framework does not auto-configure — a hand-rolled table, or a projection
whose auto-configuration was deliberately excluded from a minimal test context — declare an extra
ManagedRelationalSchema bean so the sweep includes it too. It is a small interface: report the tables
and sequences your test owns, and the datasource they live in.
import de.dwittkoetter.ddd.annotation.ManagedRelationalSchema
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import javax.sql.DataSource
@TestConfiguration
class AccountSummaryTestConfig {
@Bean
fun accountSummaryManagedSchema(dataSource: DataSource): ManagedRelationalSchema =
object : ManagedRelationalSchema {
override fun tables() = setOf("ACCOUNT_SUMMARY_PROJECTION") (1)
override fun sequences() = emptySet<String>()
override fun dataSource() = dataSource (2)
}
}
| 1 | Reports the AccountSummaryProjection read-model table so it is truncated alongside the framework’s
own tables; add any sequence names to sequences(). |
| 2 | The datasource these objects live in — the sweep runs against exactly this one, so a second datasource in the same test is left untouched. |
Supported databases
The sweep auto-detects the dialect from the JDBC connection and works against PostgreSQL, H2, MySQL,
MariaDB, and SQL Server. On MySQL and MariaDB, which have no native sequence object, the shared
global-position allocator is reset by resetting its counter row rather than by an ALTER SEQUENCE.
Connection pooling in container-backed tests
When many @SpringBootTest classes run against one shared, container-backed database, Spring’s test
context cache keeps several application contexts — and their Hikari pools — alive at once. With Hikari’s
default minimum-idle (which equals the maximum pool size) every cached context holds its connections
open, and together those pools can exhaust the database’s connection limit under load and stall the
suite. Set
spring.datasource.hikari.minimum-idle=0
in your test configuration so idle connections are released between tests. This is the one pool setting worth applying to a container-backed integration suite; Hikari’s other defaults are fine.