MultiDataSourcePrepAndExpectedTestCase
Overview
A test that exercises code spanning two or more databases - an "orders" database and an
"inventory" database, say - has no way to prep and verify tables in each database around one
run of the code under test using a single
PrepAndExpectedTestCase. Hand-driving a second
IDatabaseTester and reimplementing the setup/verify/cleanup/connection bookkeeping is
error-prone: on a failed setup, the earlier data source’s rollback must call postTest(false),
not a bare cleanupData(), or its own row count check fires against a half-set-up, unknown-state
database and buries the real cause.
MultiDataSourcePrepAndExpectedTestCase
wraps an ordered {dataSourceName → PrepAndExpectedTestCase} map of delegates - normally one
DefaultPrepAndExpectedTestCase per
data source - and runs the same prep → steps → verify → cleanup lifecycle, fanning setup and
teardown out to every delegate through each delegate’s own preTest/postTest, while running the
test steps exactly once. It does not implement PrepAndExpectedTestCase itself: that
interface’s singular accessors and lifecycle methods have no honest answer for N data sources. It
is a peer type with a parallel, map-keyed API, plus a getTestCase(name) escape hatch to one
delegate.
Wiring versus data
Two things vary on two different schedules:
- Wiring (fixed for the instance’s life)
- the
{name → PrepAndExpectedTestCase}(or{name → IDatabaseTester}) map, assembled with the constructors,from(String, PrepAndExpectedTestCase), theforTesters(…)factories, andadd/addAll- each returning the instance so calls chain - then frozen on the firstpreTest/runTestcall. - Data (varies per test method, per
@ParameterizedTestrow) - the
{name → PrepAndExpectedTestData}map passed topreTest(map)/runTest(map, steps). A wired data source can sit a given run out entirely by being absent from this map, or mapped to the exactPrepAndExpectedTestData.NONEinstance - its connection is then never opened.
Before - a hand-driven second tester
private DefaultPrepAndExpectedTestCase orders;
private DefaultPrepAndExpectedTestCase inventory;
@Test
void testTransfer_success_movesStockAndRecordsOrder() throws Exception
{
orders.configureTest(ORDERS_VERIFY, ORDERS_PREP, ORDERS_EXPECTED);
inventory.configureTest(INV_VERIFY, INV_PREP, INV_EXPECTED);
orders.preTest();
inventory.preTest();
try
{
service.transfer(orderId); // writes to both databases
orders.verifyData(); // first mismatch hides the second
inventory.verifyData();
}
finally
{
// reverse order, both, suppressed exceptions - and postTest(false), not
// cleanupData(), on a failed run
inventory.cleanupData();
orders.cleanupData();
}
}After - the wrapper
private MultiDataSourcePrepAndExpectedTestCase testCase;
@BeforeEach
void setUp()
{
final Map<String, IDatabaseTester> testers = new LinkedHashMap<>();
testers.put("orders", makeTester(ordersDataSource));
testers.put("inventory", makeTester(inventoryDataSource));
testCase = MultiDataSourcePrepAndExpectedTestCase.forTesters(loader, testers);
}
@Test
void testTransfer_success_movesStockAndRecordsOrder() throws Exception
{
final Map<String, PrepAndExpectedTestData> data = new LinkedHashMap<>();
data.put("orders", new PrepAndExpectedTestData(ORDERS_VERIFY, ORDERS_PREP, ORDERS_EXPECTED));
data.put("inventory", new PrepAndExpectedTestData(INV_VERIFY, INV_PREP, INV_EXPECTED));
testCase.runTest(data, () ->
{
service.transfer(orderId);
return null;
});
}Setup order, reverse teardown, verify-every-source-then-report, postTest(false) (not a bare
cleanup) on a failed run, and suppressed-exception handling are all the wrapper’s job.
Orchestration rules
| Phase | Order | Behavior |
|---|---|---|
preTest(map) |
declared | An unknown key, or a key mapped to null, throws before any delegate is touched. For each
involved data source (present in the map and not mapped to NONE), in declared order:
delegate.preTest(…). If one throws, every involved delegate set up so far - including the one
that just failed - is rolled back via postTest(false) in reverse - never a bare cleanupData()
- with each rollback failure attached as suppressed on the original failure, which is then
rethrown as-is. |
| test steps | once | Run by runTest, or by the caller between preTest(map) and postTest(boolean). A delegate’s
own overridden runTestSteps() is not invoked - the steps span every data source, so no single
delegate’s step wrapper is the right scope. |
postTest(verify) |
reverse | delegate.postTest(verify) for each involved delegate, in reverse declared order, even after an
earlier one in that order fails - so every data source is verified and cleaned up, and every
mismatch reported, instead of stopping at the first. Every collected failure is aggregated into
one MultiDataSourceAssertionError. |
MultiDataSourceAssertionError extends AssertionError (not a checked exception, and not
org.opentest4j.MultipleFailuresError, keeping this core class JUnit-independent): the first
failure becomes its cause, so a single failing data source still surfaces its real
DbComparisonFailure one
Caused by: hop down, and the rest are attached as suppressed, each labelled with its data source
name and phase, e.g. "2 data sources failed in postTest: orders (verifyData), inventory
(cleanupData)".
A @ParameterizedTest suite
Condensed from PrepAndExpectedTestCase’s
Bundling Prep, Expected, and Verify Into One
Argument section: one PrepAndExpectedTestData per data source per scenario, collected into one
Map<String, PrepAndExpectedTestData> per @MethodSource row.
@ParameterizedTest(name = "[{index}] {0}")
@MethodSource("testData")
void test(String testName, Map<String, PrepAndExpectedTestData> rowData, long customerId,
String sku, int quantity) throws Exception
{
testCase.runTest(rowData, () -> {
orderService.placeOrder(customerId, sku, quantity);
return null;
});
}
private static Map<String, PrepAndExpectedTestData> data(PrepAndExpectedTestData catalog,
PrepAndExpectedTestData orders, PrepAndExpectedTestData inventory)
{
Map<String, PrepAndExpectedTestData> map = new LinkedHashMap<>();
map.put("catalog", catalog);
map.put("orders", orders);
map.put("inventory", inventory);
return map;
}
private static Object[][] testData()
{
return new Object[][] {
{"in stock", data(CATALOG_SOLD, ORDER_INSERTED, STOCK_DRAWN_DOWN), 42L, "WIDGET-1", 3},
};
}A row may omit a data source, or pass PrepAndExpectedTestData.NONE for it, to leave it out of
that run entirely.
Replacing a hand-rolled multi-tester harness
A suite that already hand-rolls this pattern - two or more @Qualifier-injected
PrepAndExpectedTestCase delegates wired into a component, a per-scenario record shaped like
PrepAndExpectedTestData, and hand-coded pre/run/post fan-out with its own aggregate exception -
can delegate the dbUnit fan-out to this wrapper directly:
public class PrimarySecondaryDbTester
{
private final MultiDataSourcePrepAndExpectedTestCase testCase;
public PrimarySecondaryDbTester(
@Qualifier("primaryTestCase") PrepAndExpectedTestCase primaryTestCase,
@Qualifier("secondaryTestCase") PrepAndExpectedTestCase secondaryTestCase)
{
testCase = MultiDataSourcePrepAndExpectedTestCase
.from("primary", primaryTestCase)
.add("secondary", secondaryTestCase);
}
public Object runTest(PrepAndExpectedTestData primary, PrepAndExpectedTestData secondary,
PrepAndExpectedTestCaseSteps testSteps) throws Exception
{
final Map<String, PrepAndExpectedTestData> data = new LinkedHashMap<>();
data.put("primary", primary);
data.put("secondary", secondary);
return testCase.runTest(data, testSteps);
}
}The harness’s own case list, per-case fan-out methods, and aggregate exception type are all
deleted; MultiDataSourcePrepAndExpectedTestCase owns that logic now, and the aggregate becomes
unchecked, so a throws Throwable a caller declared for the old checked aggregate keeps compiling
and can be narrowed as a later cleanup. Two things to check before switching: a scenario that
intentionally preps/verifies nothing for one side but still wants that delegate’s connection
opened must build its own all-empty PrepAndExpectedTestData rather than pass NONE - the skip
check is == against that exact instance, not content equality; and teardown order flips from
whatever the harness used to reverse declared order, harmless when, as is typical, the data
sources are independent.
This wrapper’s API is deliberately the programmatic equivalent only. A named-tester /
named-connection annotation model for the @DbUnitPrep/@DbUnitExpected annotation style
described in PrepAndExpectedTestCase’s
Annotation-Driven Equivalent
section, extended to multiple data sources, is tracked separately as
issue #969. Until that lands, a
@ParameterizedTest using the programmatic API above is the way to cover multiple data sources in
one test.


