Database cleanup between tests
Integration tests that share one container-backed database leave data behind when a test method finishes.
Left alone, that data leaks into the next test: a BankAccount opened again with the same id sees the events of an earlier test, or a query returns read models an earlier test wrote.
@EnableDatabaseCleanup empties the data Spring DDD keeps between tests, so integration tests run in any order against one reused container.
Dependency
testImplementation("de.dwittkoetter:spring-ddd-starter-test:0.0.1-SNAPSHOT") (1)
| 1 | Brings every Spring DDD test module, spring-ddd-jdbc-test with the cleanup among them.
It adds test classes only: spring-jdbc comes from the application’s own JDBC or JPA dependencies. |
Enabling cleanup
Annotate the test class with @EnableDatabaseCleanup, from de.dwittkoetter.ddd.jdbc.test.annotation, and choose when the cleanup runs with CleanupMode, from de.dwittkoetter.ddd.jdbc.test:
@SpringBootTest
@EnableDatabaseCleanup(CleanupMode.PER_METHOD) (1)
class BankAccountIntegrationTest(
@Autowired private val repository: BankAccountRepository,
) {
@Test
fun `opens an account`() {
// ...
}
}
| 1 | PER_METHOD, the default, cleans up after every test method; PER_CLASS cleans up once, after the last method of the class.
A test class without the annotation is not cleaned up. |
There is no property that turns cleanup on for every test class: each class opts in, so a suite can mix classes that need isolation with classes that do not.
What a cleanup resets
A cleanup empties everything Spring DDD stores for the parts of it the test context uses:
-
the stored events and snapshots;
-
the captured messages and the read-model topics;
-
the read models of every
@QueryProjection; -
the saga instances, their deadlines and their ordered-delivery progress;
-
every consumer’s position, its undelivered messages, and its pause or halt;
-
the framework’s own bookkeeping: which instance applies which projection, the running instances, and the progress of a rebuild.
Numbering restarts as well, so every test starts from the same baseline: the first event a test appends gets the same position in every run. Emptying the tables you can see does not reproduce this by hand.
A cleanup combines with cleanup of your own: your @AfterEach, @ExtendWith or @Sql cleanup runs alongside it, and running both on data that is already gone does no harm.
An object your own cleanup dropped and has not created again yet is skipped.
Any other failure, such as a lock wait timeout, fails the test, since the data it should have removed is still there.
Only framework data
A cleanup empties only what Spring DDD reports as its own; it never empties a table the application defines. Nothing is matched by name: each part of Spring DDD the test context uses reports its own tables, and a part the context does not use reports none. A test class may therefore write to the application’s own tables as well, and their rows stay.
The read models of a @JpaProjection are the application’s own entities, so a cleanup leaves their table alone; report it as shown under Your own tables.
Background work pauses
Spring DDD’s consumers and schedules run on their own threads.
A cleanup stops them before it empties anything and starts them again after it, so no poll meets a half-emptied database.
Work a test stopped stays stopped.
Every consumer runs again after a cleanup, since its pause or halt is gone.
An @InMemoryProjection starts again empty and running, even after a pause or a halt, so it holds nothing an earlier test wrote.
If your application has a scheduled component of its own that writes Spring DDD’s data during tests, implement BackgroundWorker, from de.dwittkoetter.ddd.annotation, and the cleanup stops and starts it with 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 and adds nothing, so implementing the lifecycle is all it takes. |
How long a cleanup waits for work in flight is up to that component’s stop().
A worker whose stop() failed is not started again, since it may still be running.
A worker whose restart fails fails the test, after every other worker has been started.
A stop and a start need not keep the component’s state: it may start again from scratch.
Your own tables
Report a table Spring DDD does not create with a ManagedRelationalSchema bean, from de.dwittkoetter.ddd.annotation, and the cleanup empties it too: the table of a `@JpaProjection’s read models, a table of your own that a test fills, or one whose auto-configuration a test context leaves out.
@TestConfiguration
class AccountSummaryTestConfig {
@Bean
fun accountSummaryManagedSchema(dataSource: DataSource): ManagedRelationalSchema =
object : ManagedRelationalSchema {
override fun tables() = setOf("ACCOUNT_SUMMARY") (1)
override fun sequences() = emptySet<String>() (2)
override fun dataSource() = dataSource (3)
}
}
| 1 | The tables to empty: here the table of the AccountSummary read model.
A name may be schema-qualified, schema.table. |
| 2 | The sequences to restart at their start value. On MySQL and MariaDB a name here is reset as a counter table, so reporting a native MariaDB sequence fails the cleanup, and with it the test. |
| 3 | The DataSource these objects live in.
The cleanup runs against exactly this one, so a second DataSource in the same test is left alone. |
Two more lists default to none:
counters()-
Single-row
(id, val)tables that stand in for a sequence. Each is reset toval = 0instead of emptied. referencedTables()-
Tables another reported table references by foreign key. No database truncates such a table, so the cleanup deletes its rows after the tables referencing it are emptied. List a table in
tables()or inreferencedTables(), never in both: truncating a table that another table references fails the cleanup.
Supported databases
Cleanup works on PostgreSQL, MySQL, MariaDB, SQL Server, H2 and HSQLDB, and tells which one from the connection; on any other database it fails.
Several contexts on one database
A projection that keeps its read models in the database, a @QueryProjection or a @JpaProjection, is applied by one application context at a time, and every context on the database counts as an instance.
When several contexts use one database at the same time, test classes running in parallel or several Gradle test JVMs against one reused Testcontainers container, only one of them applies such a projection.
A test in another context that waits for that projection’s read model times out, although the same test passes on its own.
An @InMemoryProjection runs in every context and is not affected.
Give every JVM that runs such tests at the same time a database of its own.
Within one JVM, a context that the test context cache pauses hands its projections over at once, and the context used next takes them over within one spring.ddd.messaging.consumer.rebalance-interval.
With the default of 10s, every switch between cached contexts can cost a suite up to ten seconds, so shorten the interval in your test configuration, and let a test that waits for such a projection allow for one rebalance-interval:
spring.ddd.messaging.consumer.rebalance-interval=1s
spring.ddd.messaging.consumer.rebalance-interval, default10s-
How soon the context used next takes over a paused context’s projections. It must stay below
stale-instance-timeout. spring.ddd.messaging.consumer.stale-instance-timeout, default30s-
How long a context that ended without handing over keeps its projections. It does not lengthen the wait above. At least one second.
Pause unused contexts at once
By default the Spring test context cache pauses an unused context only when a test class asks for a different context (spring.test.context.cache.pause=on_context_switch).
Until then the context’s consumers keep running through every following test class that has no Spring context: the context still counts as a live instance, keeps its projections, and keeps writing to the shared database.
Set the pause mode to always, so a context is paused the moment its last test class finishes and started again when a later class uses it:
// build.gradle.kts
tasks.withType<Test>().configureEach {
systemProperty("spring.test.context.cache.pause", "always")
}
The property is read through SpringProperties, so a -D system property or a spring.properties file on the test classpath sets it; application.properties does not.
With always, the last cleanup of a test class stops the background work, empties the data and leaves the work stopped; the context starts it again when a later class uses it.
Under the default mode the cleanup starts the work again at once, because nothing else would, and the data it writes right away outlives the class.
The last cleanup of a class assumes that the classes sharing a context run one after another, as they must when they share its database.
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.