MultiDataSourcePrepAndExpectedTestCase.java
/*
*
* 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 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.Set;
import org.dbunit.util.fileloader.DataFileLoader;
/**
* Preps and verifies tables across multiple {@link org.dbunit.database.IDatabaseConnection}s
* around one run of the code under test, by fanning the {@link PrepAndExpectedTestCase} lifecycle
* out to one delegate per data source.
* <p>
* Holds an ordered {@code {dataSourceName -> PrepAndExpectedTestCase}} map, one delegate per data
* source - normally a {@link DefaultPrepAndExpectedTestCase} bound to its own
* {@link IDatabaseTester}. {@link #preTest(Map)} sets each involved delegate up in the map's
* declared order; {@link #postTest(boolean)} verifies and cleans each up in reverse order,
* collecting every delegate's failure into one {@link MultiDataSourceAssertionError} instead of
* stopping at the first; {@link #runTest(Map, PrepAndExpectedTestCaseSteps)} runs the test steps
* exactly once in between. This class does not implement {@link PrepAndExpectedTestCase} itself -
* that interface's singular accessors and lifecycle methods have no honest answer for N data
* sources - and it drives each delegate only through that interface's own public
* {@code preTest}/{@code postTest} methods, never a reimplemented verify or cleanup.
* <p>
* The data source name to {@link PrepAndExpectedTestCase} wiring is fixed for this instance's
* life: assemble it with the constructors, {@link #from(String, PrepAndExpectedTestCase)}, the
* {@code forTesters(...)} factories, and {@link #add(String, PrepAndExpectedTestCase)} /
* {@link #add(String, IDatabaseTester)} / {@link #addAll(Map)}, all of which return this instance
* so they chain; the wiring is then frozen on the first {@link #preTest(Map)} /
* {@link #runTest(Map, PrepAndExpectedTestCaseSteps)} call, and a further {@code add} throws
* {@link IllegalStateException}. The {@code {dataSourceName -> PrepAndExpectedTestData}} map each
* test run passes to {@link #preTest(Map)} / {@link #runTest(Map, PrepAndExpectedTestCaseSteps)}
* varies per call; a wired data source absent from that map, or mapped to the exact
* {@link PrepAndExpectedTestData#NONE} instance, sits that run out entirely - its connection is
* never opened.
* <p>
* This instance is not thread-safe, but is safe to reuse sequentially - across
* {@code @ParameterizedTest} rows, or as a longer-lived singleton reused across test classes -
* since each {@link #preTest(Map)} / {@link #runTest(Map, PrepAndExpectedTestCaseSteps)} /
* {@link #postTest(boolean)} call, including its own teardown, completes before the next begins.
*
* @author Jeff Jensen
* @since 3.6.0
*/
public class MultiDataSourcePrepAndExpectedTestCase
{
private final Map<String, PrepAndExpectedTestCase> testCasesByDataSourceName =
new LinkedHashMap<>();
private final DataFileLoader dataFileLoader;
private final boolean closeConnectionAfterTest;
private final boolean testerConstructionAllowed;
private boolean frozen;
private Set<String> involvedDataSourceNames = Collections.emptySet();
/**
* Creates an empty instance for the delegate currency: assemble it with
* {@link #add(String, PrepAndExpectedTestCase)} / {@link #addAll(Map)}.
*/
public MultiDataSourcePrepAndExpectedTestCase()
{
this(null, true, false);
}
/**
* Creates an instance seeded with the given delegates, in the given map's iteration order.
*
* @param testCasesByDataSourceName
* The delegates to seed this instance with, keyed by data source name; not
* {@code null}, and not containing a {@code null} or blank key or a {@code null}
* value. Defensively copied; mutating this map afterward does not affect this
* instance.
* @throws IllegalArgumentException
* If testCasesByDataSourceName is {@code null}, or contains a {@code null} or
* blank key, or a {@code null} value.
*/
public MultiDataSourcePrepAndExpectedTestCase(
final Map<String, ? extends PrepAndExpectedTestCase> testCasesByDataSourceName)
{
this();
addAll(testCasesByDataSourceName);
}
private MultiDataSourcePrepAndExpectedTestCase(final DataFileLoader dataFileLoader,
final boolean closeConnectionAfterTest, final boolean testerConstructionAllowed)
{
this.dataFileLoader = dataFileLoader;
this.closeConnectionAfterTest = closeConnectionAfterTest;
this.testerConstructionAllowed = testerConstructionAllowed;
}
/**
* Creates an instance seeded with one data source, for the delegate currency.
*
* @param dataSourceName
* The data source name; not {@code null} or blank.
* @param testCase
* The delegate for that data source; not {@code null}.
* @return The new instance, so further {@code add}/{@code addAll} calls can chain off it.
* @throws IllegalArgumentException
* If dataSourceName is {@code null} or blank, or if testCase is {@code null}.
*/
public static MultiDataSourcePrepAndExpectedTestCase from(final String dataSourceName,
final PrepAndExpectedTestCase testCase)
{
return new MultiDataSourcePrepAndExpectedTestCase().add(dataSourceName, testCase);
}
/**
* Creates an empty instance for the tester currency - {@link #add(String, IDatabaseTester)}
* wraps each tester in a {@link DefaultPrepAndExpectedTestCase} sharing the given loader, with
* {@code closeConnectionAfterTest} true.
*
* @param dataFileLoader
* The loader every {@link #add(String, IDatabaseTester)}-wrapped delegate shares.
* @return The new instance.
*/
public static MultiDataSourcePrepAndExpectedTestCase forTesters(
final DataFileLoader dataFileLoader)
{
return forTesters(dataFileLoader, true);
}
/**
* Creates an empty instance for the tester currency, as {@link #forTesters(DataFileLoader)}
* does, with the given {@code closeConnectionAfterTest} applied to every
* {@link #add(String, IDatabaseTester)}-wrapped delegate.
*
* @param dataFileLoader
* The loader every {@link #add(String, IDatabaseTester)}-wrapped delegate shares.
* @param closeConnectionAfterTest
* Whether each {@link #add(String, IDatabaseTester)}-wrapped delegate closes its
* connection after each test; false to reuse each delegate's connection across
* {@code @ParameterizedTest} rows, as for a lone {@link DefaultPrepAndExpectedTestCase}.
* @return The new instance.
*/
public static MultiDataSourcePrepAndExpectedTestCase forTesters(
final DataFileLoader dataFileLoader, final boolean closeConnectionAfterTest)
{
return new MultiDataSourcePrepAndExpectedTestCase(dataFileLoader,
closeConnectionAfterTest, true);
}
/**
* Creates an instance for the tester currency, seeded with the given testers - as
* {@link #forTesters(DataFileLoader)} does, then {@link #add(String, IDatabaseTester)} for
* each entry, in the given map's iteration order.
*
* @param dataFileLoader
* The loader every wrapped delegate shares.
* @param testersByDataSourceName
* The testers to seed this instance with, keyed by data source name; not
* {@code null}.
* @return The new instance.
* @throws IllegalArgumentException
* If testersByDataSourceName is {@code null}, or contains a {@code null} or
* blank key, or a {@code null} value.
*/
public static MultiDataSourcePrepAndExpectedTestCase forTesters(
final DataFileLoader dataFileLoader,
final Map<String, ? extends IDatabaseTester> testersByDataSourceName)
{
return forTesters(dataFileLoader, testersByDataSourceName, true);
}
/**
* Creates an instance for the tester currency, seeded with the given testers, as
* {@link #forTesters(DataFileLoader, Map)} does, with the given
* {@code closeConnectionAfterTest} applied to every wrapped delegate.
*
* @param dataFileLoader
* The loader every wrapped delegate shares.
* @param testersByDataSourceName
* The testers to seed this instance with, keyed by data source name; not
* {@code null}.
* @param closeConnectionAfterTest
* Whether each wrapped delegate closes its connection after each test.
* @return The new instance.
* @throws IllegalArgumentException
* If testersByDataSourceName is {@code null}, or contains a {@code null} or
* blank key, or a {@code null} value.
*/
public static MultiDataSourcePrepAndExpectedTestCase forTesters(
final DataFileLoader dataFileLoader,
final Map<String, ? extends IDatabaseTester> testersByDataSourceName,
final boolean closeConnectionAfterTest)
{
if (testersByDataSourceName == null)
{
throw new IllegalArgumentException("testersByDataSourceName must not be null.");
}
final MultiDataSourcePrepAndExpectedTestCase instance =
forTesters(dataFileLoader, closeConnectionAfterTest);
for (final Map.Entry<String, ? extends IDatabaseTester> entry : testersByDataSourceName
.entrySet())
{
instance.add(entry.getKey(), entry.getValue());
}
return instance;
}
/**
* Wires one data source to the given delegate.
*
* @param dataSourceName
* The data source name; not {@code null} or blank, and not already wired.
* @param testCase
* The delegate for that data source; not {@code null}.
* @return This instance, so calls can chain.
* @throws IllegalArgumentException
* If dataSourceName is {@code null}, blank, or already wired, or if testCase is
* {@code null}.
* @throws IllegalStateException
* If the wiring is already frozen by an earlier {@link #preTest(Map)} /
* {@link #runTest(Map, PrepAndExpectedTestCaseSteps)} call.
*/
public MultiDataSourcePrepAndExpectedTestCase add(final String dataSourceName,
final PrepAndExpectedTestCase testCase)
{
checkNotFrozen();
if (dataSourceName == null || dataSourceName.trim().isEmpty())
{
throw new IllegalArgumentException("dataSourceName must not be null or blank.");
}
if (testCase == null)
{
throw new IllegalArgumentException("testCase must not be null.");
}
if (testCasesByDataSourceName.containsKey(dataSourceName))
{
throw new IllegalArgumentException(
"A data source named '" + dataSourceName + "' is already wired.");
}
testCasesByDataSourceName.put(dataSourceName, testCase);
return this;
}
/**
* Wires one data source to a new {@link DefaultPrepAndExpectedTestCase} bound to the given
* tester, sharing this instance's loader and {@code closeConnectionAfterTest}.
*
* @param dataSourceName
* The data source name; not {@code null} or blank, and not already wired.
* @param tester
* The tester for that data source; not {@code null}.
* @return This instance, so calls can chain.
* @throws IllegalArgumentException
* If dataSourceName is {@code null}, blank, or already wired, or if tester is
* {@code null}.
* @throws IllegalStateException
* If this instance was not created via one of the {@code forTesters(...)}
* factories, or if the wiring is already frozen.
*/
public MultiDataSourcePrepAndExpectedTestCase add(final String dataSourceName,
final IDatabaseTester tester)
{
checkNotFrozen();
if (!testerConstructionAllowed)
{
throw new IllegalStateException("add(String, IDatabaseTester) requires an instance"
+ " created via one of the forTesters(...) factories.");
}
if (tester == null)
{
throw new IllegalArgumentException("tester must not be null.");
}
return add(dataSourceName,
new DefaultPrepAndExpectedTestCase(dataFileLoader, tester,
closeConnectionAfterTest));
}
/**
* Wires every entry of the given map, in its iteration order.
*
* @param testCasesByDataSourceName
* The delegates to add, keyed by data source name; not {@code null}.
* @return This instance, so calls can chain.
* @throws IllegalArgumentException
* If testCasesByDataSourceName is {@code null}, or contains a {@code null} or
* blank key, an already-wired key, or a {@code null} value.
* @throws IllegalStateException
* If the wiring is already frozen by an earlier {@link #preTest(Map)} /
* {@link #runTest(Map, PrepAndExpectedTestCaseSteps)} call.
*/
public MultiDataSourcePrepAndExpectedTestCase addAll(
final Map<String, ? extends PrepAndExpectedTestCase> testCasesByDataSourceName)
{
checkNotFrozen();
if (testCasesByDataSourceName == null)
{
throw new IllegalArgumentException("testCasesByDataSourceName must not be null.");
}
for (final Map.Entry<String, ? extends PrepAndExpectedTestCase> entry
: testCasesByDataSourceName.entrySet())
{
add(entry.getKey(), entry.getValue());
}
return this;
}
/**
* Runs the full lifecycle once: {@link #preTest(Map)}, then the given test steps exactly
* once, then {@link #postTest(boolean) postTest(true)}.
* <p>
* A throwable from the test steps is rethrown as-is - not wrapped - with every involved
* delegate torn down first via {@link PrepAndExpectedTestCase#postTest(boolean)
* postTest(false)} in reverse declared order, each teardown failure attached to the step
* throwable via {@link Throwable#addSuppressed(Throwable)} - except a teardown failure that
* is the exact same instance as the step throwable itself, which is dropped rather than
* attempting the self-suppression {@link Throwable#addSuppressed(Throwable)} forbids.
*
* @param dataByDataSourceName
* The prep/expected/verify data for this run, keyed by data source name; a wired
* data source absent from this map, or mapped to {@link PrepAndExpectedTestData#NONE},
* sits this run out entirely.
* @param testSteps
* The test steps to run, exactly once.
* @return The user-defined object the test steps returned.
* @throws Exception
* If {@link #preTest(Map)} fails, if the test steps fail, or if
* {@link #postTest(boolean)} fails.
*/
public Object runTest(final Map<String, PrepAndExpectedTestData> dataByDataSourceName,
final PrepAndExpectedTestCaseSteps testSteps) throws Exception
{
preTest(dataByDataSourceName);
final Object result;
try
{
result = testSteps.run();
} catch (final Throwable stepFailure)
{
tearDownSuppressing(reverseInvolvedOrder(), stepFailure);
throw stepFailure;
}
postTest(true);
return result;
}
/**
* Sets up every involved data source's prep data, in declared order.
* <p>
* A wired data source absent from {@code dataByDataSourceName}, or mapped to the exact
* {@link PrepAndExpectedTestData#NONE} instance, is skipped entirely this run - its
* {@code preTest} is never called and its connection is never opened. An unknown key in
* {@code dataByDataSourceName} - one not wired to this instance - is rejected before any
* delegate is touched.
* <p>
* If an involved delegate's own {@code preTest} fails, every involved delegate set up so far -
* including the one that just failed - is rolled back, via
* {@link PrepAndExpectedTestCase#postTest(boolean) postTest(false)}, in reverse order, never a
* bare {@code cleanupData()} - with each rollback failure attached to the original failure via
* {@link Throwable#addSuppressed(Throwable)}, except a rollback failure that is the exact same
* instance as the original failure, which is dropped rather than attempting the
* self-suppression {@link Throwable#addSuppressed(Throwable)} forbids; the original failure is
* then rethrown as-is.
*
* @param dataByDataSourceName
* The prep/expected/verify data for this run, keyed by data source name; not
* {@code null}, and containing no key this instance has no delegate wired for.
* @throws IllegalStateException
* If no data source is wired yet.
* @throws IllegalArgumentException
* If dataByDataSourceName is {@code null}, contains an unknown data source name,
* or maps a wired data source name to {@code null}.
* @throws Exception
* If an involved delegate's own {@code preTest} fails.
*/
public void preTest(final Map<String, PrepAndExpectedTestData> dataByDataSourceName)
throws Exception
{
if (testCasesByDataSourceName.isEmpty())
{
throw new IllegalStateException(
"No data source is wired; call add(...) before preTest(...).");
}
if (dataByDataSourceName == null)
{
throw new IllegalArgumentException("dataByDataSourceName must not be null.");
}
final Set<String> unknownDataSourceNames = new LinkedHashSet<>(
dataByDataSourceName.keySet());
unknownDataSourceNames.removeAll(testCasesByDataSourceName.keySet());
if (!unknownDataSourceNames.isEmpty())
{
throw new IllegalArgumentException("Unknown data source name(s) "
+ unknownDataSourceNames + "; wired data source(s) are "
+ testCasesByDataSourceName.keySet() + ".");
}
final Set<String> involved = new LinkedHashSet<>();
for (final String dataSourceName : testCasesByDataSourceName.keySet())
{
final boolean hasEntry = dataByDataSourceName.containsKey(dataSourceName);
final PrepAndExpectedTestData data = dataByDataSourceName.get(dataSourceName);
if (hasEntry && data == null)
{
throw new IllegalArgumentException("dataByDataSourceName must not map \""
+ dataSourceName + "\" to null; omit the key or map it to"
+ " PrepAndExpectedTestData.NONE to skip that data source.");
}
if (data != null && data != PrepAndExpectedTestData.NONE)
{
involved.add(dataSourceName);
}
}
frozen = true;
involvedDataSourceNames = Collections.emptySet();
final List<String> setUpSoFar = new ArrayList<>();
for (final String dataSourceName : involved)
{
final PrepAndExpectedTestData data = dataByDataSourceName.get(dataSourceName);
final PrepAndExpectedTestCase testCase = testCasesByDataSourceName.get(dataSourceName);
try
{
testCase.preTest(data.getVerifyTableDefinitions(), data.getPrepDataFiles(),
data.getExpectedDataFiles());
} catch (final Throwable setupFailure)
{
setUpSoFar.add(dataSourceName);
rollBackSuppressing(setUpSoFar, setupFailure);
involvedDataSourceNames = Collections.emptySet();
throw setupFailure;
}
setUpSoFar.add(dataSourceName);
}
involvedDataSourceNames = involved;
}
private void rollBackSuppressing(final List<String> setUpSoFar, final Throwable primaryFailure)
{
final List<String> reverseOrder = new ArrayList<>(setUpSoFar);
Collections.reverse(reverseOrder);
tearDownSuppressing(reverseOrder, primaryFailure);
}
private void tearDownSuppressing(final List<String> dataSourceNamesInTeardownOrder,
final Throwable primaryFailure)
{
for (final String dataSourceName : dataSourceNamesInTeardownOrder)
{
try
{
testCasesByDataSourceName.get(dataSourceName).postTest(false);
} catch (final Throwable teardownFailure)
{
// Throwable.addSuppressed() throws IllegalArgumentException for
// self-suppression; skip it rather than let that mask the real failure.
if (teardownFailure != primaryFailure)
{
primaryFailure.addSuppressed(teardownFailure);
}
}
}
}
/**
* Verifies and cleans up every involved data source, in reverse declared order.
* <p>
* Every involved delegate's own {@link PrepAndExpectedTestCase#postTest(boolean)} runs -
* verify (or, when {@code verifyData} is false, its own row count baseline discard) and
* cleanup - even if an earlier one in this reverse order failed; every collected failure is
* then aggregated into one {@link MultiDataSourceAssertionError} rather than the first one
* stopping the rest from being torn down.
* <p>
* The run is over once this is called, whether it returns or throws: a further call without
* a new {@code preTest} in between, such as one in a caller's {@code finally} block after a
* rejected {@code preTest}, has nothing to tear down.
*
* @param verifyData
* True to verify each involved delegate's data before cleaning it up; false to
* skip verification (the test steps already failed) and just clean up.
* @throws MultiDataSourceAssertionError
* If one or more involved delegates failed their own {@code postTest}.
*/
public void postTest(final boolean verifyData) throws Exception
{
final List<String> teardownOrder = reverseInvolvedOrder();
involvedDataSourceNames = Collections.emptySet();
final Map<String, Throwable> failuresByDataSourceName = new LinkedHashMap<>();
for (final String dataSourceName : teardownOrder)
{
try
{
testCasesByDataSourceName.get(dataSourceName).postTest(verifyData);
} catch (final Throwable failure)
{
failuresByDataSourceName.put(dataSourceName, failure);
}
}
if (failuresByDataSourceName.isEmpty())
{
return;
}
final List<MultiDataSourceAssertionError.NamedFailure> namedFailures = new ArrayList<>();
for (final String dataSourceName : testCasesByDataSourceName.keySet())
{
final Throwable failure = failuresByDataSourceName.get(dataSourceName);
if (failure != null)
{
namedFailures.add(new MultiDataSourceAssertionError.NamedFailure(dataSourceName,
phaseOf(verifyData, failure), failure));
}
}
throw MultiDataSourceAssertionError.aggregate(namedFailures);
}
// Only an AssertionError reliably means the verify step failed - a checked exception (e.g. a
// DataSetException from a malformed dataset) could equally have come from cleanupData(), so
// it is labelled generically rather than guessed wrong.
private static String phaseOf(final boolean verifyData, final Throwable failure)
{
if (!verifyData)
{
return "cleanupData";
}
return failure instanceof AssertionError ? "verifyData" : "postTest";
}
private List<String> reverseInvolvedOrder()
{
final List<String> reverseOrder = new ArrayList<>(involvedDataSourceNames);
Collections.reverse(reverseOrder);
return reverseOrder;
}
private void checkNotFrozen()
{
if (frozen)
{
throw new IllegalStateException(
"The data source wiring is frozen after the first preTest(...)/runTest(...)"
+ " call; assemble it fully before running a test.");
}
}
/**
* Returns the wired data source names, in declared order.
*
* @return An unmodifiable view of the wired data source names, in declared order.
*/
public Set<String> getDataSourceNames()
{
return Collections.unmodifiableSet(testCasesByDataSourceName.keySet());
}
/**
* Returns the delegate wired to one data source, for a test that needs to reach one directly -
* e.g. to assert something extra on it, or to inspect it after a failure.
*
* @param dataSourceName
* The data source name.
* @return The delegate wired to that data source.
* @throws IllegalArgumentException
* If no data source with that name is wired.
*/
public PrepAndExpectedTestCase getTestCase(final String dataSourceName)
{
final PrepAndExpectedTestCase testCase = testCasesByDataSourceName.get(dataSourceName);
if (testCase == null)
{
throw new IllegalArgumentException(
"No data source named '" + dataSourceName + "' is wired.");
}
return testCase;
}
}