Add repo-root README.adoc, rendered natively by GitHub via Asciidoctor, so the repository landing page shows a pitch, build/reproducible-build badges, a pointer to the "dbUnit in 5 Minutes" tutorial, and links to the documentation site, Maven coordinates, GitHub Discussions, and CONTRIBUTING.md instead of nothing.
diff --git a/src/main/java/org/dbunit/DefaultPrepAndExpectedTestCase.java b/src/main/java/org/dbunit/DefaultPrepAndExpectedTestCase.java
index d3b0400c9..0ed3a4edc 100644
--- a/src/main/java/org/dbunit/DefaultPrepAndExpectedTestCase.java
+++ b/src/main/java/org/dbunit/DefaultPrepAndExpectedTestCase.java
@@ -34,6 +34,8 @@
import org.dbunit.assertion.comparer.value.ValueComparer;
import org.dbunit.database.DatabaseConfig;
import org.dbunit.database.IDatabaseConnection;
+import org.dbunit.database.rowcount.RowCountCheck;
+import org.dbunit.database.rowcount.RowCountChecker;
import org.dbunit.dataset.Column;
import org.dbunit.dataset.CompositeDataSet;
import org.dbunit.dataset.DataSetException;
@@ -132,6 +134,15 @@ public class DefaultPrepAndExpectedTestCase extends DBTestCase
*/
private Boolean cachedIsCaseSensitiveTableNames;
+ /**
+ * Manages the row count check baseline used by preTest() and cleanupData() to detect a
+ * table the test left dirty - either one it should have cleaned up and did not, or a
+ * reference table it wrongly cleaned.
+ *
+ * @since 3.6.0
+ */
+ private final RowCountChecker rowCountChecker = new RowCountChecker();
+
private ExpectedDataSetAndVerifyTableDefinitionVerifier expectedDataSetAndVerifyTableDefinitionVerifier =
new DefaultExpectedDataSetAndVerifyTableDefinitionVerifier();
@@ -425,13 +436,40 @@ public void operationTearDownFinished(
/**
* {@inheritDoc}
+ *
+ * Captures the row count check baseline, if enabled, before setting up the prep data - see
+ * {@link RowCountCheck#capture(IDatabaseConnection)}.
*/
@Override
public void preTest() throws Exception
{
+ captureRowCountBaseline();
setupData();
}
+ /**
+ * Capture the row count check baseline, using the connection shared with the rest of this
+ * test's lifecycle. A no-op that leaves no baseline captured when the check is disabled.
+ *
+ * @throws Exception On dbUnit errors.
+ * @since 3.6.0
+ */
+ private void captureRowCountBaseline() throws Exception
+ {
+ final boolean acquiredConnectionHere = connection == null;
+ try
+ {
+ rowCountChecker.capture(getReusableConnection());
+ } catch (final Exception e)
+ {
+ if (acquiredConnectionHere)
+ {
+ closeReusableConnectionSuppressing(e);
+ }
+ throw e;
+ }
+ }
+
/**
* {@inheritDoc}
*/
@@ -507,6 +545,11 @@ public void postTest() throws Exception
/**
* {@inheritDoc}
+ *
+ * When {@code verifyData} is false - the test steps already failed - discards any
+ * captured row count check baseline, so cleanupData() skips that check too: the database
+ * is in an unknown state, so a count difference would be noise around the real failure,
+ * not a finding worth its own report.
*/
@Override
public void postTest(final boolean verifyData) throws Exception
@@ -517,6 +560,9 @@ public void postTest(final boolean verifyData) throws Exception
if (verifyData)
{
verifyData();
+ } else
+ {
+ rowCountChecker.discardBaseline();
}
} catch (final Throwable t)
{
@@ -585,6 +631,7 @@ public void cleanupData() throws Exception
makeReusableConnectionDatabaseTester(dataset);
reusableTester.onTearDown();
log.debug("cleanupData: Clean up done");
+ verifyRowCountUnchanged();
closeReusableConnection();
} catch (final Exception e)
{
@@ -594,6 +641,21 @@ public void cleanupData() throws Exception
}
}
+ /**
+ * Verify the row count check baseline, if one was captured, using the connection shared
+ * with the rest of this test's lifecycle. A no-op when no baseline was captured - the
+ * check is disabled, capture never ran, or {@link #postTest(boolean)} discarded it
+ * because the test steps already failed.
+ *
+ * @throws Exception On dbUnit errors, including {@link org.dbunit.database.rowcount.UnexpectedRowCountException}
+ * when a table's row count no longer matches the baseline.
+ * @since 3.6.0
+ */
+ private void verifyRowCountUnchanged() throws Exception
+ {
+ rowCountChecker.verify(getReusableConnection());
+ }
+
/**
* Legacy JUnit-3-era tear-down hook. Not invoked automatically under JUnit 5;
* kept for subclasses that drive the lifecycle manually. Calling it after a
@@ -1488,6 +1550,34 @@ public void setFailureHandler(final FailureHandler failureHandler)
this.failureHandler = failureHandler;
}
+ /**
+ * Get the RowCountCheck in use.
+ *
+ * @see #rowCountChecker
+ *
+ * @return The RowCountCheck, or null if none has been resolved or set yet.
+ * @since 3.6.0
+ */
+ public RowCountCheck getRowCountCheck()
+ {
+ return rowCountChecker.getRowCountCheck();
+ }
+
+ /**
+ * Set the RowCountCheck, overriding the one otherwise lazily built from the shared
+ * connection's DatabaseConfig.
+ *
+ * @see #rowCountChecker
+ *
+ * @param rowCountCheck
+ * The RowCountCheck to use.
+ * @since 3.6.0
+ */
+ public void setRowCountCheck(final RowCountCheck rowCountCheck)
+ {
+ rowCountChecker.setRowCountCheck(rowCountCheck);
+ }
+
/**
* {@link IDatabaseTester} that runs setUp/tearDown operations against a
* connection supplied by the given {@link Callable} instead of calling
diff --git a/src/main/java/org/dbunit/database/DatabaseConfig.java b/src/main/java/org/dbunit/database/DatabaseConfig.java
index 6443a06a6..79a501aff 100644
--- a/src/main/java/org/dbunit/database/DatabaseConfig.java
+++ b/src/main/java/org/dbunit/database/DatabaseConfig.java
@@ -28,6 +28,8 @@
import java.util.Properties;
import org.dbunit.DatabaseUnitException;
+import org.dbunit.database.rowcount.QueryPerTableRowCounter;
+import org.dbunit.database.rowcount.RowCounter;
import org.dbunit.database.statement.IStatementFactory;
import org.dbunit.database.statement.PreparedStatementFactory;
import org.dbunit.dataset.datatype.DefaultDataTypeFactory;
@@ -89,6 +91,12 @@ public class DatabaseConfig
/** Name of the property configuring the {@link IColumnFilter} used to determine MS SQL identity columns. */
public static final String PROPERTY_IDENTITY_COLUMN_FILTER =
"http://www.dbunit.org/properties/mssql/identityColumnFilter";
+ /** Name of the property configuring the table name patterns excluded from the row count check. */
+ public static final String PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES =
+ "http://www.dbunit.org/properties/rowCountCheckExcludeTables";
+ /** Name of the property configuring the {@link RowCounter} implementation to use. */
+ public static final String PROPERTY_ROW_COUNTER =
+ "http://www.dbunit.org/properties/rowCounter";
/** Name of the feature controlling whether table names are treated as case sensitive. */
public static final String FEATURE_CASE_SENSITIVE_TABLE_NAMES =
@@ -120,9 +128,15 @@ public class DatabaseConfig
*/
public static final String FEATURE_SKIP_CYCLE_CHECK =
"http://www.dbunit.org/features/skipCycleCheck";
+ /**
+ * Name of the feature controlling whether table row counts are compared before and after
+ * each test.
+ */
+ public static final String FEATURE_ROW_COUNT_CHECK =
+ "http://www.dbunit.org/features/rowCountCheck";
/**
- * A list of all properties as {@link ConfigProperty} objects.
+ * A list of all properties as {@link ConfigProperty} objects.
* The objects contain the allowed java type and whether or not a property is nullable.
*/
public static final ConfigProperty[] ALL_PROPERTIES = new ConfigProperty[] {
@@ -145,6 +159,9 @@ public class DatabaseConfig
new ConfigProperty(PROPERTY_ALLOW_VERIFYTABLEDEFINITION_EXPECTEDTABLE_COUNT_MISMATCH, Boolean.class, false),
new ConfigProperty(FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY, Boolean.class, false),
new ConfigProperty(FEATURE_SKIP_CYCLE_CHECK, Boolean.class, false),
+ new ConfigProperty(PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES, String[].class, false),
+ new ConfigProperty(PROPERTY_ROW_COUNTER, RowCounter.class, false),
+ new ConfigProperty(FEATURE_ROW_COUNT_CHECK, Boolean.class, false),
};
/**
@@ -159,7 +176,8 @@ public class DatabaseConfig
FEATURE_SKIP_ORACLE_RECYCLEBIN_TABLES,
FEATURE_ALLOW_EMPTY_FIELDS,
FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY,
- FEATURE_SKIP_CYCLE_CHECK
+ FEATURE_SKIP_CYCLE_CHECK,
+ FEATURE_ROW_COUNT_CHECK
};
private static final DefaultDataTypeFactory DEFAULT_DATA_TYPE_FACTORY =
@@ -172,6 +190,9 @@ public class DatabaseConfig
private static final String[] DEFAULT_TABLE_TYPE = {"TABLE"};
private static final Integer DEFAULT_BATCH_SIZE = 100;
private static final Integer DEFAULT_FETCH_SIZE = 100;
+ private static final String[] DEFAULT_ROW_COUNT_CHECK_EXCLUDE_TABLES = new String[0];
+ private static final QueryPerTableRowCounter DEFAULT_ROW_COUNTER =
+ new QueryPerTableRowCounter();
@@ -192,6 +213,7 @@ public DatabaseConfig()
setFeature(FEATURE_ALLOW_EMPTY_FIELDS, false);
setFeature(FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY, false);
setFeature(FEATURE_SKIP_CYCLE_CHECK, false);
+ setFeature(FEATURE_ROW_COUNT_CHECK, false);
setProperty(PROPERTY_STATEMENT_FACTORY, PREPARED_STATEMENT_FACTORY);
setProperty(PROPERTY_RESULTSET_TABLE_FACTORY, RESULT_SET_TABLE_FACTORY);
@@ -204,6 +226,9 @@ public DatabaseConfig()
setProperty(
PROPERTY_ALLOW_VERIFYTABLEDEFINITION_EXPECTEDTABLE_COUNT_MISMATCH,
Boolean.FALSE);
+ setProperty(PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES,
+ DEFAULT_ROW_COUNT_CHECK_EXCLUDE_TABLES);
+ setProperty(PROPERTY_ROW_COUNTER, DEFAULT_ROW_COUNTER);
this.configurator = new Configurator(this);
}
diff --git a/src/main/java/org/dbunit/database/rowcount/QueryPerTableRowCounter.java b/src/main/java/org/dbunit/database/rowcount/QueryPerTableRowCounter.java
new file mode 100644
index 000000000..6b01fb2e5
--- /dev/null
+++ b/src/main/java/org/dbunit/database/rowcount/QueryPerTableRowCounter.java
@@ -0,0 +1,60 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import java.sql.SQLException;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.dbunit.database.DatabaseConfig;
+import org.dbunit.database.IDatabaseConnection;
+
+/**
+ * {@link RowCounter} that issues one SELECT COUNT(*) per table, by
+ * looping {@link IDatabaseConnection#getRowCount(String)}. Already handles schema
+ * qualification and {@link DatabaseConfig#PROPERTY_ESCAPE_PATTERN} through
+ * {@link org.dbunit.util.QualifiedTableName}, and is exercised across every
+ * supported database by that method's own tests.
+ *
+ * This is the {@link RowCountCheck} v1 implementation, and the value
+ * {@link DatabaseConfig#PROPERTY_ROW_COUNTER} initialises to.
+ *
+ * @author dbunit
+ * @since 3.6.0
+ */
+public class QueryPerTableRowCounter implements RowCounter
+{
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Map countRows(final IDatabaseConnection connection,
+ final List tableNames) throws SQLException
+ {
+ final Map rowCounts = new LinkedHashMap<>();
+ for (final String tableName : tableNames)
+ {
+ rowCounts.put(tableName, connection.getRowCount(tableName));
+ }
+ return rowCounts;
+ }
+}
diff --git a/src/main/java/org/dbunit/database/rowcount/RowCountCheck.java b/src/main/java/org/dbunit/database/rowcount/RowCountCheck.java
new file mode 100644
index 000000000..2ebda27e4
--- /dev/null
+++ b/src/main/java/org/dbunit/database/rowcount/RowCountCheck.java
@@ -0,0 +1,127 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.dbunit.DatabaseUnitException;
+import org.dbunit.database.IDatabaseConnection;
+import org.dbunit.dataset.filter.ExcludeTableFilter;
+
+/**
+ * Compares a database's table row counts before and after a test, to catch a table the test
+ * left dirty - either one it should have cleaned up and did not, or a reference table it wrongly
+ * cleaned. Read-only: never modifies data.
+ *
+ * Every method is a no-op, and never queries the connection, when
+ * {@link RowCountCheckConfiguration#isEnabled()} is {@code false} - the check costs nothing
+ * when a caller has not opted in.
+ *
+ * @author dbunit
+ * @since 3.6.0
+ */
+public class RowCountCheck
+{
+ private final RowCountCheckConfiguration configuration;
+
+ /**
+ * Creates a check using the given configuration.
+ *
+ * @param configuration resolves whether the check is enabled, the excluded table patterns,
+ * and the {@link RowCounter} to use.
+ */
+ public RowCountCheck(final RowCountCheckConfiguration configuration)
+ {
+ this.configuration = configuration;
+ }
+
+ /**
+ * Captures the baseline row counts to later {@link #verify(RowCountSnapshot, IDatabaseConnection)}
+ * against, of every table {@code connection} exposes that survives the configured exclude
+ * patterns.
+ *
+ * @param connection the connection to capture the baseline from.
+ * @return the baseline snapshot, or {@code null} when the check is disabled.
+ * @throws DatabaseUnitException if enumerating or filtering the tables fails.
+ * @throws SQLException if counting a table's rows fails.
+ */
+ public RowCountSnapshot capture(final IDatabaseConnection connection)
+ throws DatabaseUnitException, SQLException
+ {
+ if (!configuration.isEnabled())
+ {
+ return null;
+ }
+ return snapshot(connection);
+ }
+
+ /**
+ * Verifies that {@code connection}'s current row counts still match {@code baseline}.
+ * A no-op that never queries the connection when the check is disabled or when
+ * {@code baseline} is {@code null} - the latter meaning no baseline was captured, e.g.
+ * because the caller intentionally skipped {@link #capture(IDatabaseConnection)}.
+ *
+ * @param baseline the baseline to compare against; {@code null} to skip the check silently.
+ * @param connection the connection to read the current row counts from.
+ * @throws DatabaseUnitException if enumerating or filtering the tables fails, or if any
+ * table's row count no longer matches the baseline
+ * ({@link UnexpectedRowCountException}).
+ * @throws SQLException if counting a table's rows fails.
+ */
+ public void verify(final RowCountSnapshot baseline, final IDatabaseConnection connection)
+ throws DatabaseUnitException, SQLException
+ {
+ if (!configuration.isEnabled() || baseline == null)
+ {
+ return;
+ }
+
+ final RowCountSnapshot current = snapshot(connection);
+ final List differences = baseline.difference(current);
+ if (!differences.isEmpty())
+ {
+ throw new UnexpectedRowCountException(differences);
+ }
+ }
+
+ private RowCountSnapshot snapshot(final IDatabaseConnection connection)
+ throws DatabaseUnitException, SQLException
+ {
+ final String[] allTableNames = connection.createDataSet().getTableNames();
+ final ExcludeTableFilter excludeTableFilter = configuration.getExcludeTableFilter();
+
+ final List tableNames = new ArrayList<>();
+ for (final String tableName : allTableNames)
+ {
+ if (excludeTableFilter.isValidName(tableName))
+ {
+ tableNames.add(tableName);
+ }
+ }
+
+ final Map rowCounts =
+ configuration.getRowCounter().countRows(connection, tableNames);
+ return new RowCountSnapshot(rowCounts);
+ }
+}
diff --git a/src/main/java/org/dbunit/database/rowcount/RowCountCheckConfiguration.java b/src/main/java/org/dbunit/database/rowcount/RowCountCheckConfiguration.java
new file mode 100644
index 000000000..23982317c
--- /dev/null
+++ b/src/main/java/org/dbunit/database/rowcount/RowCountCheckConfiguration.java
@@ -0,0 +1,139 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import org.dbunit.database.DatabaseConfig;
+import org.dbunit.dataset.filter.ExcludeTableFilter;
+
+/**
+ * Resolves whether the row count check is enabled, its excluded table patterns, and the
+ * {@link RowCounter} to use, from a {@link DatabaseConfig} and the {@code dbunit.*} system
+ * property overrides.
+ *
+ * Precedence, for both the enabled flag and the exclude patterns: the system property, when
+ * present, wins in either direction, so it can force-enable in CI and force-disable locally.
+ * Absent, resolution falls through to the {@link DatabaseConfig} value; absent there too, the
+ * enabled flag defaults to {@code false}. The exclude system property, when present, replaces
+ * rather than appends to the configured patterns.
+ *
+ * The {@link RowCounter} has no system property override: swapping the counting implementation
+ * is a code-level decision made once for a suite, not something flipped per run.
+ *
+ * @author dbunit
+ * @since 3.6.0
+ */
+public final class RowCountCheckConfiguration
+{
+ /**
+ * System property overriding {@link DatabaseConfig#FEATURE_ROW_COUNT_CHECK}, in either
+ * direction, when present.
+ */
+ public static final String DBUNIT_ROW_COUNT_CHECK = "dbunit.rowCountCheck";
+
+ /**
+ * System property overriding {@link DatabaseConfig#PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES}
+ * when present, replacing rather than appending to the configured patterns.
+ */
+ public static final String DBUNIT_ROW_COUNT_CHECK_EXCLUDE_TABLES =
+ "dbunit.rowCountCheckExcludeTables";
+
+ private final boolean enabled;
+ private final ExcludeTableFilter excludeTableFilter;
+ private final RowCounter rowCounter;
+
+ /**
+ * Resolves the configuration from the given database config and the current system
+ * properties.
+ *
+ * @param databaseConfig the database config to resolve {@link DatabaseConfig#FEATURE_ROW_COUNT_CHECK},
+ * {@link DatabaseConfig#PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES}, and
+ * {@link DatabaseConfig#PROPERTY_ROW_COUNTER} from when no system property
+ * overrides them.
+ */
+ public RowCountCheckConfiguration(final DatabaseConfig databaseConfig)
+ {
+ enabled = resolveEnabled(databaseConfig);
+ excludeTableFilter = new ExcludeTableFilter(resolveExcludeTablePatterns(databaseConfig));
+ rowCounter = (RowCounter) databaseConfig.getProperty(DatabaseConfig.PROPERTY_ROW_COUNTER);
+ }
+
+ private static boolean resolveEnabled(final DatabaseConfig databaseConfig)
+ {
+ final String systemProperty = System.getProperty(DBUNIT_ROW_COUNT_CHECK);
+ if (systemProperty != null)
+ {
+ return Boolean.parseBoolean(systemProperty);
+ }
+ return databaseConfig.getFeature(DatabaseConfig.FEATURE_ROW_COUNT_CHECK);
+ }
+
+ private static String[] resolveExcludeTablePatterns(final DatabaseConfig databaseConfig)
+ {
+ final String systemProperty =
+ System.getProperty(DBUNIT_ROW_COUNT_CHECK_EXCLUDE_TABLES);
+ if (systemProperty != null)
+ {
+ return splitAndTrim(systemProperty);
+ }
+ return (String[]) databaseConfig
+ .getProperty(DatabaseConfig.PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES);
+ }
+
+ private static String[] splitAndTrim(final String commaSeparatedPatterns)
+ {
+ final String[] patterns = commaSeparatedPatterns.split(",");
+ for (int i = 0; i < patterns.length; i++)
+ {
+ patterns[i] = patterns[i].trim();
+ }
+ return patterns;
+ }
+
+ /**
+ * Returns whether the row count check is enabled.
+ *
+ * @return {@code true} when enabled.
+ */
+ public boolean isEnabled()
+ {
+ return enabled;
+ }
+
+ /**
+ * Returns the filter built from the resolved exclude table patterns.
+ *
+ * @return the exclude table filter.
+ */
+ public ExcludeTableFilter getExcludeTableFilter()
+ {
+ return excludeTableFilter;
+ }
+
+ /**
+ * Returns the {@link RowCounter} to use.
+ *
+ * @return the configured row counter.
+ */
+ public RowCounter getRowCounter()
+ {
+ return rowCounter;
+ }
+}
diff --git a/src/main/java/org/dbunit/database/rowcount/RowCountChecker.java b/src/main/java/org/dbunit/database/rowcount/RowCountChecker.java
new file mode 100644
index 000000000..6d5a3ada0
--- /dev/null
+++ b/src/main/java/org/dbunit/database/rowcount/RowCountChecker.java
@@ -0,0 +1,137 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import java.sql.SQLException;
+
+import org.dbunit.DatabaseUnitException;
+import org.dbunit.database.IDatabaseConnection;
+
+/**
+ * Manages a {@link RowCountCheck} baseline across one caller's test lifecycle: lazily
+ * resolves a {@link RowCountCheck} from a connection's
+ * {@link org.dbunit.database.DatabaseConfig} on first use (unless one is supplied),
+ * captures a baseline, verifies it later, and lets the baseline be discarded when a
+ * verify would be noise - e.g. the caller's own test steps already failed, so the
+ * database is in an unknown state and a count difference is not a finding worth its own
+ * report.
+ *
+ * Holds no connection of its own; every method takes the connection to use, leaving
+ * acquisition and closing entirely to the caller.
+ *
+ * @author dbunit
+ * @since 3.6.0
+ */
+public class RowCountChecker
+{
+ private RowCountCheck rowCountCheck;
+ private RowCountSnapshot baseline;
+
+ /**
+ * Captures the baseline using the given connection, resolving a {@link RowCountCheck}
+ * from its {@link org.dbunit.database.DatabaseConfig} first if none has been resolved
+ * or set yet.
+ *
+ * @param connection the connection to capture the baseline from.
+ * @throws DatabaseUnitException if enumerating or filtering the tables fails.
+ * @throws SQLException if counting a table's rows fails.
+ */
+ public void capture(final IDatabaseConnection connection)
+ throws DatabaseUnitException, SQLException
+ {
+ baseline = resolve(connection).capture(connection);
+ }
+
+ /**
+ * Verifies the captured baseline against the given connection's current row counts.
+ * A no-op that never queries the connection when no baseline was captured - the check
+ * is disabled, {@link #capture(IDatabaseConnection)} was never called, or
+ * {@link #discardBaseline()} was.
+ *
+ * @param connection the connection to read the current row counts from.
+ * @throws DatabaseUnitException if enumerating or filtering the tables fails, or if any
+ * table's row count no longer matches the baseline
+ * ({@link UnexpectedRowCountException}).
+ * @throws SQLException if counting a table's rows fails.
+ */
+ public void verify(final IDatabaseConnection connection)
+ throws DatabaseUnitException, SQLException
+ {
+ if (baseline == null)
+ {
+ return;
+ }
+ resolve(connection).verify(baseline, connection);
+ }
+
+ /**
+ * Discards the captured baseline, so a later {@link #verify(IDatabaseConnection)} call
+ * skips silently instead of comparing against it.
+ */
+ public void discardBaseline()
+ {
+ baseline = null;
+ }
+
+ /**
+ * Returns whether a baseline is currently held, so a caller can tell there is nothing to
+ * verify - e.g. to skip acquiring a connection for {@link #verify(IDatabaseConnection)}
+ * entirely - without needing one just to ask.
+ *
+ * @return {@code true} when a baseline was captured and neither consumed by
+ * {@link #discardBaseline()} nor left uncaptured because the check was disabled.
+ */
+ public boolean hasBaseline()
+ {
+ return baseline != null;
+ }
+
+ private RowCountCheck resolve(final IDatabaseConnection connection)
+ {
+ if (rowCountCheck == null)
+ {
+ rowCountCheck =
+ new RowCountCheck(new RowCountCheckConfiguration(connection.getConfig()));
+ }
+ return rowCountCheck;
+ }
+
+ /**
+ * Returns the {@link RowCountCheck} in use.
+ *
+ * @return the row count check, or {@code null} if none has been resolved or set yet.
+ */
+ public RowCountCheck getRowCountCheck()
+ {
+ return rowCountCheck;
+ }
+
+ /**
+ * Sets the {@link RowCountCheck} to use, overriding the one otherwise lazily built from
+ * a connection's DatabaseConfig on first use.
+ *
+ * @param rowCountCheck the row count check to use.
+ */
+ public void setRowCountCheck(final RowCountCheck rowCountCheck)
+ {
+ this.rowCountCheck = rowCountCheck;
+ }
+}
diff --git a/src/main/java/org/dbunit/database/rowcount/RowCountDifference.java b/src/main/java/org/dbunit/database/rowcount/RowCountDifference.java
new file mode 100644
index 000000000..b9916534a
--- /dev/null
+++ b/src/main/java/org/dbunit/database/rowcount/RowCountDifference.java
@@ -0,0 +1,146 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import java.util.Objects;
+
+/**
+ * Immutable record of one table's row count having changed between a
+ * {@link RowCountCheck} baseline and a later comparison.
+ *
+ * @author dbunit
+ * @since 3.6.0
+ */
+public final class RowCountDifference
+{
+ private static final String ADVICE_ROWS_LEFT_BEHIND =
+ "rows left behind; add the table to the expected dataset, or exclude it";
+ private static final String ADVICE_ROWS_REMOVED =
+ "rows removed that should remain; drop the table from the prep/expected dataset, or exclude it";
+
+ private final String tableName;
+ private final int baselineCount;
+ private final int currentCount;
+
+ /**
+ * Creates a difference for the given table.
+ *
+ * @param tableName The name of the table whose count changed, in the form
+ * {@link org.dbunit.dataset.IDataSet#getTableNames()} returned it.
+ * @param baselineCount The row count captured before the test.
+ * @param currentCount The row count found at comparison time.
+ */
+ public RowCountDifference(final String tableName, final int baselineCount,
+ final int currentCount)
+ {
+ this.tableName = tableName;
+ this.baselineCount = baselineCount;
+ this.currentCount = currentCount;
+ }
+
+ /**
+ * Returns the name of the table whose count changed.
+ *
+ * @return The table name, in the form {@link org.dbunit.dataset.IDataSet#getTableNames()}
+ * returned it.
+ */
+ public String getTableName()
+ {
+ return tableName;
+ }
+
+ /**
+ * Returns the row count captured before the test.
+ *
+ * @return The baseline row count.
+ */
+ public int getBaselineCount()
+ {
+ return baselineCount;
+ }
+
+ /**
+ * Returns the row count found at comparison time.
+ *
+ * @return The current row count.
+ */
+ public int getCurrentCount()
+ {
+ return currentCount;
+ }
+
+ /**
+ * Returns how much the row count changed.
+ *
+ * @return {@link #getCurrentCount()} minus {@link #getBaselineCount()}; positive when rows
+ * were left behind, negative when rows that should remain were removed.
+ */
+ public int getDelta()
+ {
+ return currentCount - baselineCount;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean equals(final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+ if (!(o instanceof RowCountDifference))
+ {
+ return false;
+ }
+ final RowCountDifference other = (RowCountDifference) o;
+ return baselineCount == other.baselineCount && currentCount == other.currentCount
+ && Objects.equals(tableName, other.tableName);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(tableName, baselineCount, currentCount);
+ }
+
+ /**
+ * Returns a one-line, human-readable description of this difference, naming the table, both
+ * counts, the signed delta, and direction-specific advice; the table name prints in the form
+ * {@link org.dbunit.dataset.IDataSet#getTableNames()} returned it, so it can be pasted
+ * straight into a dataset or an exclude list.
+ *
+ * @return The one-line description.
+ */
+ @Override
+ public String toString()
+ {
+ final int delta = getDelta();
+ final String signedDelta = delta > 0 ? "+" + delta : String.valueOf(delta);
+ final String advice = delta > 0 ? ADVICE_ROWS_LEFT_BEHIND : ADVICE_ROWS_REMOVED;
+ return tableName + " " + baselineCount + " -> " + currentCount + " (" + signedDelta
+ + ") " + advice;
+ }
+}
diff --git a/src/main/java/org/dbunit/database/rowcount/RowCountSnapshot.java b/src/main/java/org/dbunit/database/rowcount/RowCountSnapshot.java
new file mode 100644
index 000000000..a4404bc8f
--- /dev/null
+++ b/src/main/java/org/dbunit/database/rowcount/RowCountSnapshot.java
@@ -0,0 +1,132 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Immutable snapshot of table row counts, keyed by table name, taken at one point in
+ * a {@link RowCountCheck}. Holds no connection and issues no SQL of its own; it is built
+ * from the map a {@link RowCounter} already returned.
+ *
+ * @author dbunit
+ * @since 3.6.0
+ */
+public final class RowCountSnapshot
+{
+ private final Map rowCounts;
+
+ /**
+ * Creates a snapshot from a {@link RowCounter}'s result.
+ *
+ * @param rowCounts The row count of each table, keyed by table name; copied, not retained.
+ */
+ public RowCountSnapshot(final Map rowCounts)
+ {
+ this.rowCounts = Collections.unmodifiableMap(new LinkedHashMap<>(rowCounts));
+ }
+
+ /**
+ * Returns the row count of each table in this snapshot.
+ *
+ * @return An unmodifiable map of row count keyed by table name.
+ */
+ public Map getRowCounts()
+ {
+ return rowCounts;
+ }
+
+ /**
+ * Compares this snapshot, used as the baseline, against a snapshot captured later.
+ * Does not assume the two snapshots share the identical key set: a table absent from
+ * one side - e.g. dropped or created between the two captures, or the two connections
+ * behind them not enumerating identically - counts as {@code 0} on that side rather
+ * than throwing, so the difference is reported like any other rather than failing with
+ * a {@code NullPointerException}.
+ *
+ * @param current The snapshot to compare this baseline against.
+ * @return One {@link RowCountDifference} per table whose count changed, in this
+ * snapshot's table order followed by any table {@code current} has that this
+ * snapshot does not, in {@code current}'s order; empty when every count is
+ * unchanged.
+ */
+ public List difference(final RowCountSnapshot current)
+ {
+ final Set tableNames = new LinkedHashSet<>(rowCounts.keySet());
+ tableNames.addAll(current.rowCounts.keySet());
+
+ final List differences = new ArrayList<>();
+ for (final String tableName : tableNames)
+ {
+ final int baselineCount = rowCounts.getOrDefault(tableName, 0);
+ final int currentCount = current.rowCounts.getOrDefault(tableName, 0);
+ if (currentCount != baselineCount)
+ {
+ differences.add(
+ new RowCountDifference(tableName, baselineCount, currentCount));
+ }
+ }
+ return differences;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean equals(final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+ if (!(o instanceof RowCountSnapshot))
+ {
+ return false;
+ }
+ final RowCountSnapshot other = (RowCountSnapshot) o;
+ return Objects.equals(rowCounts, other.rowCounts);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(rowCounts);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String toString()
+ {
+ return getClass().getSimpleName() + rowCounts;
+ }
+}
diff --git a/src/main/java/org/dbunit/database/rowcount/RowCounter.java b/src/main/java/org/dbunit/database/rowcount/RowCounter.java
new file mode 100644
index 000000000..f70a39d6b
--- /dev/null
+++ b/src/main/java/org/dbunit/database/rowcount/RowCounter.java
@@ -0,0 +1,66 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Map;
+
+import org.dbunit.database.DatabaseConfig;
+import org.dbunit.database.IDatabaseConnection;
+
+/**
+ * Counts the rows of a set of database tables. The strategy behind
+ * {@link RowCountCheck}, configured via
+ * {@link DatabaseConfig#PROPERTY_ROW_COUNTER}; counting is the expensive part
+ * of the row count check and the part most likely to be improved, so it is
+ * the one seam the check opens.
+ *
+ * An 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. Estimates, such as vendor statistics views
+ * that lag asynchronously, are not acceptable however cheap they are, since
+ * they would produce false failures.
+ *
+ * Table enumeration and exclusion filtering are handled by {@link RowCountCheck}
+ * before this interface is ever consulted, so an implementation's whole job is
+ * counting the list it is handed.
+ *
+ * @author dbunit
+ * @since 3.6.0
+ */
+public interface RowCounter
+{
+ /**
+ * Counts the rows of every given table.
+ *
+ * @param connection the connection to count rows through.
+ * @param tableNames the names of the tables to count; every name must appear
+ * as a key in the returned map, and no other keys may appear.
+ * @return the row count of each requested table, keyed exactly as supplied.
+ * @throws SQLException if counting a table's rows fails.
+ */
+ Map countRows(IDatabaseConnection connection, List tableNames)
+ throws SQLException;
+}
diff --git a/src/main/java/org/dbunit/database/rowcount/UnexpectedRowCountException.java b/src/main/java/org/dbunit/database/rowcount/UnexpectedRowCountException.java
new file mode 100644
index 000000000..44b222501
--- /dev/null
+++ b/src/main/java/org/dbunit/database/rowcount/UnexpectedRowCountException.java
@@ -0,0 +1,81 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.dbunit.DatabaseUnitException;
+import org.dbunit.database.IDatabaseConnection;
+
+/**
+ * Thrown by {@link RowCountCheck#verify(RowCountSnapshot, IDatabaseConnection)} when one or
+ * more tables' row counts no longer match the baseline. The message names every affected
+ * table, both counts, the signed delta, and direction-specific advice, in the form a reader can
+ * paste straight into a dataset or an exclude list.
+ *
+ * @author dbunit
+ * @since 3.6.0
+ */
+public class UnexpectedRowCountException extends DatabaseUnitException
+{
+ private static final long serialVersionUID = 1L;
+
+ private final List differences;
+
+ /**
+ * Creates an exception reporting the given differences.
+ *
+ * @param differences The tables whose row counts no longer match the baseline; must not be
+ * empty.
+ */
+ public UnexpectedRowCountException(final List differences)
+ {
+ super(buildMessage(differences));
+ this.differences = Collections.unmodifiableList(new ArrayList<>(differences));
+ }
+
+ /**
+ * Returns the tables whose row counts no longer match the baseline.
+ *
+ * @return The differences, one per affected table.
+ */
+ public List getDifferences()
+ {
+ return differences;
+ }
+
+ private static String buildMessage(final List differences)
+ {
+ final int count = differences.size();
+ final String tableWord = count == 1 ? " table differs" : " tables differ";
+
+ final StringBuilder message = new StringBuilder();
+ message.append("Row count check failed: ").append(count).append(tableWord)
+ .append(" from the pre-test baseline.");
+ for (final RowCountDifference difference : differences)
+ {
+ message.append(System.lineSeparator()).append(" ").append(difference);
+ }
+ return message.toString();
+ }
+}
diff --git a/src/main/java/org/dbunit/junit/jupiter/DbUnitExtension.java b/src/main/java/org/dbunit/junit/jupiter/DbUnitExtension.java
index fc28b0c08..e8adc666d 100644
--- a/src/main/java/org/dbunit/junit/jupiter/DbUnitExtension.java
+++ b/src/main/java/org/dbunit/junit/jupiter/DbUnitExtension.java
@@ -24,6 +24,10 @@
import java.lang.reflect.Modifier;
import org.dbunit.IDatabaseTester;
+import org.dbunit.database.IDatabaseConnection;
+import org.dbunit.database.rowcount.RowCountCheck;
+import org.dbunit.database.rowcount.RowCountCheckConfiguration;
+import org.dbunit.database.rowcount.RowCountChecker;
import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
@@ -66,6 +70,20 @@
* enclosing class instances—since a Java nested class does not extend its
* enclosing class.
*
+ * Also runs the {@link RowCountCheck row count check} around the test, via the same
+ * {@link RowCountChecker} that manages it for {@code DefaultPrepAndExpectedTestCase}: a
+ * baseline is captured before {@code onSetup()}, and verified after {@code onTearDown()}
+ * unless the test method itself threw, in which case the database is in an unknown state and
+ * a count difference would be noise around the real failure. The check is opt-in and off by
+ * default - see {@link RowCountCheckConfiguration}. A tester whose
+ * {@link IDatabaseTester#getConnection()} returns {@code null} (e.g. a test double) is
+ * tolerated; the check simply never activates for it. The connection {@link
+ * IDatabaseTester#getConnection()} returns is never closed here - only its owner (the
+ * tester's {@link org.dbunit.IOperationListener}, or the caller that constructed the
+ * tester) knows whether it is safe to close, e.g. a {@code DefaultDatabaseTester} built
+ * from one fixed connection returns that same connection from every call, and closing it
+ * early would break the {@code onSetup()} that runs right after capturing the baseline.
+ *
* @author dbunit
* @since 3.5.0
*/
@@ -75,34 +93,95 @@ public class DbUnitExtension implements BeforeTestExecutionCallback, AfterTestEx
private static final ExtensionContext.Namespace NAMESPACE =
ExtensionContext.Namespace.create(DbUnitExtension.class);
- private static final String TESTER_KEY = "databaseTester";
+ /** Package-visible so tests can reference it instead of duplicating the literal. */
+ static final String TESTER_KEY = "databaseTester";
+ /** Package-visible so tests can reference it instead of duplicating the literal. */
+ static final String ROW_COUNT_CHECKER_KEY = "rowCountChecker";
/**
* Runs database setup before the test method executes.
*
* @param context The extension context for the test method.
- * @throws Exception If resolving the {@link IDatabaseTester} field or its onSetup() call fails.
+ * @throws Exception If resolving the {@link IDatabaseTester} field, capturing the row
+ * count check baseline, or its onSetup() call fails.
*/
@Override
public void beforeTestExecution(final ExtensionContext context) throws Exception {
final IDatabaseTester tester = resolveTester(context);
- context.getStore(NAMESPACE).put(TESTER_KEY, tester);
+ final ExtensionContext.Store store = context.getStore(NAMESPACE);
+ store.put(TESTER_KEY, tester);
+ store.put(ROW_COUNT_CHECKER_KEY, captureRowCountBaseline(tester));
+
tester.onSetup();
}
/**
- * Runs database teardown after the test method executes.
+ * Runs database teardown after the test method executes, then verifies the row count
+ * check baseline unless the test method itself threw.
*
* @param context The extension context for the test method.
- * @throws Exception If the stored {@link IDatabaseTester}'s onTearDown() call fails.
+ * @throws Exception If the stored {@link IDatabaseTester}'s onTearDown() call fails, or if
+ * a table's row count no longer matches the baseline
+ * ({@link org.dbunit.database.rowcount.UnexpectedRowCountException}).
*/
@Override
public void afterTestExecution(final ExtensionContext context) throws Exception {
- final IDatabaseTester tester =
- context.getStore(NAMESPACE).get(TESTER_KEY, IDatabaseTester.class);
+ final ExtensionContext.Store store = context.getStore(NAMESPACE);
+ final IDatabaseTester tester = store.get(TESTER_KEY, IDatabaseTester.class);
if (tester != null) {
tester.onTearDown();
+
+ final RowCountChecker rowCountChecker =
+ store.get(ROW_COUNT_CHECKER_KEY, RowCountChecker.class);
+ if (rowCountChecker != null && rowCountChecker.hasBaseline()
+ && !context.getExecutionException().isPresent()) {
+ verifyRowCountUnchanged(tester, rowCountChecker);
+ }
+ }
+ }
+
+ /**
+ * Captures the row count check baseline for {@code tester} into a fresh
+ * {@link RowCountChecker}, using whatever connection {@code tester.getConnection()}
+ * returns - the same one {@code onSetup()} itself will use if the tester always returns
+ * one fixed connection - without closing it: that connection's lifecycle belongs to the
+ * tester, not to this check.
+ *
+ * @param tester the tester to capture a baseline for.
+ * @return the checker holding the captured baseline, or {@code null} when {@code tester}
+ * has no connection to inspect. The returned checker holds no baseline - see
+ * {@link RowCountChecker#hasBaseline()} - when the check is disabled.
+ * @throws Exception if resolving the connection or capturing the baseline fails.
+ */
+ private RowCountChecker captureRowCountBaseline(final IDatabaseTester tester)
+ throws Exception {
+ final IDatabaseConnection connection = tester.getConnection();
+ if (connection == null) {
+ return null;
+ }
+ final RowCountChecker rowCountChecker = new RowCountChecker();
+ rowCountChecker.capture(connection);
+ return rowCountChecker;
+ }
+
+ /**
+ * Verifies {@code rowCountChecker}'s baseline against whatever connection
+ * {@code tester.getConnection()} returns, without closing it - see
+ * {@link #captureRowCountBaseline(IDatabaseTester)}.
+ *
+ * @param tester the tester to verify against.
+ * @param rowCountChecker the checker holding the baseline captured by
+ * {@link #captureRowCountBaseline(IDatabaseTester)}.
+ * @throws Exception if resolving the connection fails, or if a table's row count no longer
+ * matches the baseline.
+ */
+ private void verifyRowCountUnchanged(final IDatabaseTester tester,
+ final RowCountChecker rowCountChecker) throws Exception {
+ final IDatabaseConnection connection = tester.getConnection();
+ if (connection == null) {
+ return;
}
+ rowCountChecker.verify(connection);
}
private IDatabaseTester resolveTester(final ExtensionContext context) throws Exception {
diff --git a/src/site/asciidoc/bestpractices.adoc b/src/site/asciidoc/bestpractices.adoc
index 7890c929c..9eaa8c33c 100644
--- a/src/site/asciidoc/bestpractices.adoc
+++ b/src/site/asciidoc/bestpractices.adoc
@@ -5,6 +5,7 @@
. <>
. <>
. <>
+. <>
[#onedbperdev]
== Use one database instance per developer
@@ -115,3 +116,18 @@ public class MyJNDIDatabaseTest extends JndiBasedDBTestCase {
----
You may also use JndiDatabaseTester if you can't subclass JndiBasedDBTestCase.
+
+[#rowcountcheck]
+== Periodically run with the row count check enabled
+
+link:components/rowcountcheck.html[RowCountCheck] catches a table your teardown forgot
+(its leftover rows corrupt whatever unrelated test runs next) and a reference table your
+teardown wrongly cleaned (every later test depending on it fails in ways that look
+nothing like the cause). It is read-only and opt-in for a reason: capturing two row-count
+snapshots per test roughly doubles that test's table-counting cost, so it is not something
+you want paying for on every routine run.
+
+Instead, run your suite with it enabled periodically — e.g. a scheduled/nightly CI job via
+`-Ddbunit.rowCountCheck=true` — rather than leaving it on permanently. That is enough to
+catch a teardown regression soon after it's introduced, without slowing down the CI feedback
+loop of every pull request.
diff --git a/src/site/asciidoc/components.adoc b/src/site/asciidoc/components.adoc
index b73d825b6..c4e192dc4 100644
--- a/src/site/asciidoc/components.adoc
+++ b/src/site/asciidoc/components.adoc
@@ -111,4 +111,5 @@ A few — `ITable`, `VerifyTableDefinition`, `IOperationListener`, and
|`PrepAndExpectedTestCase`, `PrepAndExpectedTestCaseSteps` |link:testcases/PrepAndExpectedTestCase.html[PrepAndExpectedTestCase] — turn-key prep/verify/cleanup test case.
|anchor:verifytabledefinition[]`link:components/verifytabledefinition.html[VerifyTableDefinition]` |*New:* link:components/verifytabledefinition.html[VerifyTableDefinition] — defines one table to verify: column filters and `ValueComparer`s.
|`link:components/ioperationlistener.html[IOperationListener]` |*New:* link:components/ioperationlistener.html[IOperationListener] — hooks into connection setup/teardown lifecycle events.
+|`link:components/rowcountcheck.html[RowCountCheck]`, `RowCounter`, `RowCountChecker`, `RowCountSnapshot`, `RowCountDifference` |*New:* link:components/rowcountcheck.html[RowCountCheck] — opt-in diagnostic that fails a test when a table's row count moved between setup and teardown, catching prep/expected datasets that are missing a table or wrongly list one.
|===
diff --git a/src/site/asciidoc/components/rowcountcheck.adoc b/src/site/asciidoc/components/rowcountcheck.adoc
new file mode 100644
index 000000000..8b45bed1f
--- /dev/null
+++ b/src/site/asciidoc/components/rowcountcheck.adoc
@@ -0,0 +1,209 @@
+= RowCountCheck
+
+[#overview]
+== Overview
+
+link:/dbunit/apidocs/org/dbunit/database/rowcount/RowCountCheck.html[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:
+
+[cols="1,3", options="header"]
+|===
+|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]
+== 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 `UPDATE` that 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]
+== 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 link:../properties.html#rowcountcheck[`FEATURE_ROW_COUNT_CHECK`] feature:
+
+[source,java]
+----
+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:
+
+[source]
+----
+-Ddbunit.rowCountCheck=true
+----
+
+It is wired into both link:../testcases/PrepAndExpectedTestCase.html[PrepAndExpectedTestCase]
+and link:../testcases/DbUnitExtension.html[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.
+
+[#exclusions]
+== 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_SEQUENCES` table) 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
+link:../filters.html[`ExcludeTableFilter`] — via
+link:../properties.html#rowcountcheckexcludetables[`PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES`]:
+
+[source,java]
+----
+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:
+
+[source]
+----
+-Ddbunit.rowCountCheckExcludeTables=AUDIT_*,HIBERNATE_SEQUENCES
+----
+
+[#failure-message]
+== Reading a Failure
+
+[source]
+----
+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 it
+----
+
+Each 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 matching
+`VerifyTableDefinition`, 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.
+
+[#row-counter]
+== Supplying Your Own RowCounter
+
+Counting is the expensive part of the check, so it is the one seam left open:
+link:/dbunit/apidocs/org/dbunit/database/rowcount/RowCounter.html[`RowCounter`], registered like
+every other dbUnit extension point via
+link:../properties.html#rowcounter[`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:
+
+[source,java]
+----
+public interface RowCounter
+{
+ Map countRows(IDatabaseConnection connection, List 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:
+
+[source,java]
+----
+public class UnionAllRowCounter implements RowCounter
+{
+ @Override
+ public Map countRows(IDatabaseConnection connection,
+ List tableNames) throws SQLException
+ {
+ Map 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:
+
+[source,java]
+----
+config.setProperty(DatabaseConfig.PROPERTY_ROW_COUNTER, new UnionAllRowCounter());
+----
+
+[#caveats]
+== 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 <>.
+* *Parallel tests against one database break the check*, as they already break
+`DELETE_ALL` teardown. Not specific to this check.
diff --git a/src/site/asciidoc/index.adoc b/src/site/asciidoc/index.adoc
index 6adf6db04..0d7a90f0b 100644
--- a/src/site/asciidoc/index.adoc
+++ b/src/site/asciidoc/index.adoc
@@ -46,14 +46,14 @@ We will gladly help you as needed with your ideas and contributions and look to
|===
|Date |News
-// |TBD
-// |Please try the 3.5.0-SNAPSHOT snapshot build and let us know how it works!
-// It adds `DbUnitExtension` for *native JUnit 5/6 lifecycle management*, a new *JSON dataset format* and JSON `ValueComparer`, *MariaDB and H2 2.x* database support, expanded PostgreSQL `json`/`jsonb` and array type support, and an opt-in escape hatch for *circular foreign keys*, plus a ground-up *documentation site overhaul* and numerous other bug fixes.
-// See link:https://dbunit.github.io/dbunit-extension/repos.html#snapshots[SNAPSHOTS] for how to use them.
-// Refer to the link:changes.html#a3.5.0-SNAPSHOT[changes report],
-// the link:https://github.com/dbunit/dbunit-extension/issues?q=is%3Aissue+milestone%3A3.5.0%20type%3AFeature[feature list], and
-// the link:https://github.com/dbunit/dbunit-extension/issues?q=is%3Aissue+milestone%3A3.5.0%20type%3ABug[bug list]
-// for the snapshot contents (and subsequent updates).
+|TBD
+|Please try the 3.6.0-SNAPSHOT snapshot build and let us know how it works!
+It adds an opt-in *row count check* (`RowCountCheck`) that catches tables teardown missed or wrongly cleaned.
+See link:https://dbunit.github.io/dbunit-extension/repos.html#snapshots[SNAPSHOTS] for how to use them.
+Refer to the link:changes.html#a3.6.0-SNAPSHOT[changes report],
+the link:https://github.com/dbunit/dbunit-extension/issues?q=is%3Aissue+milestone%3A3.6.0%20type%3AFeature[feature list], and
+the link:https://github.com/dbunit/dbunit-extension/issues?q=is%3Aissue+milestone%3A3.6.0%20type%3ABug[bug list]
+for the snapshot contents (and subsequent updates).
|2026-08-11
|Release 3.5.0 available.
diff --git a/src/site/asciidoc/properties.adoc b/src/site/asciidoc/properties.adoc
index cc048bfe2..eed87e149 100644
--- a/src/site/asciidoc/properties.adoc
+++ b/src/site/asciidoc/properties.adoc
@@ -80,6 +80,11 @@ _Note:_ this feature was not compatible with the <
|http://www.dbunit.org/features/skipCycleCheck
|false
|Let link:filters.html#databasesequencefilter[DatabaseSequenceFilter] skip its foreign-key dependency cycle check instead of throwing `CyclicTablesDependencyException`. Each cycle is instead treated as one unit for ordering purposes: tables outside it are still correctly sorted relative to it, but the relative order of the tables making up the cycle falls back to their original input order — only useful when the cycle is handled another way (e.g. nullable FK columns populated by a later operation, or database-side deferred constraint checking).
+
+|anchor:rowcountcheck[]`FEATURE_ROW_COUNT_CHECK`
+|http://www.dbunit.org/features/rowCountCheck
+|false
+|Compare every table's row count before and after each test, failing the test when a count moved — see link:components/rowcountcheck.html[RowCountCheck] for what it catches and how to read a failure. Read-only; never modifies data. The `dbunit.rowCountCheck` system property overrides this feature in *either* direction when present (e.g. `-Ddbunit.rowCountCheck=true`), so it can force-enable in CI or force-disable locally regardless of what the code configures.
|===
== Properties
@@ -164,4 +169,14 @@ For all others the default handler should do the job: link:apidocs/org/dbunit/da
|false
|By default, link:apidocs/org/dbunit/DefaultPrepAndExpectedTestCase.html[DefaultPrepAndExpectedTestCase] fails the test when the expected dataset has more tables than the supplied link:apidocs/org/dbunit/VerifyTableDefinition.html[VerifyTableDefinition]s (see link:components/verifytabledefinition.html[VerifyTableDefinition] for the class reference) — a safety net for an expected table that was defined but never wired to a VerifyTableDefinition, which would otherwise be silently skipped during verification. Set this property to true to relax that check and allow the counts to disagree.
|
+
+|anchor:rowcountcheckexcludetables[]http://www.dbunit.org/properties/rowCountCheckExcludeTables
+|String[]{}
+|Table name patterns (`*`/`?` wildcards supported, same as link:filters.html[ExcludeTableFilter]) excluded from the <>. See link:components/rowcountcheck.html[RowCountCheck] for worked examples.
+|The `dbunit.rowCountCheckExcludeTables` system property, when present, *replaces* rather than appends to these patterns (e.g. `-Ddbunit.rowCountCheckExcludeTables=AUDIT_*,HIBERNATE_SEQUENCES`, comma-separated, each pattern trimmed).
+
+|anchor:rowcounter[]http://www.dbunit.org/properties/rowCounter
+|org.dbunit.database.rowcount.QueryPerTableRowCounter
+|Used to configure the row-counting strategy the <> uses. The Object must implement link:apidocs/org/dbunit/database/rowcount/RowCounter.html[org.dbunit.database.rowcount.RowCounter]. See link:components/rowcountcheck.html#row-counter[Supplying Your Own RowCounter] for the contract and a worked implementation.
+|No system property override — swapping the counting implementation is a code-level decision made once for a suite, not something flipped per run.
|===
diff --git a/src/site/asciidoc/testcases/DbUnitExtension.adoc b/src/site/asciidoc/testcases/DbUnitExtension.adoc
index 28ce4ebc2..aab5e04ee 100644
--- a/src/site/asciidoc/testcases/DbUnitExtension.adoc
+++ b/src/site/asciidoc/testcases/DbUnitExtension.adoc
@@ -57,6 +57,18 @@ NOTE: `@Nested` test classes are not supported — field discovery only walks
the innermost test instance and its superclasses, not enclosing class
instances, since a Java nested class does not extend its enclosing class.
+== Row Count Check
+
+The same link:../components/rowcountcheck.html[RowCountCheck] diagnostic available to
+link:PrepAndExpectedTestCase.html[PrepAndExpectedTestCase] applies here too: a baseline is
+captured from the resolved `IDatabaseTester`'s connection before `onSetup()` and verified
+after `onTearDown()`, failing the test by name when a table's row count moved. Verification
+is skipped whenever `onTearDown()` itself throws, and also when the test method threw
+before reaching it, since either way the database is left in an unknown state and a count
+difference would only be noise around the real failure. It is off by default; enable it
+with `DatabaseConfig.FEATURE_ROW_COUNT_CHECK` (or `-Ddbunit.rowCountCheck=true`) on the
+connection's config.
+
== When to Use This Instead of Manual Lifecycle Calls
Reach for `DbUnitExtension` when a `@BeforeEach`/`@AfterEach` pair that only
diff --git a/src/site/asciidoc/testcases/PrepAndExpectedTestCase.adoc b/src/site/asciidoc/testcases/PrepAndExpectedTestCase.adoc
index 5f9797eca..b92957e69 100644
--- a/src/site/asciidoc/testcases/PrepAndExpectedTestCase.adoc
+++ b/src/site/asciidoc/testcases/PrepAndExpectedTestCase.adoc
@@ -86,6 +86,23 @@ link:../operations.html[DatabaseOperation]s (default `CLEAN_INSERT` / `NONE`) ru
automatically around your test steps — no manual `onSetup()`/`onTearDown()` calls
needed when using `runTest()`.
+== Verifying Your Teardown Table List Is Complete
+
+The teardown operation only cleans the tables named in your prep and expected datasets.
+Two mistakes here are silent at the point they happen and only surface later, in an
+*unrelated* test: forgetting to list a table the code under test writes to (its rows
+survive teardown), and wrongly listing a reference table that should never be cleaned
+(teardown strips rows the DDL seeded).
+
+link:../components/rowcountcheck.html[RowCountCheck] is an opt-in diagnostic for exactly
+this: it compares every table's row count before `preTest()` and after `cleanupData()`,
+failing the test by name when a count moved instead of letting the corruption surface in
+whichever test happens to run next. It is off by default; turn it on with
+`DatabaseConfig.FEATURE_ROW_COUNT_CHECK` (or `-Ddbunit.rowCountCheck=true`) when
+verifying a suite's teardown correctness, and see
+link:../components/rowcountcheck.html[the RowCountCheck page] for what it catches, how
+to exclude legitimately-changing tables, and how to read a failure.
+
== Usage
=== Configure
diff --git a/src/site/site.xml b/src/site/site.xml
index f7ce2f072..c19442387 100644
--- a/src/site/site.xml
+++ b/src/site/site.xml
@@ -113,6 +113,7 @@
+
diff --git a/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseDiIT.java b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseDiIT.java
index f7ed1d998..c1c2cd08a 100644
--- a/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseDiIT.java
+++ b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseDiIT.java
@@ -5,6 +5,7 @@
import org.dbunit.assertion.DbComparisonFailure;
import org.dbunit.database.IDatabaseConnection;
+import org.dbunit.operation.DatabaseOperation;
import org.dbunit.util.fileloader.DataFileLoader;
import org.dbunit.util.fileloader.FlatXmlDataFileLoader;
import org.junit.jupiter.api.Test;
@@ -111,6 +112,10 @@ protected IDatabaseTester makeDatabaseTester() throws Exception
{
final DatabaseEnvironment dbEnv = DatabaseEnvironment.getInstance();
final IDatabaseConnection connection = dbEnv.getConnection();
- return new DefaultDatabaseTester(connection);
+ final IDatabaseTester databaseTester = new DefaultDatabaseTester(connection);
+ // without this, the prep rows this test inserts are never cleaned up and leak
+ // into whatever test runs next against the same tables
+ databaseTester.setTearDownOperation(DatabaseOperation.DELETE_ALL);
+ return databaseTester;
}
}
diff --git a/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseExtIT.java b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseExtIT.java
index 6e197a41d..caa70a40e 100644
--- a/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseExtIT.java
+++ b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseExtIT.java
@@ -5,6 +5,7 @@
import org.dbunit.assertion.DbComparisonFailure;
import org.dbunit.database.IDatabaseConnection;
+import org.dbunit.operation.DatabaseOperation;
import org.dbunit.util.fileloader.DataFileLoader;
import org.dbunit.util.fileloader.FlatXmlDataFileLoader;
import org.junit.jupiter.api.BeforeEach;
@@ -117,6 +118,10 @@ protected IDatabaseTester makeDatabaseTester() throws Exception
{
final DatabaseEnvironment dbEnv = DatabaseEnvironment.getInstance();
final IDatabaseConnection connection = dbEnv.getConnection();
- return new DefaultDatabaseTester(connection);
+ final IDatabaseTester databaseTester = new DefaultDatabaseTester(connection);
+ // without this, the prep rows this test inserts are never cleaned up and leak
+ // into whatever test runs next against the same tables
+ databaseTester.setTearDownOperation(DatabaseOperation.DELETE_ALL);
+ return databaseTester;
}
}
diff --git a/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseRowCountCheckIT.java b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseRowCountCheckIT.java
new file mode 100644
index 000000000..6832f0f5f
--- /dev/null
+++ b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseRowCountCheckIT.java
@@ -0,0 +1,197 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+import java.sql.Statement;
+
+import org.dbunit.database.DatabaseConfig;
+import org.dbunit.database.IDatabaseConnection;
+import org.dbunit.database.rowcount.ClearRowCountCheckSystemProperties;
+import org.dbunit.database.rowcount.RowCountDifference;
+import org.dbunit.database.rowcount.UnexpectedRowCountException;
+import org.dbunit.dataset.Column;
+import org.dbunit.dataset.DefaultDataSet;
+import org.dbunit.dataset.DefaultTable;
+import org.dbunit.dataset.datatype.DataType;
+import org.dbunit.operation.DatabaseOperation;
+import org.dbunit.util.fileloader.DataFileLoader;
+import org.dbunit.util.fileloader.FlatXmlDataFileLoader;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Real-database integration test of the row count check wired into
+ * {@link DefaultPrepAndExpectedTestCase}: a leaked row in a table the test never lists, a
+ * reference table wrongly listed for cleanup, and the exclude list silencing a legitimate
+ * case of either. Deltas are asserted rather than absolute counts, so the test does not
+ * depend on {@code EMPTY_TABLE}/{@code SECOND_TABLE} starting genuinely empty.
+ */
+@ClearRowCountCheckSystemProperties
+class DefaultPrepAndExpectedTestCaseRowCountCheckIT
+{
+ private static final String EMPTY_TABLE = "EMPTY_TABLE";
+ private static final String SECOND_TABLE = "SECOND_TABLE";
+
+ private final DataFileLoader dataFileLoader = new FlatXmlDataFileLoader();
+
+ private IDatabaseConnection connection;
+ private DefaultPrepAndExpectedTestCase tc;
+
+ @BeforeEach
+ void setUp() throws Exception
+ {
+ final DatabaseEnvironment dbEnv = DatabaseEnvironment.getInstance();
+ connection = dbEnv.getConnection();
+ connection.getConfig().setFeature(DatabaseConfig.FEATURE_ROW_COUNT_CHECK, true);
+
+ final IDatabaseTester databaseTester = new DefaultDatabaseTester(connection);
+ databaseTester.setTearDownOperation(DatabaseOperation.DELETE_ALL);
+ tc = new DefaultPrepAndExpectedTestCase(dataFileLoader, databaseTester);
+ }
+
+ @AfterEach
+ void cleanUp() throws Exception
+ {
+ final DatabaseEnvironment dbEnv = DatabaseEnvironment.getInstance();
+ final IDatabaseConnection cleanupConnection = dbEnv.getConnection();
+ try
+ {
+ deleteAllRowsQuietly(cleanupConnection, EMPTY_TABLE);
+ deleteAllRowsQuietly(cleanupConnection, SECOND_TABLE);
+ } finally
+ {
+ closeQuietly(cleanupConnection);
+ }
+ }
+
+ @Test
+ void testPostTest_rowLeakedIntoUnlistedTable_throwsUnexpectedRowCountExceptionNamingIt()
+ throws Exception
+ {
+ tc.preTest();
+
+ // the code under test wrote to a table the developer forgot to list for teardown
+ insertRow(EMPTY_TABLE);
+
+ final Throwable thrown = catchThrowable(() -> tc.postTest());
+
+ assertThat(thrown)
+ .as("A row left behind in a table absent from prep/expected must fail the"
+ + " test with UnexpectedRowCountException.")
+ .isInstanceOf(UnexpectedRowCountException.class);
+ assertThat(differenceFor((UnexpectedRowCountException) thrown, EMPTY_TABLE).getDelta())
+ .as("A row was left behind, so the delta must be positive; not asserting the"
+ + " exact value, since EMPTY_TABLE is shared with other IT classes"
+ + " and may not start genuinely empty.")
+ .isPositive();
+ }
+
+ @Test
+ void testPostTest_referenceTableWronglyListedForCleanup_throwsUnexpectedRowCountExceptionWithNegativeDelta()
+ throws Exception
+ {
+ // pre-existing reference data, seeded before this test's baseline is captured
+ insertRow(SECOND_TABLE);
+
+ // wrongly listing SECOND_TABLE for cleanup - its rows get wiped, by CLEAN_INSERT
+ // during setupData() here, or by DELETE_ALL during cleanupData() otherwise
+ final Column[] columns = {new Column("COLUMN0", DataType.VARCHAR)};
+ tc.setPrepDs(new DefaultDataSet(new DefaultTable(SECOND_TABLE, columns)));
+
+ tc.preTest();
+
+ final Throwable thrown = catchThrowable(() -> tc.postTest());
+
+ assertThat(thrown)
+ .as("A reference table wrongly listed for cleanup must fail the test with"
+ + " UnexpectedRowCountException.")
+ .isInstanceOf(UnexpectedRowCountException.class);
+ assertThat(differenceFor((UnexpectedRowCountException) thrown, SECOND_TABLE).getDelta())
+ .as("Pre-existing rows were wiped, so the delta must be negative; not"
+ + " asserting the exact value, since SECOND_TABLE is shared with"
+ + " other IT classes and may carry more than this test's own row.")
+ .isNegative();
+ }
+
+ @Test
+ void testPostTest_leakedRowInExcludedTable_doesNotThrow() throws Exception
+ {
+ connection.getConfig().setProperty(
+ DatabaseConfig.PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES,
+ new String[] {EMPTY_TABLE});
+
+ tc.preTest();
+ insertRow(EMPTY_TABLE);
+
+ assertThatCode(() -> tc.postTest())
+ .as("A table matching an exclude pattern must never be reported, even"
+ + " though its count actually changed.")
+ .doesNotThrowAnyException();
+ }
+
+ private RowCountDifference differenceFor(final UnexpectedRowCountException exception,
+ final String tableName)
+ {
+ return exception.getDifferences().stream()
+ .filter(difference -> difference.getTableName().equalsIgnoreCase(tableName))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError(
+ "No difference reported for table '" + tableName + "': "
+ + exception.getDifferences()));
+ }
+
+ private void insertRow(final String tableName) throws Exception
+ {
+ try (Statement statement = connection.getConnection().createStatement())
+ {
+ statement.execute(
+ "INSERT INTO " + tableName + " (COLUMN0) VALUES ('rowCountCheckIT')");
+ }
+ }
+
+ private static void deleteAllRowsQuietly(final IDatabaseConnection connection,
+ final String tableName)
+ {
+ try (Statement statement = connection.getConnection().createStatement())
+ {
+ statement.execute("DELETE FROM " + tableName);
+ } catch (final Exception e)
+ {
+ // best-effort cleanup only; a failure here must not fail the test that already ran
+ }
+ }
+
+ private static void closeQuietly(final IDatabaseConnection connection)
+ {
+ try
+ {
+ connection.close();
+ } catch (final Exception e)
+ {
+ // best-effort cleanup only; a failure here must not fail the test that already ran
+ }
+ }
+}
diff --git a/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseTest.java b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseTest.java
index 0f10054e4..3183c47c9 100644
--- a/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseTest.java
+++ b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseTest.java
@@ -5,12 +5,18 @@
import static org.assertj.core.api.Assertions.catchThrowable;
import java.sql.Connection;
+import java.util.Collections;
import org.dbunit.assertion.DbComparisonFailure;
import org.dbunit.assertion.DiffCollectingFailureHandler;
import org.dbunit.database.DatabaseConfig;
import org.dbunit.database.IDatabaseConnection;
import org.dbunit.database.MockDatabaseConnection;
+import org.dbunit.database.rowcount.ClearRowCountCheckSystemProperties;
+import org.dbunit.database.rowcount.RowCountCheck;
+import org.dbunit.database.rowcount.RowCountDifference;
+import org.dbunit.database.rowcount.RowCountSnapshot;
+import org.dbunit.database.rowcount.UnexpectedRowCountException;
import org.dbunit.database.statement.IBatchStatement;
import org.dbunit.database.statement.MockBatchStatement;
import org.dbunit.database.statement.MockStatementFactory;
@@ -32,6 +38,7 @@
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
+@ClearRowCountCheckSystemProperties
class DefaultPrepAndExpectedTestCaseTest
{
@Mock
@@ -744,6 +751,77 @@ void testCleanupData_withCloseConnectionAfterTestFalse_leavesConnectionOpen()
connection.verify();
}
+ @Test
+ void testCleanupData_checkDisabled_doesNotCaptureABaseline() throws Exception
+ {
+ // MockDatabaseConnection's dataset/getRowCount() are unconfigured here, so a real
+ // capture()/verify() attempt would throw; reaching the end proves the disabled
+ // default RowCountCheck no-opped instead of querying the connection (#939)
+ tc.preTest();
+ tc.cleanupData();
+
+ assertThat(tc.getRowCountCheck())
+ .as("preTest() must still lazily resolve a RowCountCheck even though the"
+ + " check is disabled, proving the disabled path was actually"
+ + " exercised rather than skipped outright.")
+ .isNotNull();
+ }
+
+ @Test
+ void testCleanupData_noBaselineCaptured_skipsTheCheck() throws Exception
+ {
+ final RowCountCheck mockRowCountCheck = Mockito.mock(RowCountCheck.class);
+ tc.setRowCountCheck(mockRowCountCheck);
+
+ // cleanupData() called directly, without preTest() first, so no baseline was captured
+ tc.cleanupData();
+
+ Mockito.verify(mockRowCountCheck, Mockito.never())
+ .verify(Mockito.any(), Mockito.any());
+ }
+
+ @Test
+ void testPostTest_testStepsFailed_skipsTheCheck() throws Exception
+ {
+ final RowCountCheck mockRowCountCheck = Mockito.mock(RowCountCheck.class);
+ final RowCountSnapshot baseline =
+ new RowCountSnapshot(Collections.singletonMap("ACCOUNT", 5));
+ Mockito.when(mockRowCountCheck.capture(Mockito.any())).thenReturn(baseline);
+ tc.setRowCountCheck(mockRowCountCheck);
+
+ tc.preTest();
+ tc.postTest(false);
+
+ Mockito.verify(mockRowCountCheck, Mockito.never())
+ .verify(Mockito.any(), Mockito.any());
+ }
+
+ @Test
+ void testCleanupData_rowCountChanged_throwsAndStillClosesTheConnection() throws Exception
+ {
+ final RowCountCheck mockRowCountCheck = Mockito.mock(RowCountCheck.class);
+ final RowCountSnapshot baseline =
+ new RowCountSnapshot(Collections.singletonMap("ACCOUNT_AUDIT", 0));
+ Mockito.when(mockRowCountCheck.capture(Mockito.any())).thenReturn(baseline);
+ final UnexpectedRowCountException failure = new UnexpectedRowCountException(
+ Collections.singletonList(new RowCountDifference("ACCOUNT_AUDIT", 0, 3)));
+ Mockito.doThrow(failure).when(mockRowCountCheck).verify(Mockito.any(), Mockito.any());
+ tc.setRowCountCheck(mockRowCountCheck);
+ tc.preTest();
+
+ assertThat(catchThrowable(() -> tc.cleanupData()))
+ .as("A row count difference detected during cleanup must propagate as-is,"
+ + " the same as any other cleanupData() failure.")
+ .isSameAs(failure);
+
+ final MockDatabaseConnection connection =
+ (MockDatabaseConnection) databaseTester.getConnection();
+ // the existing catch block in cleanupData() must still close the connection even
+ // though the row count check, not the tear down operation, is what failed (#939)
+ connection.setExpectedCloseCalls(1);
+ connection.verify();
+ }
+
@Test
void testConfigureTestThenSetupData_withCloseDisabledNoProvider_doesNotReacquireConnection()
throws Exception
diff --git a/src/test/java/org/dbunit/database/DatabaseConfigTest.java b/src/test/java/org/dbunit/database/DatabaseConfigTest.java
index 7557ffcd0..4e60e5e22 100644
--- a/src/test/java/org/dbunit/database/DatabaseConfigTest.java
+++ b/src/test/java/org/dbunit/database/DatabaseConfigTest.java
@@ -23,6 +23,10 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import java.util.Properties;
+
+import org.dbunit.database.rowcount.QueryPerTableRowCounter;
+import org.dbunit.database.rowcount.RowCounter;
import org.dbunit.dataset.datatype.DataType;
import org.dbunit.dataset.datatype.DataTypeException;
import org.dbunit.dataset.datatype.IDataTypeFactory;
@@ -188,4 +192,98 @@ void testCopyPropertiesInto_withNullablePropertyAtDefault_overwritesTargetWithNu
.isNull();
}
+ @Test
+ void testGetFeature_rowCountCheckDefault_isFalse() throws Exception
+ {
+ final DatabaseConfig config = new DatabaseConfig();
+
+ assertThat(config.getFeature(DatabaseConfig.FEATURE_ROW_COUNT_CHECK))
+ .as("FEATURE_ROW_COUNT_CHECK must default to false; the check is opt-in.")
+ .isFalse();
+ }
+
+ @Test
+ void testGetProperty_rowCountCheckExcludeTablesDefault_isEmptyArray() throws Exception
+ {
+ final DatabaseConfig config = new DatabaseConfig();
+
+ assertThat(
+ (String[]) config
+ .getProperty(DatabaseConfig.PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES))
+ .as("PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES must default to an"
+ + " empty pattern list.")
+ .isEmpty();
+ }
+
+ @Test
+ void testGetProperty_rowCounterDefault_isQueryPerTableRowCounterInstance() throws Exception
+ {
+ final DatabaseConfig config = new DatabaseConfig();
+
+ assertThat(config.getProperty(DatabaseConfig.PROPERTY_ROW_COUNTER))
+ .as("PROPERTY_ROW_COUNTER must default to a QueryPerTableRowCounter, the v1"
+ + " RowCounter implementation.")
+ .isInstanceOf(QueryPerTableRowCounter.class);
+ }
+
+ @Test
+ void testFindByName_rowCountCheckFeature_returnsBooleanConfigProperty() throws Exception
+ {
+ final DatabaseConfig.ConfigProperty property =
+ DatabaseConfig.findByName(DatabaseConfig.FEATURE_ROW_COUNT_CHECK);
+
+ assertThat(property)
+ .as("FEATURE_ROW_COUNT_CHECK must be registered so its type can be validated,"
+ + " instead of only logging \"Unknown property\".")
+ .isNotNull();
+ assertThat(property.getPropertyType()).as("The feature is boolean-valued.")
+ .isEqualTo(Boolean.class);
+ }
+
+ @Test
+ void testFindByShortName_rowCountCheckExcludeTables_returnsStringArrayConfigProperty()
+ throws Exception
+ {
+ final DatabaseConfig.ConfigProperty property =
+ DatabaseConfig.findByShortName("rowCountCheckExcludeTables");
+
+ assertThat(property)
+ .as("The short name must resolve to PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES.")
+ .isNotNull();
+ assertThat(property.getPropertyType())
+ .as("The exclude patterns are a String array, matching the shape of"
+ + " PROPERTY_TABLE_TYPE and the dbunit.rowCountCheckExcludeTables"
+ + " system property.")
+ .isEqualTo(String[].class);
+ }
+
+ @Test
+ void testFindByName_rowCounter_returnsRowCounterConfigProperty() throws Exception
+ {
+ final DatabaseConfig.ConfigProperty property =
+ DatabaseConfig.findByName(DatabaseConfig.PROPERTY_ROW_COUNTER);
+
+ assertThat(property)
+ .as("PROPERTY_ROW_COUNTER must be registered so its type can be validated.")
+ .isNotNull();
+ assertThat(property.getPropertyType()).as("The property holds a RowCounter instance.")
+ .isEqualTo(RowCounter.class);
+ }
+
+ @Test
+ void testSetPropertiesByString_rowCounterClassName_instantiatesThatCounter() throws Exception
+ {
+ final DatabaseConfig config = new DatabaseConfig();
+ final Properties stringProperties = new Properties();
+ stringProperties.setProperty("rowCounter", QueryPerTableRowCounter.class.getName());
+
+ config.setPropertiesByString(stringProperties);
+
+ assertThat(config.getProperty(DatabaseConfig.PROPERTY_ROW_COUNTER))
+ .as("A class name configured via a String property (e.g. from Ant or Maven)"
+ + " must be reflectively instantiated, the same as any other"
+ + " object-valued property.")
+ .isInstanceOf(QueryPerTableRowCounter.class);
+ }
+
}
diff --git a/src/test/java/org/dbunit/database/rowcount/ClearRowCountCheckSystemProperties.java b/src/test/java/org/dbunit/database/rowcount/ClearRowCountCheckSystemProperties.java
new file mode 100644
index 000000000..60f2f1340
--- /dev/null
+++ b/src/test/java/org/dbunit/database/rowcount/ClearRowCountCheckSystemProperties.java
@@ -0,0 +1,47 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.dbunit.database.DatabaseConfig;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * Marks a test class or method as needing the {@code dbunit.rowCountCheck} and
+ * {@code dbunit.rowCountCheckExcludeTables} system properties cleared for its duration, via
+ * {@link ClearRowCountCheckSystemPropertiesExtension}. Without this, a test that builds a
+ * {@link DatabaseConfig} and relies on the row count check defaulting to disabled is at the
+ * mercy of whatever an ambient {@code -Ddbunit.rowCountCheck=true} (e.g. from the very field
+ * test {@code row-count-check-plan.adoc}'s Verification section calls for) already set for the
+ * whole JVM - the system property wins over DatabaseConfig in either direction by design.
+ *
+ * @since 3.6.0
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+@ExtendWith(ClearRowCountCheckSystemPropertiesExtension.class)
+public @interface ClearRowCountCheckSystemProperties
+{
+}
diff --git a/src/test/java/org/dbunit/database/rowcount/ClearRowCountCheckSystemPropertiesExtension.java b/src/test/java/org/dbunit/database/rowcount/ClearRowCountCheckSystemPropertiesExtension.java
new file mode 100644
index 000000000..e03081906
--- /dev/null
+++ b/src/test/java/org/dbunit/database/rowcount/ClearRowCountCheckSystemPropertiesExtension.java
@@ -0,0 +1,85 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import org.junit.jupiter.api.extension.AfterEachCallback;
+import org.junit.jupiter.api.extension.BeforeEachCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+
+/**
+ * Clears the {@code dbunit.rowCountCheck} and {@code dbunit.rowCountCheckExcludeTables}
+ * system properties before each test and restores whatever they were afterward. See
+ * {@link ClearRowCountCheckSystemProperties}.
+ *
+ * @since 3.6.0
+ */
+public class ClearRowCountCheckSystemPropertiesExtension
+ implements BeforeEachCallback, AfterEachCallback
+{
+ private static final ExtensionContext.Namespace NAMESPACE =
+ ExtensionContext.Namespace.create(ClearRowCountCheckSystemPropertiesExtension.class);
+
+ /**
+ * Saves the current values of both system properties, then clears them.
+ *
+ * @param context the extension context for the test about to run.
+ */
+ @Override
+ public void beforeEach(final ExtensionContext context)
+ {
+ final ExtensionContext.Store store = context.getStore(NAMESPACE);
+ store.put(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK,
+ System.getProperty(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK));
+ store.put(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK_EXCLUDE_TABLES, System
+ .getProperty(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK_EXCLUDE_TABLES));
+
+ System.clearProperty(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK);
+ System.clearProperty(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK_EXCLUDE_TABLES);
+ }
+
+ /**
+ * Restores both system properties to the values {@link #beforeEach(ExtensionContext)}
+ * saved.
+ *
+ * @param context the extension context for the test that just ran.
+ */
+ @Override
+ public void afterEach(final ExtensionContext context)
+ {
+ final ExtensionContext.Store store = context.getStore(NAMESPACE);
+ restore(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK,
+ store.get(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK, String.class));
+ restore(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK_EXCLUDE_TABLES,
+ store.get(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK_EXCLUDE_TABLES,
+ String.class));
+ }
+
+ private static void restore(final String key, final String value)
+ {
+ if (value == null)
+ {
+ System.clearProperty(key);
+ } else
+ {
+ System.setProperty(key, value);
+ }
+ }
+}
diff --git a/src/test/java/org/dbunit/database/rowcount/QueryPerTableRowCounterContractTest.java b/src/test/java/org/dbunit/database/rowcount/QueryPerTableRowCounterContractTest.java
new file mode 100644
index 000000000..fb7685815
--- /dev/null
+++ b/src/test/java/org/dbunit/database/rowcount/QueryPerTableRowCounterContractTest.java
@@ -0,0 +1,45 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.dbunit.database.IDatabaseConnection;
+
+class QueryPerTableRowCounterContractTest extends RowCounterContractTest
+{
+ @Override
+ protected RowCounter createRowCounter()
+ {
+ return new QueryPerTableRowCounter();
+ }
+
+ @Override
+ protected IDatabaseConnection createConnectionReturningRowCount(final int rowCount)
+ throws Exception
+ {
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ when(connection.getRowCount(anyString())).thenReturn(rowCount);
+ return connection;
+ }
+}
diff --git a/src/test/java/org/dbunit/database/rowcount/QueryPerTableRowCounterTest.java b/src/test/java/org/dbunit/database/rowcount/QueryPerTableRowCounterTest.java
new file mode 100644
index 000000000..71d54f857
--- /dev/null
+++ b/src/test/java/org/dbunit/database/rowcount/QueryPerTableRowCounterTest.java
@@ -0,0 +1,95 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.dbunit.database.IDatabaseConnection;
+import org.junit.jupiter.api.Test;
+
+class QueryPerTableRowCounterTest
+{
+ private final QueryPerTableRowCounter rowCounter = new QueryPerTableRowCounter();
+
+ @Test
+ void testCountRows_severalTables_returnsOneEntryPerRequestedTable() throws Exception
+ {
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ when(connection.getRowCount("ACCOUNT")).thenReturn(5);
+ when(connection.getRowCount("ACCOUNT_AUDIT")).thenReturn(0);
+ final List tableNames = Arrays.asList("ACCOUNT", "ACCOUNT_AUDIT");
+
+ final Map result = rowCounter.countRows(connection, tableNames);
+
+ assertThat(result)
+ .as("Every requested table must have its own counted entry.")
+ .hasSize(2).containsEntry("ACCOUNT", 5).containsEntry("ACCOUNT_AUDIT", 0);
+ }
+
+ @Test
+ void testCountRows_emptyTableNameList_returnsEmptyMap() throws Exception
+ {
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+
+ final Map result =
+ rowCounter.countRows(connection, Collections.emptyList());
+
+ assertThat(result).as("No requested tables must produce no counted entries.").isEmpty();
+ }
+
+ @Test
+ void testCountRows_tableNameWithMixedCase_keysResultWithTheNameAsSupplied() throws Exception
+ {
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ when(connection.getRowCount("Account_Audit")).thenReturn(1);
+
+ final Map result =
+ rowCounter.countRows(connection, Collections.singletonList("Account_Audit"));
+
+ assertThat(result)
+ .as("The result must be keyed exactly as the table name was supplied, not"
+ + " normalized to another case.")
+ .containsOnlyKeys("Account_Audit");
+ }
+
+ @Test
+ void testCountRows_getRowCountThrows_propagatesTheSameSQLExceptionUnchanged() throws Exception
+ {
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ final SQLException failure = new SQLException("boom");
+ when(connection.getRowCount("ACCOUNT")).thenThrow(failure);
+
+ assertThatThrownBy(() -> rowCounter.countRows(connection,
+ Collections.singletonList("ACCOUNT")))
+ .as("A counting failure for one table must propagate as-is, not be"
+ + " swallowed or wrapped.")
+ .isSameAs(failure);
+ }
+}
diff --git a/src/test/java/org/dbunit/database/rowcount/RowCountCheckConfigurationTest.java b/src/test/java/org/dbunit/database/rowcount/RowCountCheckConfigurationTest.java
new file mode 100644
index 000000000..aa3279e12
--- /dev/null
+++ b/src/test/java/org/dbunit/database/rowcount/RowCountCheckConfigurationTest.java
@@ -0,0 +1,165 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+import org.dbunit.database.DatabaseConfig;
+import org.dbunit.dataset.DataSetException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * CAUTION: mutates the {@code dbunit.rowCountCheck} and
+ * {@code dbunit.rowCountCheckExcludeTables} system properties; {@link ClearRowCountCheckSystemProperties}
+ * clears them before each case and restores whatever they were afterward, to stay independent
+ * of test execution order and any ambient value already set for the whole JVM.
+ */
+@ClearRowCountCheckSystemProperties
+class RowCountCheckConfigurationTest
+{
+ @Test
+ void testIsEnabled_noSystemPropertyAndNoFeature_returnsFalse()
+ {
+ final RowCountCheckConfiguration configuration =
+ new RowCountCheckConfiguration(new DatabaseConfig());
+
+ assertThat(configuration.isEnabled())
+ .as("With neither the system property nor the feature set, the check must"
+ + " default to disabled.")
+ .isFalse();
+ }
+
+ @Test
+ void testIsEnabled_featureTrueAndNoSystemProperty_returnsTrue()
+ {
+ final DatabaseConfig databaseConfig = new DatabaseConfig();
+ databaseConfig.setFeature(DatabaseConfig.FEATURE_ROW_COUNT_CHECK, true);
+
+ final RowCountCheckConfiguration configuration =
+ new RowCountCheckConfiguration(databaseConfig);
+
+ assertThat(configuration.isEnabled())
+ .as("With no system property override, the DatabaseConfig feature must be used.")
+ .isTrue();
+ }
+
+ @Test
+ void testIsEnabled_systemPropertyFalseAndFeatureTrue_returnsFalse()
+ {
+ final DatabaseConfig databaseConfig = new DatabaseConfig();
+ databaseConfig.setFeature(DatabaseConfig.FEATURE_ROW_COUNT_CHECK, true);
+ System.setProperty(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK, "false");
+
+ final RowCountCheckConfiguration configuration =
+ new RowCountCheckConfiguration(databaseConfig);
+
+ assertThat(configuration.isEnabled())
+ .as("The system property must win over the DatabaseConfig feature, so it can"
+ + " force-disable locally even when the feature is on.")
+ .isFalse();
+ }
+
+ @Test
+ void testIsEnabled_systemPropertyTrueAndFeatureFalse_returnsTrue()
+ {
+ final DatabaseConfig databaseConfig = new DatabaseConfig();
+ System.setProperty(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK, "true");
+
+ final RowCountCheckConfiguration configuration =
+ new RowCountCheckConfiguration(databaseConfig);
+
+ assertThat(configuration.isEnabled())
+ .as("The system property must win over the DatabaseConfig feature, so it can"
+ + " force-enable in CI even when the feature is off.")
+ .isTrue();
+ }
+
+ @Test
+ void testExcludeTables_systemPropertyAndConfiguredPatterns_replacesConfiguredPatterns()
+ throws DataSetException
+ {
+ final DatabaseConfig databaseConfig = new DatabaseConfig();
+ databaseConfig.setProperty(DatabaseConfig.PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES,
+ new String[] {"CONFIGURED_TABLE"});
+ System.setProperty(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK_EXCLUDE_TABLES,
+ "SYSTEM_PROPERTY_TABLE");
+
+ final RowCountCheckConfiguration configuration =
+ new RowCountCheckConfiguration(databaseConfig);
+
+ assertThat(configuration.getExcludeTableFilter().isValidName("CONFIGURED_TABLE"))
+ .as("The system property must replace, not append to, the configured patterns,"
+ + " so a pattern configured only on DatabaseConfig no longer excludes.")
+ .isTrue();
+ assertThat(configuration.getExcludeTableFilter().isValidName("SYSTEM_PROPERTY_TABLE"))
+ .as("The system property's own pattern must be applied.").isFalse();
+ }
+
+ @Test
+ void testExcludeTables_commaSeparatedSystemProperty_splitsAndTrimsEveryPattern()
+ throws DataSetException
+ {
+ System.setProperty(RowCountCheckConfiguration.DBUNIT_ROW_COUNT_CHECK_EXCLUDE_TABLES,
+ "AUDIT_* , HIBERNATE_SEQUENCES");
+
+ final RowCountCheckConfiguration configuration =
+ new RowCountCheckConfiguration(new DatabaseConfig());
+
+ assertThat(configuration.getExcludeTableFilter().isValidName("AUDIT_LOG"))
+ .as("Whitespace around a comma-separated pattern must be trimmed before"
+ + " matching, and wildcards must still work.")
+ .isFalse();
+ assertThat(configuration.getExcludeTableFilter().isValidName("HIBERNATE_SEQUENCES"))
+ .as("Every comma-separated pattern must be applied, not only the first.")
+ .isFalse();
+ assertThat(configuration.getExcludeTableFilter().isValidName("ACCOUNT"))
+ .as("A table matching no configured pattern must not be excluded.").isTrue();
+ }
+
+ @Test
+ void testGetRowCounter_defaultConfiguration_returnsQueryPerTableRowCounter()
+ {
+ final RowCountCheckConfiguration configuration =
+ new RowCountCheckConfiguration(new DatabaseConfig());
+
+ assertThat(configuration.getRowCounter())
+ .as("With no configured PROPERTY_ROW_COUNTER, DatabaseConfig's own default"
+ + " (QueryPerTableRowCounter) must be used.")
+ .isInstanceOf(QueryPerTableRowCounter.class);
+ }
+
+ @Test
+ void testGetRowCounter_customRowCounterConfigured_returnsIt()
+ {
+ final RowCounter customRowCounter = mock(RowCounter.class);
+ final DatabaseConfig databaseConfig = new DatabaseConfig();
+ databaseConfig.setProperty(DatabaseConfig.PROPERTY_ROW_COUNTER, customRowCounter);
+
+ final RowCountCheckConfiguration configuration =
+ new RowCountCheckConfiguration(databaseConfig);
+
+ assertThat(configuration.getRowCounter())
+ .as("A RowCounter configured on DatabaseConfig must be used as-is; there is no"
+ + " system property override for it.")
+ .isSameAs(customRowCounter);
+ }
+}
diff --git a/src/test/java/org/dbunit/database/rowcount/RowCountCheckTest.java b/src/test/java/org/dbunit/database/rowcount/RowCountCheckTest.java
new file mode 100644
index 000000000..2bf7dac80
--- /dev/null
+++ b/src/test/java/org/dbunit/database/rowcount/RowCountCheckTest.java
@@ -0,0 +1,269 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.dbunit.database.DatabaseConfig;
+import org.dbunit.database.IDatabaseConnection;
+import org.dbunit.dataset.IDataSet;
+import org.junit.jupiter.api.Test;
+
+@ClearRowCountCheckSystemProperties
+class RowCountCheckTest
+{
+ @Test
+ void testCapture_disabled_returnsNullAndDoesNotQueryTheConnection() throws Exception
+ {
+ final RowCountCheck check = new RowCountCheck(
+ new RowCountCheckConfiguration(new DatabaseConfig()));
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+
+ final RowCountSnapshot result = check.capture(connection);
+
+ assertThat(result).as("A disabled check must not capture a baseline.").isNull();
+ verifyNoInteractions(connection);
+ }
+
+ @Test
+ void testVerify_disabled_doesNotQueryTheConnection() throws Exception
+ {
+ final RowCountCheck check = new RowCountCheck(
+ new RowCountCheckConfiguration(new DatabaseConfig()));
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ final RowCountSnapshot baseline = new RowCountSnapshot(mapOf("ACCOUNT", 5));
+
+ assertThatCode(() -> check.verify(baseline, connection))
+ .as("A disabled check must not verify, even with a real baseline.")
+ .doesNotThrowAnyException();
+ verifyNoInteractions(connection);
+ }
+
+ @Test
+ void testVerify_nullBaseline_doesNotThrow() throws Exception
+ {
+ final RowCountCheck check =
+ new RowCountCheck(new RowCountCheckConfiguration(enabledDatabaseConfig()));
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+
+ assertThatCode(() -> check.verify(null, connection))
+ .as("A null baseline means no baseline was captured; the check must skip"
+ + " silently rather than fail.")
+ .doesNotThrowAnyException();
+ verifyNoInteractions(connection);
+ }
+
+ @Test
+ void testVerify_countsUnchanged_doesNotThrow() throws Exception
+ {
+ final RowCounter rowCounter = mock(RowCounter.class);
+ final RowCountCheck check = new RowCountCheck(
+ new RowCountCheckConfiguration(enabledDatabaseConfig(rowCounter)));
+ final IDatabaseConnection connection = connectionWithTables("ACCOUNT", "ACCOUNT_AUDIT");
+ when(rowCounter.countRows(any(), any()))
+ .thenReturn(mapOf("ACCOUNT", 5, "ACCOUNT_AUDIT", 0));
+ final RowCountSnapshot baseline = new RowCountSnapshot(mapOf("ACCOUNT", 5, "ACCOUNT_AUDIT", 0));
+
+ assertThatCode(() -> check.verify(baseline, connection))
+ .as("Matching current counts must not be reported as a failure.")
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void testVerify_countsChanged_throwsUnexpectedRowCountExceptionNamingEveryTable()
+ throws Exception
+ {
+ final RowCounter rowCounter = mock(RowCounter.class);
+ final RowCountCheck check = new RowCountCheck(
+ new RowCountCheckConfiguration(enabledDatabaseConfig(rowCounter)));
+ final IDatabaseConnection connection =
+ connectionWithTables("ACCOUNT_AUDIT", "COUNTRY_CODE");
+ when(rowCounter.countRows(any(), any()))
+ .thenReturn(mapOf("ACCOUNT_AUDIT", 3, "COUNTRY_CODE", 0));
+ final RowCountSnapshot baseline =
+ new RowCountSnapshot(mapOf("ACCOUNT_AUDIT", 0, "COUNTRY_CODE", 12));
+
+ assertThatThrownBy(() -> check.verify(baseline, connection))
+ .as("Every table whose count changed must be named in the failure, regardless"
+ + " of which direction it changed.")
+ .isInstanceOf(UnexpectedRowCountException.class)
+ .hasMessageContaining("ACCOUNT_AUDIT").hasMessageContaining("COUNTRY_CODE");
+ }
+
+ @Test
+ void testCaptureThenVerify_tableDroppedBeforeVerify_reportsItWithZeroCurrentCount()
+ throws Exception
+ {
+ final RowCounter rowCounter = mock(RowCounter.class);
+ final RowCountCheck check = new RowCountCheck(
+ new RowCountCheckConfiguration(enabledDatabaseConfig(rowCounter)));
+ final IDatabaseConnection connection =
+ connectionWithSequentialTables(new String[] {"ACCOUNT", "COUNTRY_CODE"},
+ new String[] {"ACCOUNT"});
+ when(rowCounter.countRows(any(), any()))
+ .thenReturn(mapOf("ACCOUNT", 5, "COUNTRY_CODE", 12))
+ .thenReturn(mapOf("ACCOUNT", 5));
+
+ final RowCountSnapshot baseline = check.capture(connection);
+
+ assertThatThrownBy(() -> check.verify(baseline, connection))
+ .as("A table the connection no longer enumerates at verify time - dropped, or"
+ + " a differently-scoped connection - must be reported with a"
+ + " negative delta to zero, not throw a NullPointerException.")
+ .isInstanceOf(UnexpectedRowCountException.class)
+ .hasMessageContaining("COUNTRY_CODE").hasMessageContaining("12 -> 0");
+ }
+
+ @Test
+ void testCaptureThenVerify_tableAddedBeforeVerify_reportsItWithZeroBaselineCount()
+ throws Exception
+ {
+ final RowCounter rowCounter = mock(RowCounter.class);
+ final RowCountCheck check = new RowCountCheck(
+ new RowCountCheckConfiguration(enabledDatabaseConfig(rowCounter)));
+ final IDatabaseConnection connection = connectionWithSequentialTables(
+ new String[] {"ACCOUNT"}, new String[] {"ACCOUNT", "COUNTRY_CODE"});
+ when(rowCounter.countRows(any(), any()))
+ .thenReturn(mapOf("ACCOUNT", 5))
+ .thenReturn(mapOf("ACCOUNT", 5, "COUNTRY_CODE", 12));
+
+ final RowCountSnapshot baseline = check.capture(connection);
+
+ assertThatThrownBy(() -> check.verify(baseline, connection))
+ .as("A table the connection newly enumerates at verify time - created since"
+ + " the baseline, or a differently-scoped connection - must be"
+ + " reported with a positive delta from zero, naming it even though"
+ + " the baseline never saw it.")
+ .isInstanceOf(UnexpectedRowCountException.class)
+ .hasMessageContaining("COUNTRY_CODE").hasMessageContaining("0 -> 12");
+ }
+
+ @Test
+ void testCapture_configuredCounter_delegatesToItWithTheFilteredTableNames() throws Exception
+ {
+ final RowCounter rowCounter = mock(RowCounter.class);
+ final RowCountCheck check = new RowCountCheck(
+ new RowCountCheckConfiguration(enabledDatabaseConfig(rowCounter)));
+ final IDatabaseConnection connection = connectionWithTables("ACCOUNT", "ACCOUNT_AUDIT");
+ when(rowCounter.countRows(any(), any()))
+ .thenReturn(mapOf("ACCOUNT", 5, "ACCOUNT_AUDIT", 0));
+
+ check.capture(connection);
+
+ verify(rowCounter).countRows(connection, Arrays.asList("ACCOUNT", "ACCOUNT_AUDIT"));
+ }
+
+ @Test
+ void testCapture_excludedTables_areNotPassedToTheCounter() throws Exception
+ {
+ final RowCounter rowCounter = mock(RowCounter.class);
+ final DatabaseConfig databaseConfig = enabledDatabaseConfig(rowCounter);
+ databaseConfig.setProperty(DatabaseConfig.PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES,
+ new String[] {"AUDIT_*"});
+ final RowCountCheck check = new RowCountCheck(new RowCountCheckConfiguration(databaseConfig));
+ final IDatabaseConnection connection = connectionWithTables("ACCOUNT", "AUDIT_LOG");
+ when(rowCounter.countRows(any(), any())).thenReturn(mapOf("ACCOUNT", 5));
+
+ check.capture(connection);
+
+ verify(rowCounter).countRows(connection, Collections.singletonList("ACCOUNT"));
+ }
+
+ @Test
+ void testCapture_customCounter_usesItInsteadOfTheDefault() throws Exception
+ {
+ final RowCounter customRowCounter = mock(RowCounter.class);
+ final RowCountCheck check = new RowCountCheck(
+ new RowCountCheckConfiguration(enabledDatabaseConfig(customRowCounter)));
+ final IDatabaseConnection connection = connectionWithTables("ACCOUNT");
+ when(customRowCounter.countRows(any(), any())).thenReturn(mapOf("ACCOUNT", 42));
+
+ final RowCountSnapshot snapshot = check.capture(connection);
+
+ assertThat(snapshot.getRowCounts())
+ .as("The configured custom counter's result must be used, not the default"
+ + " QueryPerTableRowCounter's.")
+ .containsEntry("ACCOUNT", 42);
+ }
+
+ private static DatabaseConfig enabledDatabaseConfig()
+ {
+ final DatabaseConfig databaseConfig = new DatabaseConfig();
+ databaseConfig.setFeature(DatabaseConfig.FEATURE_ROW_COUNT_CHECK, true);
+ return databaseConfig;
+ }
+
+ private static DatabaseConfig enabledDatabaseConfig(final RowCounter rowCounter)
+ {
+ final DatabaseConfig databaseConfig = enabledDatabaseConfig();
+ databaseConfig.setProperty(DatabaseConfig.PROPERTY_ROW_COUNTER, rowCounter);
+ return databaseConfig;
+ }
+
+ private static IDatabaseConnection connectionWithTables(final String... tableNames)
+ throws Exception
+ {
+ final IDataSet dataSet = mock(IDataSet.class);
+ when(dataSet.getTableNames()).thenReturn(tableNames);
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ when(connection.createDataSet()).thenReturn(dataSet);
+ return connection;
+ }
+
+ /**
+ * A connection whose enumerated tables differ between its first {@code createDataSet()}
+ * call and every call after, simulating a table dropped or created between a baseline
+ * capture and a later verify - or two calls that simply reach different connections.
+ */
+ private static IDatabaseConnection connectionWithSequentialTables(
+ final String[] firstCallTableNames, final String[] laterCallsTableNames)
+ throws Exception
+ {
+ final IDataSet dataSet = mock(IDataSet.class);
+ when(dataSet.getTableNames()).thenReturn(firstCallTableNames, laterCallsTableNames);
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ when(connection.createDataSet()).thenReturn(dataSet);
+ return connection;
+ }
+
+ private static Map mapOf(final Object... tableNameAndCountPairs)
+ {
+ final Map rowCounts = new LinkedHashMap<>();
+ for (int i = 0; i < tableNameAndCountPairs.length; i += 2)
+ {
+ rowCounts.put((String) tableNameAndCountPairs[i],
+ (Integer) tableNameAndCountPairs[i + 1]);
+ }
+ return rowCounts;
+ }
+}
diff --git a/src/test/java/org/dbunit/database/rowcount/RowCountCheckerTest.java b/src/test/java/org/dbunit/database/rowcount/RowCountCheckerTest.java
new file mode 100644
index 000000000..1e6630b9f
--- /dev/null
+++ b/src/test/java/org/dbunit/database/rowcount/RowCountCheckerTest.java
@@ -0,0 +1,242 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+
+import org.dbunit.database.DatabaseConfig;
+import org.dbunit.database.IDatabaseConnection;
+import org.dbunit.dataset.IDataSet;
+import org.junit.jupiter.api.Test;
+
+@ClearRowCountCheckSystemProperties
+class RowCountCheckerTest
+{
+ @Test
+ void testGetRowCountCheck_beforeAnyUse_returnsNull()
+ {
+ final RowCountChecker checker = new RowCountChecker();
+
+ assertThat(checker.getRowCountCheck())
+ .as("No RowCountCheck must be resolved before capture()/verify() ever run.")
+ .isNull();
+ }
+
+ @Test
+ void testCapture_noRowCountCheckSet_lazilyResolvesOneFromConnectionConfig()
+ throws Exception
+ {
+ final RowCountChecker checker = new RowCountChecker();
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ when(connection.getConfig()).thenReturn(new DatabaseConfig());
+
+ checker.capture(connection);
+
+ assertThat(checker.getRowCountCheck())
+ .as("capture() must lazily build a RowCountCheck from the connection's"
+ + " DatabaseConfig when none was set.")
+ .isNotNull();
+ }
+
+ @Test
+ void testCapture_rowCountCheckAlreadySet_reusesItWithoutQueryingConnectionConfig()
+ throws Exception
+ {
+ final RowCountChecker checker = new RowCountChecker();
+ final RowCountCheck mockRowCountCheck = mock(RowCountCheck.class);
+ checker.setRowCountCheck(mockRowCountCheck);
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ final RowCountSnapshot snapshot = new RowCountSnapshot(Collections.emptyMap());
+ when(mockRowCountCheck.capture(connection)).thenReturn(snapshot);
+
+ checker.capture(connection);
+
+ verify(connection, never()).getConfig();
+ assertThat(checker.getRowCountCheck())
+ .as("A RowCountCheck supplied via setRowCountCheck() must be used as-is,"
+ + " never rebuilt.")
+ .isSameAs(mockRowCountCheck);
+ }
+
+ @Test
+ void testVerify_noBaselineCaptured_doesNotQueryTheConnection() throws Exception
+ {
+ final RowCountChecker checker = new RowCountChecker();
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+
+ assertThatCode(() -> checker.verify(connection))
+ .as("No captured baseline means nothing to compare; verify() must skip"
+ + " silently rather than fail.")
+ .doesNotThrowAnyException();
+ verifyNoInteractions(connection);
+ }
+
+ @Test
+ void testVerify_baselineCaptured_delegatesToTheResolvedRowCountCheck() throws Exception
+ {
+ final RowCountChecker checker = new RowCountChecker();
+ final RowCountCheck mockRowCountCheck = mock(RowCountCheck.class);
+ checker.setRowCountCheck(mockRowCountCheck);
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ final RowCountSnapshot snapshot =
+ new RowCountSnapshot(Collections.singletonMap("ACCOUNT", 5));
+ when(mockRowCountCheck.capture(connection)).thenReturn(snapshot);
+ checker.capture(connection);
+
+ checker.verify(connection);
+
+ verify(mockRowCountCheck).verify(snapshot, connection);
+ }
+
+ @Test
+ void testVerify_rowCountCheckThrows_propagates() throws Exception
+ {
+ final RowCountChecker checker = new RowCountChecker();
+ final RowCountCheck mockRowCountCheck = mock(RowCountCheck.class);
+ checker.setRowCountCheck(mockRowCountCheck);
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ final RowCountSnapshot snapshot =
+ new RowCountSnapshot(Collections.singletonMap("ACCOUNT", 5));
+ when(mockRowCountCheck.capture(connection)).thenReturn(snapshot);
+ checker.capture(connection);
+ final UnexpectedRowCountException failure = new UnexpectedRowCountException(
+ Collections.singletonList(new RowCountDifference("ACCOUNT", 5, 8)));
+ doThrow(failure).when(mockRowCountCheck).verify(snapshot, connection);
+
+ assertThatThrownBy(() -> checker.verify(connection))
+ .as("A row count difference detected during verify() must propagate as-is.")
+ .isSameAs(failure);
+ }
+
+ @Test
+ void testDiscardBaseline_afterCapture_verifyBecomesNoOp() throws Exception
+ {
+ final RowCountChecker checker = new RowCountChecker();
+ final RowCountCheck mockRowCountCheck = mock(RowCountCheck.class);
+ checker.setRowCountCheck(mockRowCountCheck);
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ final RowCountSnapshot snapshot =
+ new RowCountSnapshot(Collections.singletonMap("ACCOUNT", 5));
+ when(mockRowCountCheck.capture(connection)).thenReturn(snapshot);
+ checker.capture(connection);
+
+ checker.discardBaseline();
+ checker.verify(connection);
+
+ verify(mockRowCountCheck, never()).verify(any(), any());
+ }
+
+ @Test
+ void testCaptureThenVerify_bothReuseTheSameResolvedRowCountCheck() throws Exception
+ {
+ final RowCountChecker checker = new RowCountChecker();
+ final DatabaseConfig config = new DatabaseConfig();
+ config.setFeature(DatabaseConfig.FEATURE_ROW_COUNT_CHECK, true);
+ final IDataSet dataSet = mock(IDataSet.class);
+ when(dataSet.getTableNames()).thenReturn(new String[0]);
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ when(connection.getConfig()).thenReturn(config);
+ when(connection.createDataSet()).thenReturn(dataSet);
+
+ checker.capture(connection);
+ final RowCountCheck resolvedDuringCapture = checker.getRowCountCheck();
+ checker.verify(connection);
+
+ verify(connection, times(1)).getConfig();
+ assertThat(checker.getRowCountCheck())
+ .as("verify() must reuse the same RowCountCheck capture() resolved, not"
+ + " build a second one.")
+ .isSameAs(resolvedDuringCapture);
+ }
+
+ @Test
+ void testHasBaseline_beforeAnyUse_returnsFalse()
+ {
+ final RowCountChecker checker = new RowCountChecker();
+
+ assertThat(checker.hasBaseline())
+ .as("No baseline has been captured yet.").isFalse();
+ }
+
+ @Test
+ void testHasBaseline_afterCapture_returnsTrue() throws Exception
+ {
+ final RowCountChecker checker = new RowCountChecker();
+ final RowCountCheck mockRowCountCheck = mock(RowCountCheck.class);
+ checker.setRowCountCheck(mockRowCountCheck);
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ final RowCountSnapshot snapshot =
+ new RowCountSnapshot(Collections.singletonMap("ACCOUNT", 5));
+ when(mockRowCountCheck.capture(connection)).thenReturn(snapshot);
+
+ checker.capture(connection);
+
+ assertThat(checker.hasBaseline())
+ .as("A caller must be able to tell a baseline was captured without needing a"
+ + " connection just to ask.")
+ .isTrue();
+ }
+
+ @Test
+ void testHasBaseline_captureResolvesDisabled_returnsFalse() throws Exception
+ {
+ final RowCountChecker checker = new RowCountChecker();
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ when(connection.getConfig()).thenReturn(new DatabaseConfig());
+
+ checker.capture(connection);
+
+ assertThat(checker.hasBaseline())
+ .as("A disabled check captures no baseline, so a caller can skip acquiring a"
+ + " connection for verify() entirely.")
+ .isFalse();
+ }
+
+ @Test
+ void testHasBaseline_afterDiscardBaseline_returnsFalse() throws Exception
+ {
+ final RowCountChecker checker = new RowCountChecker();
+ final RowCountCheck mockRowCountCheck = mock(RowCountCheck.class);
+ checker.setRowCountCheck(mockRowCountCheck);
+ final IDatabaseConnection connection = mock(IDatabaseConnection.class);
+ final RowCountSnapshot snapshot =
+ new RowCountSnapshot(Collections.singletonMap("ACCOUNT", 5));
+ when(mockRowCountCheck.capture(connection)).thenReturn(snapshot);
+ checker.capture(connection);
+
+ checker.discardBaseline();
+
+ assertThat(checker.hasBaseline())
+ .as("A discarded baseline must no longer be reported as held.").isFalse();
+ }
+}
diff --git a/src/test/java/org/dbunit/database/rowcount/RowCountDifferenceTest.java b/src/test/java/org/dbunit/database/rowcount/RowCountDifferenceTest.java
new file mode 100644
index 000000000..5fa7df219
--- /dev/null
+++ b/src/test/java/org/dbunit/database/rowcount/RowCountDifferenceTest.java
@@ -0,0 +1,93 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+class RowCountDifferenceTest
+{
+ @Test
+ void testGetDelta_currentGreaterThanBaseline_returnsPositiveDelta()
+ {
+ final RowCountDifference difference = new RowCountDifference("ACCOUNT_AUDIT", 0, 3);
+
+ assertThat(difference.getDelta())
+ .as("Delta must be current minus baseline.").isEqualTo(3);
+ }
+
+ @Test
+ void testGetDelta_currentLessThanBaseline_returnsNegativeDelta()
+ {
+ final RowCountDifference difference = new RowCountDifference("COUNTRY_CODE", 12, 0);
+
+ assertThat(difference.getDelta())
+ .as("Delta must be current minus baseline.").isEqualTo(-12);
+ }
+
+ @Test
+ void testEquals_sameTableNameAndCounts_returnsTrue()
+ {
+ final RowCountDifference first = new RowCountDifference("ACCOUNT_AUDIT", 0, 3);
+ final RowCountDifference second = new RowCountDifference("ACCOUNT_AUDIT", 0, 3);
+
+ assertThat(first).as("Differences with identical field values must be equal.")
+ .isEqualTo(second);
+ assertThat(first.hashCode())
+ .as("Equal differences must have equal hash codes.")
+ .isEqualTo(second.hashCode());
+ }
+
+ @Test
+ void testEquals_differentCurrentCount_returnsFalse()
+ {
+ final RowCountDifference first = new RowCountDifference("ACCOUNT_AUDIT", 0, 3);
+ final RowCountDifference second = new RowCountDifference("ACCOUNT_AUDIT", 0, 4);
+
+ assertThat(first).as("Differences with a different current count must not be equal.")
+ .isNotEqualTo(second);
+ }
+
+ @Test
+ void testToString_positiveDelta_namesTableBothCountsAndRowsLeftBehindAdvice()
+ {
+ final RowCountDifference difference = new RowCountDifference("ACCOUNT_AUDIT", 0, 3);
+
+ assertThat(difference.toString())
+ .as("A positive delta must be reported as rows left behind, with a fix"
+ + " pointing at the expected dataset or the exclude list.")
+ .contains("ACCOUNT_AUDIT").contains("0 -> 3").contains("(+3)")
+ .contains("rows left behind");
+ }
+
+ @Test
+ void testToString_negativeDelta_namesTableBothCountsAndRowsRemovedAdvice()
+ {
+ final RowCountDifference difference = new RowCountDifference("COUNTRY_CODE", 12, 0);
+
+ assertThat(difference.toString())
+ .as("A negative delta must be reported as rows removed that should remain,"
+ + " with a fix pointing at the prep/expected dataset or the exclude list.")
+ .contains("COUNTRY_CODE").contains("12 -> 0").contains("(-12)")
+ .contains("rows removed that should remain");
+ }
+}
diff --git a/src/test/java/org/dbunit/database/rowcount/RowCountSnapshotTest.java b/src/test/java/org/dbunit/database/rowcount/RowCountSnapshotTest.java
new file mode 100644
index 000000000..825c509b4
--- /dev/null
+++ b/src/test/java/org/dbunit/database/rowcount/RowCountSnapshotTest.java
@@ -0,0 +1,139 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+class RowCountSnapshotTest
+{
+ @Test
+ void testDifference_identicalSnapshots_returnsEmptyList()
+ {
+ final RowCountSnapshot baseline = snapshotOf("ACCOUNT", 5, "COUNTRY_CODE", 12);
+ final RowCountSnapshot current = snapshotOf("ACCOUNT", 5, "COUNTRY_CODE", 12);
+
+ assertThat(baseline.difference(current))
+ .as("Identical snapshots must report no differences.").isEmpty();
+ }
+
+ @Test
+ void testDifference_tableGainedRows_returnsPositiveDelta()
+ {
+ final RowCountSnapshot baseline = snapshotOf("ACCOUNT_AUDIT", 0);
+ final RowCountSnapshot current = snapshotOf("ACCOUNT_AUDIT", 3);
+
+ final List differences = baseline.difference(current);
+
+ assertThat(differences).as("Exactly one table changed.").hasSize(1);
+ assertThat(differences.get(0))
+ .as("A table with more current rows than baseline must report a positive delta"
+ + " naming both counts.")
+ .isEqualTo(new RowCountDifference("ACCOUNT_AUDIT", 0, 3));
+ }
+
+ @Test
+ void testDifference_tableLostRows_returnsNegativeDelta()
+ {
+ final RowCountSnapshot baseline = snapshotOf("COUNTRY_CODE", 12);
+ final RowCountSnapshot current = snapshotOf("COUNTRY_CODE", 0);
+
+ final List differences = baseline.difference(current);
+
+ assertThat(differences).as("Exactly one table changed.").hasSize(1);
+ assertThat(differences.get(0))
+ .as("A table with fewer current rows than baseline must report a negative delta"
+ + " naming both counts.")
+ .isEqualTo(new RowCountDifference("COUNTRY_CODE", 12, 0));
+ }
+
+ @Test
+ void testDifference_multipleTablesChanged_returnsOneDifferencePerTable()
+ {
+ final RowCountSnapshot baseline =
+ snapshotOf("ACCOUNT", 5, "ACCOUNT_AUDIT", 0, "COUNTRY_CODE", 12);
+ final RowCountSnapshot current =
+ snapshotOf("ACCOUNT", 5, "ACCOUNT_AUDIT", 3, "COUNTRY_CODE", 0);
+
+ final List differences = baseline.difference(current);
+
+ assertThat(differences)
+ .as("Only the two tables whose counts actually changed must be reported;"
+ + " ACCOUNT is unchanged and must be absent.")
+ .containsExactlyInAnyOrder(new RowCountDifference("ACCOUNT_AUDIT", 0, 3),
+ new RowCountDifference("COUNTRY_CODE", 12, 0));
+ }
+
+ @Test
+ void testDifference_tableMissingFromCurrent_treatsItAsZeroInsteadOfThrowing()
+ {
+ final RowCountSnapshot baseline = snapshotOf("ACCOUNT", 5, "COUNTRY_CODE", 12);
+ final RowCountSnapshot current = snapshotOf("ACCOUNT", 5);
+
+ final List differences = baseline.difference(current);
+
+ assertThat(differences)
+ .as("A table dropped between the two captures must be reported with a"
+ + " current count of 0, not throw a NullPointerException.")
+ .containsExactly(new RowCountDifference("COUNTRY_CODE", 12, 0));
+ }
+
+ @Test
+ void testDifference_tableAddedInCurrent_treatsBaselineAsZero()
+ {
+ final RowCountSnapshot baseline = snapshotOf("ACCOUNT", 5);
+ final RowCountSnapshot current = snapshotOf("ACCOUNT", 5, "COUNTRY_CODE", 12);
+
+ final List differences = baseline.difference(current);
+
+ assertThat(differences)
+ .as("A table created between the two captures must be reported with a"
+ + " baseline count of 0, naming it even though this snapshot never"
+ + " saw it.")
+ .containsExactly(new RowCountDifference("COUNTRY_CODE", 0, 12));
+ }
+
+ @Test
+ void testGetRowCounts_afterConstruction_returnsSuppliedCounts()
+ {
+ final RowCountSnapshot snapshot = snapshotOf("ACCOUNT", 5);
+
+ assertThat(snapshot.getRowCounts())
+ .as("getRowCounts() must return the counts the snapshot was built from.")
+ .hasSize(1).containsEntry("ACCOUNT", 5);
+ }
+
+ private static RowCountSnapshot snapshotOf(final Object... tableNameAndCountPairs)
+ {
+ final Map rowCounts = new LinkedHashMap<>();
+ for (int i = 0; i < tableNameAndCountPairs.length; i += 2)
+ {
+ rowCounts.put((String) tableNameAndCountPairs[i],
+ (Integer) tableNameAndCountPairs[i + 1]);
+ }
+ return new RowCountSnapshot(rowCounts);
+ }
+}
diff --git a/src/test/java/org/dbunit/database/rowcount/RowCounterContractTest.java b/src/test/java/org/dbunit/database/rowcount/RowCounterContractTest.java
new file mode 100644
index 000000000..9a599965d
--- /dev/null
+++ b/src/test/java/org/dbunit/database/rowcount/RowCounterContractTest.java
@@ -0,0 +1,109 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import org.dbunit.database.IDatabaseConnection;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Contract every {@link RowCounter} implementation must satisfy, so a user-written counter can
+ * be checked against the same rules the shipped implementations are. Extend this class and
+ * implement {@link #createRowCounter()} and {@link #createConnectionReturningRowCount(int)} to
+ * verify a new implementation.
+ */
+abstract class RowCounterContractTest
+{
+ /**
+ * Creates the {@link RowCounter} under test.
+ */
+ protected abstract RowCounter createRowCounter();
+
+ /**
+ * Creates a connection that reports {@code rowCount} for every table name the counter under
+ * test may query.
+ */
+ protected abstract IDatabaseConnection createConnectionReturningRowCount(int rowCount)
+ throws Exception;
+
+ @Test
+ void testCountRows_requestedTables_returnsAnEntryForEveryOne() throws Exception
+ {
+ final RowCounter rowCounter = createRowCounter();
+ final IDatabaseConnection connection = createConnectionReturningRowCount(0);
+ final List tableNames = Arrays.asList("ACCOUNT", "ACCOUNT_AUDIT", "COUNTRY_CODE");
+
+ final Map result = rowCounter.countRows(connection, tableNames);
+
+ assertThat(result.keySet())
+ .as("Every requested table name must appear as a key in the result.")
+ .containsExactlyInAnyOrderElementsOf(tableNames);
+ }
+
+ @Test
+ void testCountRows_requestedTables_returnsNoUnrequestedEntries() throws Exception
+ {
+ final RowCounter rowCounter = createRowCounter();
+ final IDatabaseConnection connection = createConnectionReturningRowCount(0);
+ final List tableNames = Arrays.asList("ACCOUNT", "ACCOUNT_AUDIT");
+
+ final Map result = rowCounter.countRows(connection, tableNames);
+
+ assertThat(result)
+ .as("The result must contain no entries beyond the requested tables.")
+ .hasSameSizeAs(tableNames);
+ }
+
+ @Test
+ void testCountRows_mixedCaseTableNames_keysMatchTheSuppliedNamesExactly() throws Exception
+ {
+ final RowCounter rowCounter = createRowCounter();
+ final IDatabaseConnection connection = createConnectionReturningRowCount(0);
+ final List tableNames = Arrays.asList("Account", "ACCOUNT_AUDIT");
+
+ final Map result = rowCounter.countRows(connection, tableNames);
+
+ assertThat(result.keySet())
+ .as("Result keys must match the supplied table names exactly, including case -"
+ + " not normalized to another form.")
+ .containsExactlyInAnyOrderElementsOf(tableNames);
+ }
+
+ @Test
+ void testCountRows_connectionReportsNonZeroCount_returnsThatExactCount() throws Exception
+ {
+ final RowCounter rowCounter = createRowCounter();
+ final IDatabaseConnection connection = createConnectionReturningRowCount(7);
+ final List tableNames = Arrays.asList("ACCOUNT", "ACCOUNT_AUDIT");
+
+ final Map result = rowCounter.countRows(connection, tableNames);
+
+ assertThat(result.values())
+ .as("Counts must be exact, not estimated or defaulted regardless of what the"
+ + " connection actually reports.")
+ .containsOnly(7);
+ }
+}
diff --git a/src/test/java/org/dbunit/database/rowcount/UnexpectedRowCountExceptionTest.java b/src/test/java/org/dbunit/database/rowcount/UnexpectedRowCountExceptionTest.java
new file mode 100644
index 000000000..eaffbf862
--- /dev/null
+++ b/src/test/java/org/dbunit/database/rowcount/UnexpectedRowCountExceptionTest.java
@@ -0,0 +1,78 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.database.rowcount;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+class UnexpectedRowCountExceptionTest
+{
+ @Test
+ void testGetMessage_singleDifference_namesTheTableAndBothCounts()
+ {
+ final List differences =
+ Collections.singletonList(new RowCountDifference("ACCOUNT_AUDIT", 0, 3));
+
+ final UnexpectedRowCountException exception =
+ new UnexpectedRowCountException(differences);
+
+ assertThat(exception.getMessage())
+ .as("The message must name the affected table and both its counts.")
+ .contains("ACCOUNT_AUDIT").contains("0 -> 3").contains("1 table differs");
+ }
+
+ @Test
+ void testGetMessage_positiveAndNegativeDeltas_describesEachDirectionDistinctly()
+ {
+ final List differences = Arrays.asList(
+ new RowCountDifference("ACCOUNT_AUDIT", 0, 3),
+ new RowCountDifference("COUNTRY_CODE", 12, 0));
+
+ final UnexpectedRowCountException exception =
+ new UnexpectedRowCountException(differences);
+
+ assertThat(exception.getMessage())
+ .as("The message must report both tables, each with wording specific to its"
+ + " own delta direction, and the overall count of affected tables.")
+ .contains("2 tables differ")
+ .contains("ACCOUNT_AUDIT").contains("rows left behind")
+ .contains("COUNTRY_CODE").contains("rows removed that should remain");
+ }
+
+ @Test
+ void testGetDifferences_afterConstruction_returnsSuppliedDifferences()
+ {
+ final List differences =
+ Collections.singletonList(new RowCountDifference("ACCOUNT_AUDIT", 0, 3));
+
+ final UnexpectedRowCountException exception =
+ new UnexpectedRowCountException(differences);
+
+ assertThat(exception.getDifferences())
+ .as("getDifferences() must return the differences the exception was built from.")
+ .isEqualTo(differences);
+ }
+}
diff --git a/src/test/java/org/dbunit/junit/jupiter/DbUnitExtensionRowCountCheckTest.java b/src/test/java/org/dbunit/junit/jupiter/DbUnitExtensionRowCountCheckTest.java
new file mode 100644
index 000000000..14fe37958
--- /dev/null
+++ b/src/test/java/org/dbunit/junit/jupiter/DbUnitExtensionRowCountCheckTest.java
@@ -0,0 +1,286 @@
+/*
+ *
+ * The DbUnit Database Testing Framework
+ * Copyright (C)2002-2026, DbUnit.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+ *
+ */
+package org.dbunit.junit.jupiter;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Optional;
+
+import org.dbunit.DefaultDatabaseTester;
+import org.dbunit.IDatabaseTester;
+import org.dbunit.IOperationListener;
+import org.dbunit.database.DatabaseConfig;
+import org.dbunit.database.IDatabaseConnection;
+import org.dbunit.database.rowcount.ClearRowCountCheckSystemProperties;
+import org.dbunit.database.rowcount.RowCountChecker;
+import org.dbunit.database.rowcount.UnexpectedRowCountException;
+import org.dbunit.dataset.IDataSet;
+import org.dbunit.operation.DatabaseOperation;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+/**
+ * Unit tests for the row count check wiring in {@link DbUnitExtension}, mirroring
+ * {@link DbUnitExtensionTest}'s Mockito-based style. {@link DbUnitExtensionTest} itself
+ * already covers the case where {@link IDatabaseTester#getConnection()} goes unstubbed
+ * (returning null, the Mockito default) and confirms that leaves onSetup()/onTearDown()
+ * unaffected; this class exercises the check with a real connection stubbed in.
+ */
+@ExtendWith(MockitoExtension.class)
+@ClearRowCountCheckSystemProperties
+class DbUnitExtensionRowCountCheckTest {
+ @Mock
+ ExtensionContext context;
+
+ @Mock
+ ExtensionContext.Store store;
+
+ @Mock
+ IDatabaseTester databaseTester;
+
+ @Mock
+ IDatabaseConnection databaseConnection;
+
+ final DbUnitExtension extension = new DbUnitExtension();
+
+ @Test
+ void testBeforeTestExecution_checkEnabled_capturesBaselineWithoutClosingTheConnection()
+ throws Exception {
+ givenTesterField();
+ stubConnection(enabledDatabaseConfig(), "ACCOUNT");
+ when(databaseConnection.getRowCount("ACCOUNT")).thenReturn(5);
+
+ extension.beforeTestExecution(context);
+
+ verify(store).put(eq(DbUnitExtension.ROW_COUNT_CHECKER_KEY),
+ any(RowCountChecker.class));
+ verify(databaseConnection, never()).close();
+ verify(databaseTester).onSetup();
+ }
+
+ @Test
+ void testBeforeTestExecution_checkDisabled_neverQueriesTheConnection()
+ throws Exception {
+ givenTesterField();
+ stubConnection(new DatabaseConfig()); // FEATURE_ROW_COUNT_CHECK defaults to false
+
+ extension.beforeTestExecution(context);
+
+ verify(databaseConnection, never()).createDataSet();
+ verify(databaseConnection, never()).getRowCount(anyString());
+ }
+
+ @Test
+ void testBeforeTestExecution_fixedConnectionTester_neverClosesTheConnectionOnSetupNeeds()
+ throws Exception {
+ // DefaultDatabaseTester(connection) returns this same connection from every
+ // getConnection() call, including the one onSetup() makes right after the row
+ // count check captures its baseline; NONE + a non-closing listener isolates that
+ // onSetup() call so any close() interaction can only have come from the check
+ // itself (#944)
+ when(databaseConnection.getConfig()).thenReturn(enabledDatabaseConfig());
+ final IDataSet dataSet = mock(IDataSet.class);
+ when(dataSet.getTableNames()).thenReturn(new String[] {"ACCOUNT"});
+ when(databaseConnection.createDataSet()).thenReturn(dataSet);
+ when(databaseConnection.getRowCount("ACCOUNT")).thenReturn(5);
+ final IDatabaseTester fixedConnectionTester =
+ new DefaultDatabaseTester(databaseConnection);
+ fixedConnectionTester.setSetUpOperation(DatabaseOperation.NONE);
+ fixedConnectionTester.setOperationListener(IOperationListener.NO_OP_OPERATION_LISTENER);
+ when(context.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store);
+ when(context.getTestInstance()).thenReturn(Optional
+ .of(new DbUnitExtensionTest.HasTester(fixedConnectionTester)));
+
+ extension.beforeTestExecution(context);
+
+ verify(databaseConnection, never()).close();
+ }
+
+ @Test
+ void testBeforeTestExecution_testerConnectionNull_doesNotThrowAndStillRunsOnSetup()
+ throws Exception {
+ givenTesterField();
+ when(databaseTester.getConnection()).thenReturn(null);
+
+ assertThatCode(() -> extension.beforeTestExecution(context))
+ .as("A tester with no connection to inspect (e.g. a test double) must be"
+ + " tolerated, not throw a NullPointerException.")
+ .doesNotThrowAnyException();
+ verify(databaseTester).onSetup();
+ }
+
+ @Test
+ void testAfterTestExecution_countsUnchangedAndNoExecutionException_doesNotThrow()
+ throws Exception {
+ givenStoredTester();
+ when(context.getExecutionException()).thenReturn(Optional.empty());
+ final RowCountChecker rowCountChecker = capturedRealBaseline("ACCOUNT", 5);
+ when(store.get(DbUnitExtension.ROW_COUNT_CHECKER_KEY, RowCountChecker.class))
+ .thenReturn(rowCountChecker);
+ // current count still 5, read back through the same stub the baseline capture
+ // above used - unchanged
+
+ assertThatCode(() -> extension.afterTestExecution(context))
+ .as("Matching current counts must not be reported as a failure.")
+ .doesNotThrowAnyException();
+ verify(databaseTester).onTearDown();
+ verify(databaseConnection, never()).close();
+ }
+
+ @Test
+ void testAfterTestExecution_rowCountChanged_throwsUnexpectedRowCountException()
+ throws Exception {
+ givenStoredTester();
+ when(context.getExecutionException()).thenReturn(Optional.empty());
+ final RowCountChecker rowCountChecker = capturedRealBaseline("ACCOUNT", 5);
+ when(store.get(DbUnitExtension.ROW_COUNT_CHECKER_KEY, RowCountChecker.class))
+ .thenReturn(rowCountChecker);
+ // the fresh count this verify() call reads back differs from the captured baseline
+ when(databaseConnection.getRowCount("ACCOUNT")).thenReturn(8);
+
+ assertThatThrownBy(() -> extension.afterTestExecution(context))
+ .as("A table whose count no longer matches the baseline must fail the test.")
+ .isInstanceOf(UnexpectedRowCountException.class)
+ .hasMessageContaining("ACCOUNT");
+ }
+
+ @Test
+ void testAfterTestExecution_executionExceptionPresent_skipsVerification() throws Exception {
+ givenStoredBaseline();
+ when(context.getExecutionException())
+ .thenReturn(Optional.of(new AssertionError("test method failed")));
+
+ assertThatCode(() -> extension.afterTestExecution(context))
+ .as("The database is in an unknown state after a test failure, so a count"
+ + " difference would be noise; verification must be skipped entirely.")
+ .doesNotThrowAnyException();
+ verify(databaseTester, never()).getConnection();
+ }
+
+ @Test
+ void testAfterTestExecution_noBaselineStored_skipsVerification() throws Exception {
+ givenStoredTester();
+ when(store.get(DbUnitExtension.ROW_COUNT_CHECKER_KEY, RowCountChecker.class)).thenReturn(null);
+
+ assertThatCode(() -> extension.afterTestExecution(context))
+ .as("No stored checker (check disabled, or the tester had no connection to"
+ + " capture one from) must skip verification silently.")
+ .doesNotThrowAnyException();
+ verify(databaseTester, never()).getConnection();
+ }
+
+ @Test
+ void testAfterTestExecution_checkDisabled_neverAcquiresASecondConnection()
+ throws Exception {
+ givenTesterField();
+ stubConnection(new DatabaseConfig()); // FEATURE_ROW_COUNT_CHECK defaults to false
+ extension.beforeTestExecution(context);
+ // afterTestExecution() looks the tester back up from the store by key, same as
+ // beforeTestExecution() stored it under - givenTesterField() only covers resolving
+ // it the first time, via the test instance field
+ when(store.get(DbUnitExtension.TESTER_KEY, IDatabaseTester.class)).thenReturn(databaseTester);
+ final ArgumentCaptor checkerCaptor =
+ ArgumentCaptor.forClass(RowCountChecker.class);
+ verify(store).put(eq(DbUnitExtension.ROW_COUNT_CHECKER_KEY), checkerCaptor.capture());
+ when(store.get(DbUnitExtension.ROW_COUNT_CHECKER_KEY, RowCountChecker.class))
+ .thenReturn(checkerCaptor.getValue());
+
+ extension.afterTestExecution(context);
+
+ assertThat(checkerCaptor.getValue().hasBaseline())
+ .as("A disabled check must resolve a RowCountChecker holding no baseline.")
+ .isFalse();
+ verify(databaseTester, times(1)).getConnection();
+ verify(databaseConnection, never()).close();
+ verify(databaseTester).onTearDown();
+ }
+
+ private void givenTesterField() {
+ final DbUnitExtensionTest.HasTester testInstance =
+ new DbUnitExtensionTest.HasTester(databaseTester);
+ when(context.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store);
+ when(context.getTestInstance()).thenReturn(Optional.of(testInstance));
+ }
+
+ private void givenStoredTester() {
+ when(context.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store);
+ when(store.get(DbUnitExtension.TESTER_KEY, IDatabaseTester.class)).thenReturn(databaseTester);
+ }
+
+ private void givenStoredBaseline() {
+ givenStoredTester();
+ final RowCountChecker rowCountChecker = mock(RowCountChecker.class);
+ when(rowCountChecker.hasBaseline()).thenReturn(true);
+ when(store.get(DbUnitExtension.ROW_COUNT_CHECKER_KEY, RowCountChecker.class))
+ .thenReturn(rowCountChecker);
+ }
+
+ /**
+ * Captures a real baseline of one table into a fresh {@link RowCountChecker}, via
+ * {@link #stubConnection(DatabaseConfig, String...)} against the shared
+ * {@code databaseConnection} mock - so a later stub of {@code getRowCount(tableName)}
+ * within the same test transparently changes what a subsequent {@code verify()} reads
+ * back as the current count.
+ */
+ private RowCountChecker capturedRealBaseline(final String tableName, final int rowCount)
+ throws Exception {
+ stubConnection(enabledDatabaseConfig(), tableName);
+ when(databaseConnection.getRowCount(tableName)).thenReturn(rowCount);
+
+ final RowCountChecker rowCountChecker = new RowCountChecker();
+ rowCountChecker.capture(databaseConnection);
+ return rowCountChecker;
+ }
+
+ private void stubConnection(final DatabaseConfig config, final String... tableNames)
+ throws Exception {
+ when(databaseConnection.getConfig()).thenReturn(config);
+ when(databaseTester.getConnection()).thenReturn(databaseConnection);
+ // only stub table enumeration when the caller expects it to actually be queried -
+ // a disabled check must never reach createDataSet(), and strict stubbing rejects an
+ // unused stub
+ if (tableNames.length > 0) {
+ final IDataSet dataSet = mock(IDataSet.class);
+ when(dataSet.getTableNames()).thenReturn(tableNames);
+ when(databaseConnection.createDataSet()).thenReturn(dataSet);
+ }
+ }
+
+ private static DatabaseConfig enabledDatabaseConfig() {
+ final DatabaseConfig config = new DatabaseConfig();
+ config.setFeature(DatabaseConfig.FEATURE_ROW_COUNT_CHECK, true);
+ return config;
+ }
+}