From 13371a3b8be5a5b5b99f7fbcd24336e0a011dc97 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Sat, 30 May 2026 12:34:26 -0500 Subject: [PATCH] feat(junit5): Add DbUnitExtension for JUnit 5/6 lifecycle management Add DbUnitExtension, a JUnit 5/6 extension that wires IDatabaseTester into the test lifecycle via BeforeTestExecutionCallback and AfterTestExecutionCallback. The extension discovers the IDatabaseTester by scanning instance fields (including inherited), so test classes configure their tester in @BeforeEach and annotate with @ExtendWith(DbUnitExtension.class) rather than extending DatabaseTestCase. Package the extension as org.dbunit.junit.jupiter, mirroring JUnit's own org.junit.jupiter namespace, rather than org.dbunit.junit5: JUnit 6 kept the Jupiter programming model and its org.junit.jupiter.* API package unchanged from JUnit 5 (dbUnit itself now builds and tests against JUnit 6), so "jupiter" stays accurate across major versions where a version-numbered package name would not. Reject a test class that declares more than one non-static IDatabaseTester-assignable field at the same class level instead of silently using Class#getDeclaredFields()'s undefined iteration order, and cover the case with a test. Add an EngineTestKit lifecycle test verifying onTearDown() still runs (and the test method itself never executes) when onSetup() throws. Document the @Nested limitation and the field-ambiguity rejection in the class Javadoc. Add the testcases/DbUnitExtension.adoc site page, cross-linked from testcases.adoc and testcases/IDatabaseTester.adoc, and list the new org.dbunit.junit.jupiter package in CLAUDE.md's package structure. Clarify the IDatabaseTester.adoc TIP on DbUnitExtension: it only automates the onSetup()/onTearDown() calls, not tester configuration, so tell readers to keep @BeforeEach when it still configures the tester (e.g. setDataSet()/setTearDownOperation()) instead of implying the whole method can be deleted. Co-Authored-By: Claude Sonnet 4.6 Co-Authored-By: Claude Sonnet 5 Refs: 751 --- CLAUDE.md | 1 + pom.xml | 10 + src/changes/changes.xml | 5 +- .../dbunit/junit/jupiter/DbUnitExtension.java | 154 ++++++++++ src/site/asciidoc/testcases.adoc | 4 + .../asciidoc/testcases/DbUnitExtension.adoc | 67 ++++ .../asciidoc/testcases/IDatabaseTester.adoc | 5 + src/site/site.xml | 1 + .../jupiter/DbUnitExtensionLifecycleTest.java | 224 ++++++++++++++ .../junit/jupiter/DbUnitExtensionTest.java | 286 ++++++++++++++++++ 10 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/dbunit/junit/jupiter/DbUnitExtension.java create mode 100644 src/site/asciidoc/testcases/DbUnitExtension.adoc create mode 100644 src/test/java/org/dbunit/junit/jupiter/DbUnitExtensionLifecycleTest.java create mode 100644 src/test/java/org/dbunit/junit/jupiter/DbUnitExtensionTest.java diff --git a/CLAUDE.md b/CLAUDE.md index a1fc957b3..0233120e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,7 @@ org.dbunit ├── assertion/ DbUnitAssert, ValueComparer implementations ├── ext/ Database-specific overrides (db2/, h2/, hsqldb/, mssql/, mysql/, oracle/, postgresql/) ├── ant/ Apache Ant task support +├── junit/jupiter/ DbUnitExtension: JUnit 5/6 (Jupiter) lifecycle integration └── util/ SQLHelper, QualifiedTableName, RelativeDateTimeParser, etc. ``` diff --git a/pom.xml b/pom.xml index 47f76d669..074fff6c6 100644 --- a/pom.xml +++ b/pom.xml @@ -438,6 +438,11 @@ junit-platform-suite-engine ${junitVersion} + + org.junit.platform + junit-platform-testkit + ${junitVersion} + org.assertj assertj-core @@ -512,6 +517,11 @@ org.junit.platform junit-platform-suite-engine + + org.junit.platform + junit-platform-testkit + test + org.assertj assertj-core diff --git a/src/changes/changes.xml b/src/changes/changes.xml index 6702aacf1..d5d6cce2c 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -13,7 +13,7 @@ - + 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. @@ -233,6 +233,9 @@ Add DefaultPrepAndExpectedTestCase.setFailureHandler(FailureHandler), letting verifyData() use a caller-supplied FailureHandler such as DiffCollectingFailureHandler instead of the default fail-fast DefaultFailureHandler; unset (null) keeps the pre-existing default behavior unchanged. + + Add DbUnitExtension, a JUnit 5/6 extension that manages IDatabaseTester lifecycle via BeforeTestExecutionCallback and AfterTestExecutionCallback, enabling composition-based DbUnit tests without subclassing DatabaseTestCase. + diff --git a/src/main/java/org/dbunit/junit/jupiter/DbUnitExtension.java b/src/main/java/org/dbunit/junit/jupiter/DbUnitExtension.java new file mode 100644 index 000000000..d8190302e --- /dev/null +++ b/src/main/java/org/dbunit/junit/jupiter/DbUnitExtension.java @@ -0,0 +1,154 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2025, 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 java.lang.reflect.Field; +import java.lang.reflect.Modifier; + +import org.dbunit.IDatabaseTester; +import org.junit.jupiter.api.extension.AfterTestExecutionCallback; +import org.junit.jupiter.api.extension.BeforeTestExecutionCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * JUnit 5/6 extension for DbUnit that manages the {@link IDatabaseTester} lifecycle + * around each test method. + * + *

