RowCountCheck
Overview
RowCountCheck
(org.dbunit.database.rowcount, since 3.6.0) is an opt-in diagnostic that compares every table’s
row count before and after a test, and fails the test when a count moved. It catches two
mistakes that are otherwise silent at the point they happen and only surface later, in an
unrelated test:
| Mistake | Consequence |
|---|---|
| Under-listing — a table the code under test writes to is missing from the prep/expected dataset | Its rows survive teardown. A later, unrelated test fails on the extra data, naming the wrong test and starting the debugging in the wrong place. |
| Over-listing — a reference table is listed that should never be cleaned | The teardown operation strips rows the DDL seeded. Every later test that depends on that reference data fails in ways that look nothing like the cause. |
Both are the same underlying defect: the database did not return to the state the test inherited, and nothing checked that — until now.
What It Detects, and What It Does Not
The check compares COUNT() per table, captured fresh before the test and compared again
after teardown. That catches every row added or removed, regardless of *how — application
code, triggers, ON DELETE CASCADE, stored procedures, or a DataSource the test never
sees. It is immune to the mechanism because it reads the end state, not the statements that
produced it.
It does not catch:
- An
UPDATEthat changes values without changing the row count. - An insert-N/delete-N sequence that nets to zero.
Neither leaves extra or missing rows, which is the problem this check solves.
Enabling It
The check is off by default and read-only — it never modifies data. Turn it on to verify a suite’s teardown correctness; leave it off for routine runs, since capturing two snapshots per test roughly doubles that test’s table-counting cost.
Enable it via the FEATURE_ROW_COUNT_CHECK feature:
DatabaseConfig config = connection.getConfig();
config.setFeature(DatabaseConfig.FEATURE_ROW_COUNT_CHECK, true);or, without touching code, via a system property — which wins over the feature in either direction, so it can force-enable in CI or force-disable locally regardless of what the code configures:
-Ddbunit.rowCountCheck=trueIt is wired into both PrepAndExpectedTestCase
and DbUnitExtension; enabling the feature (or the
system property) is everything a test needs to do. Both integration points share the same
org.dbunit.database.rowcount.RowCountChecker, the class that captures a baseline, verifies
it later, and lets it be discarded when a verify would be noise (e.g. the test’s own steps
already failed) — plumbing a test integration reuses, not something a test author calls
directly.
On JUnit 5/6 (Jupiter), @DbUnitRowCountCheck turns the check on per class or method instead
of touching DatabaseConfig — see
the DbUnit Annotations page for its
attributes and precedence against the feature and system property described above.
Excluding Tables
Some tables legitimately change and can never be cleaned back to a fixed baseline:
- Append-only audit and log tables that nothing cleans.
- Sequence-emulation tables (a
NEXT_ID/HIBERNATE_SEQUENCEStable) that change on every insert by design. - Tables owned by a different lifecycle than the test’s.
- A very large, static reference table where
COUNT(*)itself is what makes the run slow — a performance exclusion, not a correctness one.
Configure patterns — supporting the same */? wildcards as
ExcludeTableFilter — via
PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES:
config.setProperty(DatabaseConfig.PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES,
new String[] {"AUDIT_*", "HIBERNATE_SEQUENCES"});or the matching system property, which replaces rather than appends to any configured patterns:
-Ddbunit.rowCountCheckExcludeTables=AUDIT_*,HIBERNATE_SEQUENCESor @DbUnitRowCountCheck(exclude = {"AUDIT_*", "HIBERNATE_SEQUENCES"}) — see
DbUnit Annotations.
Reading a Failure
Row count check failed: 2 tables differ from the pre-test baseline.
ACCOUNT_AUDIT 0 -> 3 (+3) rows left behind; add the table to the expected dataset, or exclude it
COUNTRY_CODE 12 -> 0 (-12) rows removed that should remain; drop the table from the prep/expected dataset, or exclude itEach line names the table in the exact form getTableNames() returned it, so it can be
pasted straight into a dataset or an exclude list, and the delta’s sign tells you which fix
applies:
- Positive — rows were left behind. Add the table to the expected dataset (so teardown
cleans it) — with
DefaultPrepAndExpectedTestCase, that also means adding a matchingVerifyTableDefinition, since an expected table with none fails verification on its own by default — or exclude it if it legitimately can’t be cleaned. - Negative — rows that should remain were removed. Drop the table from the prep/expected dataset (so teardown never touches it), or exclude it.
Supplying Your Own RowCounter
Counting is the expensive part of the check, so it is the one seam left open:
RowCounter, registered like
every other dbUnit extension point via
DatabaseConfig.PROPERTY_ROW_COUNTER. The shipped
implementation, QueryPerTableRowCounter, issues one SELECT COUNT(*) per table.
A RowCounter implementation’s contract:
- Return an entry for every requested table name, keyed exactly as supplied.
- Return entries for no other tables.
- Counts must be exact — a vendor statistics view (e.g. PostgreSQL’s
pg_stat_user_tables) lags asynchronously and would produce false failures, however cheap it is to query.
Table enumeration and exclusion filtering happen in RowCountCheck itself, outside the
strategy, so an implementation’s whole job is counting the list it is handed:
public interface RowCounter
{
Map<String, Integer> countRows(IDatabaseConnection connection, List<String> tableNames)
throws SQLException;
}An illustrative sketch, not production-ready — batching every table into one query per
round trip instead of one round trip per table. It embeds table names directly into both a
FROM identifier and a string literal; a real implementation has to correctly quote and
escape both for every supported vendor before this is safe to use as-is:
public class UnionAllRowCounter implements RowCounter
{
@Override
public Map<String, Integer> countRows(IDatabaseConnection connection,
List<String> tableNames) throws SQLException
{
Map<String, Integer> rowCounts = new LinkedHashMap<>();
if (tableNames.isEmpty())
{
return rowCounts;
}
String sql = tableNames.stream()
.map(name -> "SELECT '" + name + "' AS t, COUNT(*) AS c FROM " + name)
.collect(Collectors.joining(" UNION ALL "));
try (Statement statement = connection.getConnection().createStatement();
ResultSet resultSet = statement.executeQuery(sql))
{
while (resultSet.next())
{
rowCounts.put(resultSet.getString("t"), resultSet.getInt("c"));
}
}
return rowCounts;
}
}Register it the same way as any other DatabaseConfig property:
config.setProperty(DatabaseConfig.PROPERTY_ROW_COUNTER, new UnionAllRowCounter());Caveats
- Sequences and identity columns are not reset. Deleting leftover rows never resets them, so generated IDs still drift across tests even when counts balance.
- A row-removing teardown operation is presumed. With
DatabaseOperation.NONE, every prep row reads as a difference. - Modified rows are invisible — see What It Detects, and What It Does Not.
- Parallel tests against one database break the check, as they already break
DELETE_ALLteardown. Not specific to this check.


