diff --git a/pom.xml b/pom.xml index d772728ff..5aecb7bca 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.dbunit dbunit - 3.5.1-SNAPSHOT + 3.6.0-SNAPSHOT jar dbUnit Extension https://github.com/dbunit/dbunit-extension diff --git a/src/changes/changes.xml b/src/changes/changes.xml index f1c8dd2ef..781c0b561 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -13,6 +13,17 @@ + + + Add RowCounter, QueryPerTableRowCounter, RowCountSnapshot, RowCountDifference, RowCountCheckConfiguration, RowCountCheck, RowCountChecker, and UnexpectedRowCountException to org.dbunit.database, plus DatabaseConfig.FEATURE_ROW_COUNT_CHECK, PROPERTY_ROW_COUNT_CHECK_EXCLUDE_TABLES, and PROPERTY_ROW_COUNTER: the core of an opt-in diagnostic that compares every table's row count before and after a test, failing the test when a count moved. Not yet wired into any test lifecycle. + + + Wire the row count check into DefaultPrepAndExpectedTestCase: preTest() captures the baseline before setupData(), cleanupData() verifies it after the tear down operation, and postTest(false) discards it so a test that already failed does not also report a row count difference as noise. Add DefaultPrepAndExpectedTestCaseRowCountCheckIT covering a leaked row in an unlisted table, a reference table wrongly listed for cleanup, and the exclude list silencing either. + + + Wire the row count check into DbUnitExtension: beforeTestExecution() captures the baseline before onSetup(), afterTestExecution() verifies it after onTearDown() unless the test method itself threw. Each capture/verify uses its own connection, acquired and closed independently of whatever connection onSetup()/onTearDown() use internally, and tolerates a null IDatabaseTester.getConnection() (e.g. a test double) by simply never activating for it. + + 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: + *

+ * 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; + } +}