Calls {@link IDatabaseTester#onSetup()} immediately before the test method + * (after all {@code @BeforeEach} callbacks) and {@link IDatabaseTester#onTearDown()} + * immediately after the test method (before any {@code @AfterEach} callbacks). + * + *

The extension discovers the {@link IDatabaseTester} by scanning the test + * instance's fields, including inherited fields, for a non-static field assignable + * to {@link IDatabaseTester}: the nearest declaring class (test class before + * superclass) wins, but that class must declare exactly one such field—two or + * more at the same level is rejected as ambiguous. Configure the tester—including + * its dataset—in a {@code @BeforeEach} method; those run before this extension's + * setup callback: + * + *

{@code
+ * @ExtendWith(DbUnitExtension.class)
+ * class MyDatabaseTest {
+ *     IDatabaseTester databaseTester = new JdbcDatabaseTester("driver", "url", "user", "pass");
+ *
+ *     @BeforeEach
+ *     void loadDataset() throws Exception {
+ *         databaseTester.setDataSet(new FlatXmlDataSetBuilder().build(...));
+ *     }
+ *
+ *     @Test
+ *     void testSomething() { ... }
+ * }
+ * }
+ * + *

Note: {@code @Nested} test classes are not supported. Field + * discovery only scans the innermost test instance and its superclasses—not + * enclosing class instances—since a Java nested class does not extend its + * enclosing class. + * + * @author dbunit + * @since 3.4.1 + */ +public class DbUnitExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback { + private static final Logger log = LoggerFactory.getLogger(DbUnitExtension.class); + + private static final ExtensionContext.Namespace NAMESPACE = + ExtensionContext.Namespace.create(DbUnitExtension.class); + + private static final String TESTER_KEY = "databaseTester"; + + /** + * 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. + */ + @Override + public void beforeTestExecution(final ExtensionContext context) throws Exception { + final IDatabaseTester tester = resolveTester(context); + context.getStore(NAMESPACE).put(TESTER_KEY, tester); + tester.onSetup(); + } + + /** + * Runs database teardown after the test method executes. + * + * @param context The extension context for the test method. + * @throws Exception If the stored {@link IDatabaseTester}'s onTearDown() call fails. + */ + @Override + public void afterTestExecution(final ExtensionContext context) throws Exception { + final IDatabaseTester tester = + context.getStore(NAMESPACE).get(TESTER_KEY, IDatabaseTester.class); + if (tester != null) { + tester.onTearDown(); + } + } + + private IDatabaseTester resolveTester(final ExtensionContext context) throws Exception { + final Object testInstance = context.getTestInstance() + .orElseThrow(() -> new IllegalStateException( + "No test instance available in ExtensionContext.")); + + Class clazz = testInstance.getClass(); + while (clazz != null && clazz != Object.class) { + final Field field = findTesterField(clazz, testInstance); + if (field != null) { + field.setAccessible(true); + final IDatabaseTester tester = (IDatabaseTester) field.get(testInstance); + if (tester == null) { + throw new IllegalStateException("IDatabaseTester field '" + + field.getName() + "' in " + + testInstance.getClass().getName() + " is null."); + } + log.debug("Resolved IDatabaseTester '{}' in {}", + field.getName(), testInstance.getClass().getName()); + return tester; + } + clazz = clazz.getSuperclass(); + } + + throw new IllegalStateException("No IDatabaseTester field found in " + + testInstance.getClass().getName() + + " or its superclasses. Declare a non-static field whose type implements IDatabaseTester" + + " to use DbUnitExtension."); + } + + private Field findTesterField(final Class clazz, final Object testInstance) { + Field match = null; + for (final Field field : clazz.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers()) + && IDatabaseTester.class.isAssignableFrom(field.getType())) { + if (match != null) { + throw new IllegalStateException("Multiple IDatabaseTester fields found in " + + clazz.getName() + ": '" + match.getName() + "' and '" + + field.getName() + "'. Declare exactly one non-static field" + + " whose type implements IDatabaseTester in " + + testInstance.getClass().getName() + "."); + } + match = field; + } + } + return match; + } +} diff --git a/src/site/asciidoc/testcases.adoc b/src/site/asciidoc/testcases.adoc index 5717c5863..dc89e15f8 100644 --- a/src/site/asciidoc/testcases.adoc +++ b/src/site/asciidoc/testcases.adoc @@ -22,6 +22,10 @@ that predate this recommendation; see link:testcases/MigratingToIDatabaseTester.html[Migrating to IDatabaseTester] if you want to convert an existing `DBTestCase` subclass. +On JUnit 5/6 (Jupiter), composition users can also skip writing the +`@BeforeEach`/`@AfterEach` lifecycle calls entirely — see +link:testcases/DbUnitExtension.html[DbUnitExtension]. + Whichever style you choose, link:testcases/PrepAndExpectedTestCase.html[PrepAndExpectedTestCase] is usually a better starting point than driving either one directly — it diff --git a/src/site/asciidoc/testcases/DbUnitExtension.adoc b/src/site/asciidoc/testcases/DbUnitExtension.adoc new file mode 100644 index 000000000..28ce4ebc2 --- /dev/null +++ b/src/site/asciidoc/testcases/DbUnitExtension.adoc @@ -0,0 +1,67 @@ += DbUnitExtension +Jeff Jensen + +== Overview + +link:/dbunit/apidocs/org/dbunit/junit/jupiter/DbUnitExtension.html[DbUnitExtension] +is a JUnit 5/6 (Jupiter) extension that drives the +link:IDatabaseTester.html[IDatabaseTester] setup/teardown lifecycle +automatically, so a test class doesn't need its own `@BeforeEach`/`@AfterEach` +pair calling `onSetup()`/`onTearDown()`. + +Register it with `@ExtendWith(DbUnitExtension.class)`. The test class still +holds its own `IDatabaseTester` field — this is the same composition style as +link:IDatabaseTester.html[the IDatabaseTester guide], just with the lifecycle +calls automated instead of written by hand: + +[source,java] +---- +@ExtendWith(DbUnitExtension.class) +class AccountRepositoryTest +{ + IDatabaseTester databaseTester = + new JdbcDatabaseTester("org.h2.Driver", "jdbc:h2:mem:example;DB_CLOSE_DELAY=-1"); + + @BeforeEach + void loadDataset() throws Exception + { + databaseTester.setDataSet(new FlatXmlDataSetBuilder().build(new File("prep.xml"))); + databaseTester.setTearDownOperation(DatabaseOperation.DELETE_ALL); + } + + @Test + void testWithdraw_sufficientBalance_decrementsBalance() { ... } +} +---- + +`@BeforeEach` methods still run first — configure the dataset and any +operation overrides there. The extension then calls `onSetup()` immediately +before the test method and `onTearDown()` immediately after it, even if the +test method fails or `onSetup()` itself throws. + +== Field Discovery + +The extension finds the `IDatabaseTester` by scanning the test instance's +fields, including inherited ones: + +* The nearest declaring class wins — a field on the test class itself takes + precedence over one on a superclass. +* That class must declare exactly one non-static field assignable to + `IDatabaseTester`. No match anywhere in the hierarchy, or two-or-more + matches at the same class level, both fail fast with a descriptive + `IllegalStateException` rather than guessing. +* Static fields are ignored. +* Private fields are found; the field's own access modifier doesn't matter. + +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. + +== When to Use This Instead of Manual Lifecycle Calls + +Reach for `DbUnitExtension` when a `@BeforeEach`/`@AfterEach` pair that only +calls `onSetup()`/`onTearDown()` (as shown in link:IDatabaseTester.html[the +IDatabaseTester guide]) would just be boilerplate repeated across every test +class. Write the `@BeforeEach`/`@AfterEach` pair yourself instead when a test +needs other logic around those calls, or targets a JUnit version this +extension doesn't support. diff --git a/src/site/asciidoc/testcases/IDatabaseTester.adoc b/src/site/asciidoc/testcases/IDatabaseTester.adoc index 26559ffaf..559370cd9 100644 --- a/src/site/asciidoc/testcases/IDatabaseTester.adoc +++ b/src/site/asciidoc/testcases/IDatabaseTester.adoc @@ -100,6 +100,11 @@ this lifecycle (plus expected-data verification) behind `runTest()`/ `preTest()`/`postTest()`, and is usually a better starting point than driving `onSetup()`/`onTearDown()` directly — see its page for the full API. +TIP: On JUnit 5/6 (Jupiter), link:DbUnitExtension.html[`DbUnitExtension`] can +call `onSetup()`/`onTearDown()` for you, removing those two calls from the +`@BeforeEach`/`@AfterEach` pair shown above. Keep `@BeforeEach` if it still +configures the tester, e.g. via `setDataSet()`/`setTearDownOperation()`. + [#IOperationListenerHooks] == IOperationListener Hooks diff --git a/src/site/site.xml b/src/site/site.xml index 9a7b7ee56..f157338e0 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -64,6 +64,7 @@ + diff --git a/src/test/java/org/dbunit/junit/jupiter/DbUnitExtensionLifecycleTest.java b/src/test/java/org/dbunit/junit/jupiter/DbUnitExtensionLifecycleTest.java new file mode 100644 index 000000000..d407d96a0 --- /dev/null +++ b/src/test/java/org/dbunit/junit/jupiter/DbUnitExtensionLifecycleTest.java @@ -0,0 +1,224 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2025, 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.fail; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.dbunit.IOperationListener; +import org.dbunit.IDatabaseTester; +import org.dbunit.database.IDatabaseConnection; +import org.dbunit.dataset.IDataSet; +import org.dbunit.operation.DatabaseOperation; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.platform.testkit.engine.EngineTestKit; + +/** + * Lifecycle integration tests for {@link DbUnitExtension} that verify execution + * ordering and teardown behavior under failure using {@link EngineTestKit}. + */ +class DbUnitExtensionLifecycleTest { + + @Test + void testBeforeAndAfterTestExecution_testMethodSucceeds_callbacksRunInOrder() { + EngineTestKit.engine("junit-jupiter") + .selectors(selectClass(LifecycleOrderSample.class)) + .execute() + .testEvents() + .assertStatistics(stats -> stats.started(1).succeeded(1)); + } + + @Test + void testAfterTestExecution_testMethodFails_onTearDownCalled() { + FailingTestSample.CALL_LOG.clear(); + + EngineTestKit.engine("junit-jupiter") + .selectors(selectClass(FailingTestSample.class)) + .execute() + .testEvents() + .assertStatistics(stats -> stats.started(1).failed(1)); + + assertThat(FailingTestSample.CALL_LOG) + .as("onTearDown() must be called even when the test method fails.") + .contains("onTearDown"); + } + + @Test + void testBeforeTestExecution_onSetupFails_onTearDownCalledAndTestMethodSkipped() { + FailingSetupSample.CALL_LOG.clear(); + + EngineTestKit.engine("junit-jupiter") + .selectors(selectClass(FailingSetupSample.class)) + .execute() + .testEvents() + .assertStatistics(stats -> stats.started(1).failed(1)); + + assertThat(FailingSetupSample.CALL_LOG) + .as("The test method must not run and onTearDown() must still be called" + + " when onSetup() throws.") + .containsExactly("onTearDown"); + } + + /** + * Sample test class with a known-good lifecycle that verifies call ordering. + * Assertions inside verify that onSetup runs after @BeforeEach and onTearDown + * runs before @AfterEach; a failure here surfaces as a test failure in + * the outer EngineTestKit assertion. + */ + @ExtendWith(DbUnitExtension.class) + static class LifecycleOrderSample { + private final List callLog = new ArrayList<>(); + IDatabaseTester databaseTester = new CallLoggingTester(callLog); + + @BeforeEach + void beforeEach() { + callLog.add("beforeEach"); + } + + @AfterEach + void afterEach() { + assertThat(callLog) + .as("Execution order must be: beforeEach, onSetup, test, onTearDown.") + .containsExactly("beforeEach", "onSetup", "test", "onTearDown"); + } + + @Test + void testOrderVerification() { + assertThat(callLog) + .as("onSetup() must run after @BeforeEach and before the test method.") + .containsExactly("beforeEach", "onSetup"); + callLog.add("test"); + } + } + + /** + * Sample test class whose single test intentionally fails, used to verify + * that onTearDown is still called when the test method throws. + */ + @ExtendWith(DbUnitExtension.class) + static class FailingTestSample { + static final List CALL_LOG = Collections.synchronizedList(new ArrayList<>()); + IDatabaseTester databaseTester = new CallLoggingTester(CALL_LOG); + + @Test + void testThatFails() { + CALL_LOG.add("test"); + fail("intentional failure to verify onTearDown still runs"); + } + } + + /** + * Sample test class whose {@link IDatabaseTester} fails during {@code onSetup()}, used to + * verify that {@code onTearDown()} still runs and the test method itself never executes. + */ + @ExtendWith(DbUnitExtension.class) + static class FailingSetupSample { + static final List CALL_LOG = Collections.synchronizedList(new ArrayList<>()); + IDatabaseTester databaseTester = new CallLoggingTester(CALL_LOG, true); + + @Test + void testThatNeverRuns() { + CALL_LOG.add("test"); + fail("test method must not run when onSetup() throws"); + } + } + + private static class CallLoggingTester implements IDatabaseTester { + private final List callLog; + private final boolean failOnSetup; + + CallLoggingTester(final List callLog) { + this(callLog, false); + } + + CallLoggingTester(final List callLog, final boolean failOnSetup) { + this.callLog = callLog; + this.failOnSetup = failOnSetup; + } + + @Override + public void onSetup() throws Exception { + if (failOnSetup) { + throw new Exception("intentional setup failure to verify onTearDown still runs"); + } + callLog.add("onSetup"); + } + + @Override + public void onTearDown() throws Exception { + callLog.add("onTearDown"); + } + + @Override + public IDatabaseConnection getConnection() throws Exception { + return null; + } + + @Override + public IDataSet getDataSet() { + return null; + } + + @Override + public void setDataSet(final IDataSet dataSet) { + } + + @Override + public DatabaseOperation getSetUpOperation() { + return null; + } + + @Override + public DatabaseOperation getTearDownOperation() { + return null; + } + + @Override + public void setSetUpOperation(final DatabaseOperation setUpOperation) { + } + + @Override + public void setTearDownOperation(final DatabaseOperation tearDownOperation) { + } + + @Override + public void setOperationListener(final IOperationListener operationListener) { + } + + @Override + @Deprecated + public void closeConnection(final IDatabaseConnection connection) throws Exception { + } + + @Override + @Deprecated + public void setSchema(final String schema) { + } + } +} diff --git a/src/test/java/org/dbunit/junit/jupiter/DbUnitExtensionTest.java b/src/test/java/org/dbunit/junit/jupiter/DbUnitExtensionTest.java new file mode 100644 index 000000000..392c51b1c --- /dev/null +++ b/src/test/java/org/dbunit/junit/jupiter/DbUnitExtensionTest.java @@ -0,0 +1,286 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2025, 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.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.dbunit.IDatabaseTester; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DbUnitExtensionTest { + @Mock + ExtensionContext context; + + @Mock + ExtensionContext.Store store; + + @Mock + IDatabaseTester databaseTester; + + final DbUnitExtension extension = new DbUnitExtension(); + + // ---- beforeTestExecution: field discovery happy paths ---- + + @Test + void testBeforeTestExecution_testerFieldPresent_callsOnSetupAndStoresTester() throws Exception { + final HasTester testInstance = new HasTester(databaseTester); + when(context.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store); + when(context.getTestInstance()).thenReturn(Optional.of(testInstance)); + + extension.beforeTestExecution(context); + + verify(store).put(eq("databaseTester"), eq(databaseTester)); + verify(databaseTester).onSetup(); + } + + @Test + void testBeforeTestExecution_privateField_callsOnSetup() throws Exception { + final HasPrivateTester testInstance = new HasPrivateTester(databaseTester); + when(context.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store); + when(context.getTestInstance()).thenReturn(Optional.of(testInstance)); + + extension.beforeTestExecution(context); + + verify(databaseTester).onSetup(); + } + + @Test + void testBeforeTestExecution_testerInSuperclass_callsOnSetup() throws Exception { + final SubclassOfHasTester testInstance = new SubclassOfHasTester(databaseTester); + when(context.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store); + when(context.getTestInstance()).thenReturn(Optional.of(testInstance)); + + extension.beforeTestExecution(context); + + verify(databaseTester).onSetup(); + } + + @Test + void testBeforeTestExecution_testerInGrandparentClass_callsOnSetup() throws Exception { + final SubSubclassOfHasTester testInstance = new SubSubclassOfHasTester(databaseTester); + when(context.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store); + when(context.getTestInstance()).thenReturn(Optional.of(testInstance)); + + extension.beforeTestExecution(context); + + verify(databaseTester).onSetup(); + } + + @Test + void testBeforeTestExecution_subclassFieldTakesPrecedenceOverSuperclassField_usesSubclassField() + throws Exception { + final IDatabaseTester superTester = mock(IDatabaseTester.class); + final SubclassWithOwnTester testInstance = new SubclassWithOwnTester(databaseTester, superTester); + when(context.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store); + when(context.getTestInstance()).thenReturn(Optional.of(testInstance)); + + extension.beforeTestExecution(context); + + verify(databaseTester).onSetup(); + verify(superTester, never()).onSetup(); + } + + // ---- beforeTestExecution: error cases ---- + + @Test + void testBeforeTestExecution_noTestInstance_throwsIllegalStateException() { + when(context.getTestInstance()).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> extension.beforeTestExecution(context)) + .as("Expected exception when no test instance is available.") + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No test instance available"); + } + + @Test + void testBeforeTestExecution_noTesterField_throwsIllegalStateException() { + when(context.getTestInstance()).thenReturn(Optional.of(new Object())); + + assertThatThrownBy(() -> extension.beforeTestExecution(context)) + .as("Expected exception when no IDatabaseTester field is present.") + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No IDatabaseTester field found"); + } + + @Test + void testBeforeTestExecution_nullTesterField_throwsIllegalStateException() { + final HasTester testInstance = new HasTester(null); + when(context.getTestInstance()).thenReturn(Optional.of(testInstance)); + + assertThatThrownBy(() -> extension.beforeTestExecution(context)) + .as("Expected exception when IDatabaseTester field is null.") + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("is null"); + } + + @Test + void testBeforeTestExecution_staticTesterField_throwsIllegalStateException() { + when(context.getTestInstance()).thenReturn(Optional.of(new HasStaticTesterOnly())); + + assertThatThrownBy(() -> extension.beforeTestExecution(context)) + .as("Static IDatabaseTester fields must be skipped.") + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No IDatabaseTester field found"); + } + + @Test + void testBeforeTestExecution_multipleTesterFieldsInSameClass_throwsIllegalStateException() { + final IDatabaseTester otherTester = mock(IDatabaseTester.class); + final HasTwoTesters testInstance = new HasTwoTesters(databaseTester, otherTester); + when(context.getTestInstance()).thenReturn(Optional.of(testInstance)); + + assertThatThrownBy(() -> extension.beforeTestExecution(context)) + .as("Expected exception when a class declares more than one IDatabaseTester field.") + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Multiple IDatabaseTester fields found"); + } + + @Test + void testBeforeTestExecution_onSetupThrows_propagatesException() throws Exception { + final HasTester testInstance = new HasTester(databaseTester); + when(context.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store); + when(context.getTestInstance()).thenReturn(Optional.of(testInstance)); + final Exception cause = new Exception("database setup failed"); + doThrow(cause).when(databaseTester).onSetup(); + + assertThatThrownBy(() -> extension.beforeTestExecution(context)) + .as("Exception from onSetup() must propagate.") + .isSameAs(cause); + } + + // ---- afterTestExecution ---- + + @Test + void testAfterTestExecution_storedTesterPresent_callsOnTearDown() throws Exception { + when(context.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store); + when(store.get("databaseTester", IDatabaseTester.class)).thenReturn(databaseTester); + + extension.afterTestExecution(context); + + verify(databaseTester).onTearDown(); + } + + @Test + void testAfterTestExecution_noStoredTester_doesNotCallOnTearDown() throws Exception { + when(context.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store); + when(store.get("databaseTester", IDatabaseTester.class)).thenReturn(null); + + extension.afterTestExecution(context); + + verify(databaseTester, never()).onTearDown(); + } + + @Test + void testAfterTestExecution_onTearDownThrows_propagatesException() throws Exception { + when(context.getStore(any(ExtensionContext.Namespace.class))).thenReturn(store); + when(store.get("databaseTester", IDatabaseTester.class)).thenReturn(databaseTester); + final Exception cause = new Exception("database teardown failed"); + doThrow(cause).when(databaseTester).onTearDown(); + + assertThatThrownBy(() -> extension.afterTestExecution(context)) + .as("Exception from onTearDown() must propagate.") + .isSameAs(cause); + } + + // ---- helper inner classes ---- + + static class HasTester { + IDatabaseTester databaseTester; + + HasTester(final IDatabaseTester databaseTester) { + this.databaseTester = databaseTester; + } + } + + static class HasPrivateTester { + private final IDatabaseTester databaseTester; + + HasPrivateTester(final IDatabaseTester databaseTester) { + this.databaseTester = databaseTester; + } + } + + static class HasStaticTesterOnly { + @SuppressWarnings("unused") + static IDatabaseTester staticTester = null; + } + + static class HasTwoTesters { + IDatabaseTester databaseTester; + IDatabaseTester anotherTester; + + HasTwoTesters(final IDatabaseTester databaseTester, final IDatabaseTester anotherTester) { + this.databaseTester = databaseTester; + this.anotherTester = anotherTester; + } + } + + static class ParentHasTester { + IDatabaseTester databaseTester; + + ParentHasTester(final IDatabaseTester databaseTester) { + this.databaseTester = databaseTester; + } + } + + static class SubclassOfHasTester extends ParentHasTester { + SubclassOfHasTester(final IDatabaseTester databaseTester) { + super(databaseTester); + } + } + + static class SubSubclassOfHasTester extends SubclassOfHasTester { + SubSubclassOfHasTester(final IDatabaseTester databaseTester) { + super(databaseTester); + } + } + + static class SuperclassWithTester { + IDatabaseTester databaseTester; + + SuperclassWithTester(final IDatabaseTester databaseTester) { + this.databaseTester = databaseTester; + } + } + + static class SubclassWithOwnTester extends SuperclassWithTester { + IDatabaseTester databaseTester; + + SubclassWithOwnTester(final IDatabaseTester subTester, final IDatabaseTester superTester) { + super(superTester); + this.databaseTester = subTester; + } + } +}