From cfc6c79fc3448579f49c64e88f804e66c0eef320 Mon Sep 17 00:00:00 2001 From: asmhatre Date: Wed, 30 Sep 2026 19:02:06 +0000 Subject: [PATCH] Add lifecycle module: @BeforeAll/@AfterAll demos on JUnit 6.1.3 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01KqJyCidz3ZgRyHABv2GVJh --- lifecycle/.gitignore | 1 + lifecycle/README.md | 50 ++++ lifecycle/output/01-static-default.txt | 8 + lifecycle/output/02-per-class.txt | 8 + lifecycle/output/03-non-static-rejected.txt | 6 + lifecycle/output/04-beforeall-fails.txt | 4 + lifecycle/output/05-afterall-unguarded.txt | 4 + lifecycle/output/06-afterall-throws.txt | 6 + lifecycle/output/07-inheritance.txt | 8 + lifecycle/output/08-hiding.txt | 6 + lifecycle/output/09-two-beforeall.txt | 7 + lifecycle/output/10-nested.txt | 11 + lifecycle/output/11-shared-tempdir.txt | 8 + lifecycle/output/12-extension-store.txt | 9 + .../output/13-default-per-class-config.txt | 5 + lifecycle/pom.xml | 53 ++++ .../junit/lifecycle/BehaviourTest.java | 99 ++++++++ .../tutorials/junit/lifecycle/Events.java | 31 +++ .../tutorials/junit/lifecycle/Fixtures.java | 229 ++++++++++++++++++ .../tutorials/junit/lifecycle/RunHelper.java | 56 +++++ .../lifecycle/SharedServerExtension.java | 27 +++ .../junit/lifecycle/StaticLifecycleTest.java | 53 ++++ 22 files changed, 689 insertions(+) create mode 100644 lifecycle/.gitignore create mode 100644 lifecycle/README.md create mode 100644 lifecycle/output/01-static-default.txt create mode 100644 lifecycle/output/02-per-class.txt create mode 100644 lifecycle/output/03-non-static-rejected.txt create mode 100644 lifecycle/output/04-beforeall-fails.txt create mode 100644 lifecycle/output/05-afterall-unguarded.txt create mode 100644 lifecycle/output/06-afterall-throws.txt create mode 100644 lifecycle/output/07-inheritance.txt create mode 100644 lifecycle/output/08-hiding.txt create mode 100644 lifecycle/output/09-two-beforeall.txt create mode 100644 lifecycle/output/10-nested.txt create mode 100644 lifecycle/output/11-shared-tempdir.txt create mode 100644 lifecycle/output/12-extension-store.txt create mode 100644 lifecycle/output/13-default-per-class-config.txt create mode 100644 lifecycle/pom.xml create mode 100644 lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/BehaviourTest.java create mode 100644 lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/Events.java create mode 100644 lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/Fixtures.java create mode 100644 lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/RunHelper.java create mode 100644 lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/SharedServerExtension.java create mode 100644 lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/StaticLifecycleTest.java diff --git a/lifecycle/.gitignore b/lifecycle/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/lifecycle/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/lifecycle/README.md b/lifecycle/README.md new file mode 100644 index 0000000..90aca5c --- /dev/null +++ b/lifecycle/README.md @@ -0,0 +1,50 @@ +# JUnit @BeforeAll / @AfterAll Demonstration + +Runnable companion to the ankurm.com article "JUnit @BeforeAll and @AfterAll: One-Time Setup and Teardown". + +Every scenario is a small fixture class in `Fixtures.java`. The real tests execute each fixture through the +JUnit Launcher, assert on what happened, and write the event log to `output/`, so each transcript quoted in +the article is also an assertion that fails the build when it stops being true. + +## Versions + +| Item | Version | +|------|---------| +| JUnit (default, `junit.version`) | 6.1.3 (Jupiter, Platform, Launcher) | +| Also run green on | 5.14.4 (`mvn test -Djunit.version=5.14.4`; see note on `SharedServerExtension`) | +| Java | 17 language level (`maven.compiler.release`), run on JDK 25.0.4.1 | +| Maven Surefire | 3.5.4 | + +## Scenarios + +| Fixture | What it shows | Output | +|---------|---------------|--------| +| `StaticHooks` | default lifecycle: static hooks, a new instance per test | `01-static-default.txt` | +| `PerClassHooks` | `@TestInstance(PER_CLASS)`: instance hooks, one shared instance | `02-per-class.txt` | +| `NonStaticWithoutPerClass` | the classic mistake; discovery fails | `03-non-static-rejected.txt` | +| `BeforeAllFails` | failing `@BeforeAll` skips tests, `@AfterAll` still runs | `04-beforeall-fails.txt` | +| `BeforeAllFailsUnguarded` | unguarded `@AfterAll` NPE is attached as suppressed | `05-afterall-unguarded.txt` | +| `AfterAllThrows` | one failing `@AfterAll` does not stop the next | `06-afterall-throws.txt` | +| `Sub` / `Base` | inheritance order | `07-inheritance.txt` | +| `HidingSub` / `HidingBase` | a hidden static `@BeforeAll` still runs | `08-hiding.txt` | +| `TwoBeforeAllInOneClass` | order among several hooks in one class | `09-two-beforeall.txt` | +| `OuterWithNested` | static `@BeforeAll` inside `@Nested` | `10-nested.txt` | +| `SharedTempDir` | static `@TempDir` shared by the class | `11-shared-tempdir.txt` | +| `UsesServerA` / `UsesServerB` | root-store `AutoCloseable` owned by an extension | `12-extension-store.txt` | +| `NonStaticWithoutPerClass` + config | `junit.jupiter.testinstance.lifecycle.default=per_class` | `13-default-per-class-config.txt` | + +Note: `SharedServerExtension` uses `Store.computeIfAbsent(Class)`, which exists from 6.0. On 5.14.x use +`getOrComputeIfAbsent(Class)` (deprecated since 6.0); with that one-word change the whole suite passes on 5.14.4. + +## Prerequisites + +- JDK 17 or higher +- Maven 3.6+ + +## How to Run + +```bash +mvn test +``` + +The transcripts in `output/` are regenerated by that command. Instance hash codes in `01` and `02` change per run. diff --git a/lifecycle/output/01-static-default.txt b/lifecycle/output/01-static-default.txt new file mode 100644 index 0000000..6fe35a2 --- /dev/null +++ b/lifecycle/output/01-static-default.txt @@ -0,0 +1,8 @@ +@BeforeAll static (no instance yet) +a() instance=342198178 +b() instance=1988644427 +@AfterAll static +-- results -- +SUCCESSFUL a() +SUCCESSFUL b() +SUCCESSFUL Fixtures$StaticHooks diff --git a/lifecycle/output/02-per-class.txt b/lifecycle/output/02-per-class.txt new file mode 100644 index 0000000..15a9e23 --- /dev/null +++ b/lifecycle/output/02-per-class.txt @@ -0,0 +1,8 @@ +@BeforeAll instance, counter=100 +a() instance=18179709 counter=101 +b() instance=18179709 counter=102 +@AfterAll instance, counter=102 +-- results -- +SUCCESSFUL a() +SUCCESSFUL b() +SUCCESSFUL Fixtures$PerClassHooks diff --git a/lifecycle/output/03-non-static-rejected.txt b/lifecycle/output/03-non-static-rejected.txt new file mode 100644 index 0000000..c0e5459 --- /dev/null +++ b/lifecycle/output/03-non-static-rejected.txt @@ -0,0 +1,6 @@ +-- results -- +FAILED DiscoveryIssueException: TestEngine with ID 'junit-jupiter' encountered a critical issue during test discovery: + +(1) [ERROR] @BeforeAll method 'void com.ankurm.tutorials.junit.lifecycle.Fixtures$NonStaticWithoutPerClass.setupAll()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS). + Source: MethodSource [className = 'com.ankurm.tutorials.junit.lifecycle.Fixtures$NonStaticWithoutPerClass', methodName = 'setupAll', methodParameterTypes = ''] + at com.ankurm.tutorials.junit.lifecycle.Fixtures$NonStaticWithoutPerClass.setupAll(SourceFile:0) diff --git a/lifecycle/output/04-beforeall-fails.txt b/lifecycle/output/04-beforeall-fails.txt new file mode 100644 index 0000000..5e5a910 --- /dev/null +++ b/lifecycle/output/04-beforeall-fails.txt @@ -0,0 +1,4 @@ +@BeforeAll start: about to fail +@AfterAll stop: resource is null +-- results -- +FAILED Fixtures$BeforeAllFails IllegalStateException: container did not start diff --git a/lifecycle/output/05-afterall-unguarded.txt b/lifecycle/output/05-afterall-unguarded.txt new file mode 100644 index 0000000..a7c5978 --- /dev/null +++ b/lifecycle/output/05-afterall-unguarded.txt @@ -0,0 +1,4 @@ +@AfterAll stop (unguarded) +-- results -- +FAILED Fixtures$BeforeAllFailsUnguarded IllegalStateException: container did not start + suppressed NullPointerException: Cannot invoke "java.lang.AutoCloseable.close()" because "com.ankurm.tutorials.junit.lifecycle.Fixtures$BeforeAllFailsUnguarded.resource" is null diff --git a/lifecycle/output/06-afterall-throws.txt b/lifecycle/output/06-afterall-throws.txt new file mode 100644 index 0000000..9a90302 --- /dev/null +++ b/lifecycle/output/06-afterall-throws.txt @@ -0,0 +1,6 @@ +a() ran +@AfterAll closeDatabase +@AfterAll deleteFiles +-- results -- +SUCCESSFUL a() +FAILED Fixtures$AfterAllThrows IllegalStateException: db close failed diff --git a/lifecycle/output/07-inheritance.txt b/lifecycle/output/07-inheritance.txt new file mode 100644 index 0000000..abcec89 --- /dev/null +++ b/lifecycle/output/07-inheritance.txt @@ -0,0 +1,8 @@ +Base @BeforeAll +Sub @BeforeAll +Sub a() +Sub @AfterAll +Base @AfterAll +-- results -- +SUCCESSFUL a() +SUCCESSFUL Fixtures$Sub diff --git a/lifecycle/output/08-hiding.txt b/lifecycle/output/08-hiding.txt new file mode 100644 index 0000000..a8f4ea0 --- /dev/null +++ b/lifecycle/output/08-hiding.txt @@ -0,0 +1,6 @@ +HidingBase.init +HidingSub.init +HidingSub a() +-- results -- +SUCCESSFUL a() +SUCCESSFUL Fixtures$HidingSub diff --git a/lifecycle/output/09-two-beforeall.txt b/lifecycle/output/09-two-beforeall.txt new file mode 100644 index 0000000..3651797 --- /dev/null +++ b/lifecycle/output/09-two-beforeall.txt @@ -0,0 +1,7 @@ +second +first +third +a() +-- results -- +SUCCESSFUL a() +SUCCESSFUL Fixtures$TwoBeforeAllInOneClass diff --git a/lifecycle/output/10-nested.txt b/lifecycle/output/10-nested.txt new file mode 100644 index 0000000..6f67a1e --- /dev/null +++ b/lifecycle/output/10-nested.txt @@ -0,0 +1,11 @@ +Outer @BeforeAll +outer test +Inner static @BeforeAll +inner test +Inner static @AfterAll +Outer @AfterAll +-- results -- +SUCCESSFUL outerTest() +SUCCESSFUL innerTest() +SUCCESSFUL Inner +SUCCESSFUL Fixtures$OuterWithNested diff --git a/lifecycle/output/11-shared-tempdir.txt b/lifecycle/output/11-shared-tempdir.txt new file mode 100644 index 0000000..0d91db6 --- /dev/null +++ b/lifecycle/output/11-shared-tempdir.txt @@ -0,0 +1,8 @@ +@BeforeAll seeded junit- +a() reads: seeded once +b() reads: seeded once +@AfterAll dir still exists: true +-- results -- +SUCCESSFUL a() +SUCCESSFUL b() +SUCCESSFUL Fixtures$SharedTempDir diff --git a/lifecycle/output/12-extension-store.txt b/lifecycle/output/12-extension-store.txt new file mode 100644 index 0000000..a528f0f --- /dev/null +++ b/lifecycle/output/12-extension-store.txt @@ -0,0 +1,9 @@ +server STARTED +UsesServerA test +UsesServerB test +server CLOSED +-- results -- +SUCCESSFUL a() +SUCCESSFUL Fixtures$UsesServerA +SUCCESSFUL b() +SUCCESSFUL Fixtures$UsesServerB diff --git a/lifecycle/output/13-default-per-class-config.txt b/lifecycle/output/13-default-per-class-config.txt new file mode 100644 index 0000000..e5f327b --- /dev/null +++ b/lifecycle/output/13-default-per-class-config.txt @@ -0,0 +1,5 @@ +setupAll ran +test ran +-- results -- +SUCCESSFUL a() +SUCCESSFUL Fixtures$NonStaticWithoutPerClass diff --git a/lifecycle/pom.xml b/lifecycle/pom.xml new file mode 100644 index 0000000..4ef66bb --- /dev/null +++ b/lifecycle/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + com.ankurTutorials + lifecycle-demo + 1.0-SNAPSHOT + + + 17 + UTF-8 + + 6.1.3 + + + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + + + + org.junit.jupiter + junit-jupiter + test + + + + org.junit.platform + junit-platform-launcher + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + + diff --git a/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/BehaviourTest.java b/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/BehaviourTest.java new file mode 100644 index 0000000..be56fda --- /dev/null +++ b/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/BehaviourTest.java @@ -0,0 +1,99 @@ +package com.ankurm.tutorials.junit.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Failure modes, inheritance, nesting and @TempDir: each test runs a fixture and pins what happened. */ +class BehaviourTest { + + @Test + void beforeAllFailureSkipsTestsButAfterAllStillRuns() { + List log = RunHelper.run(Fixtures.BeforeAllFails.class); + Events.write("04-beforeall-fails.txt", log); + assertTrue(log.contains("@BeforeAll start: about to fail")); + assertFalse(log.contains("a() ran")); + assertFalse(log.contains("b() ran")); + assertTrue(log.contains("@AfterAll stop: resource is null")); + assertTrue(log.stream().anyMatch(s -> s.startsWith("FAILED") && s.contains("container did not start"))); + } + + @Test + void unguardedAfterAllAddsASecondFailure() { + List log = RunHelper.run(Fixtures.BeforeAllFailsUnguarded.class); + Events.write("05-afterall-unguarded.txt", log); + assertTrue(log.contains("@AfterAll stop (unguarded)")); + // the AfterAll NullPointerException does not replace the real failure; it is attached to it as suppressed + assertTrue(log.stream().anyMatch(s -> s.contains("IllegalStateException: container did not start"))); + assertTrue(log.stream().anyMatch(s -> s.contains("suppressed NullPointerException"))); + } + + @Test + void failingAfterAllDoesNotStopTheOtherAfterAll() { + List log = RunHelper.run(Fixtures.AfterAllThrows.class); + Events.write("06-afterall-throws.txt", log); + assertTrue(log.contains("@AfterAll closeDatabase")); + assertTrue(log.contains("@AfterAll deleteFiles")); + assertTrue(log.stream().anyMatch(s -> s.startsWith("FAILED") && s.contains("db close failed"))); + } + + @Test + void superclassBeforeAllFirstAndAfterAllLast() { + List log = RunHelper.run(Fixtures.Sub.class); + Events.write("07-inheritance.txt", log); + assertEquals(List.of("Base @BeforeAll", "Sub @BeforeAll", "Sub a()", "Sub @AfterAll", "Base @AfterAll"), + log.subList(0, 5)); + } + + @Test + void staticBeforeAllHiddenBySubclassStillRunsInTheSuperclass() { + List log = RunHelper.run(Fixtures.HidingSub.class); + Events.write("08-hiding.txt", log); + // unlike an overridden instance method, a hidden static @BeforeAll is NOT replaced: both run, super first + assertEquals(List.of("HidingBase.init", "HidingSub.init", "HidingSub a()"), log.subList(0, 3)); + } + + @Test + void multipleBeforeAllMethodsInOneClassRunInAnOrderYouShouldNotRelyOn() { + List log = RunHelper.run(Fixtures.TwoBeforeAllInOneClass.class); + Events.write("09-two-beforeall.txt", log); + // all three run, then the test; the order of the three is deterministic but deliberately non-obvious + assertEquals(3, log.indexOf("a()")); + assertEquals(List.of("second", "first", "third"), log.subList(0, 3)); // observed, not promised + assertEquals(java.util.Set.of("first", "second", "third"), new java.util.HashSet<>(log.subList(0, 3))); + } + + @Test + void staticBeforeAllInNestedClassWorksOnJava17() { + List log = RunHelper.run(Fixtures.OuterWithNested.class); + Events.write("10-nested.txt", log); + assertEquals(List.of("Outer @BeforeAll", "Inner static @BeforeAll", "inner test", "Inner static @AfterAll"), + log.stream().filter(s -> !s.equals("outer test")).limit(4).toList()); + assertTrue(log.indexOf("Outer @BeforeAll") < log.indexOf("Inner static @BeforeAll")); + assertTrue(log.indexOf("Inner static @AfterAll") < log.indexOf("Outer @AfterAll")); + } + + @Test + void staticTempDirIsSharedAndDeletedAfterAfterAll() { + List log = RunHelper.run(Fixtures.SharedTempDir.class); + Events.write("11-shared-tempdir.txt", log); + assertTrue(log.contains("a() reads: seeded once")); + assertTrue(log.contains("b() reads: seeded once")); + assertTrue(log.contains("@AfterAll dir still exists: true")); + assertFalse(Files.exists(Fixtures.capturedTempDir), "deleted once the class is finished"); + } + + @Test + void rootStoreResourceIsStartedOnceAndClosedByJUnit() { + List log = RunHelper.run(Fixtures.UsesServerA.class, Fixtures.UsesServerB.class); + Events.write("12-extension-store.txt", log); + assertEquals(1, log.stream().filter(s -> s.equals("server STARTED")).count()); + assertEquals(1, log.stream().filter(s -> s.equals("server CLOSED")).count()); + assertTrue(log.indexOf("server STARTED") < log.indexOf("UsesServerA test")); + assertTrue(log.indexOf("server CLOSED") > log.indexOf("UsesServerB test")); + } +} diff --git a/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/Events.java b/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/Events.java new file mode 100644 index 0000000..b82b796 --- /dev/null +++ b/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/Events.java @@ -0,0 +1,31 @@ +package com.ankurm.tutorials.junit.lifecycle; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** Ordered event log shared by the fixtures; the tests assert on it and write it to output/. */ +public final class Events { + private static final List LOG = new ArrayList<>(); + + private Events() {} + + public static synchronized void add(String s) { LOG.add(s); } + + public static synchronized void clear() { LOG.clear(); } + + public static synchronized List snapshot() { return new ArrayList<>(LOG); } + + /** Writes the lines to output/<name> relative to the module directory. */ + public static void write(String name, List lines) { + try { + Path dir = Path.of("output"); + Files.createDirectories(dir); + Files.write(dir.resolve(name), lines); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/Fixtures.java b/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/Fixtures.java new file mode 100644 index 0000000..21452ec --- /dev/null +++ b/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/Fixtures.java @@ -0,0 +1,229 @@ +package com.ankurm.tutorials.junit.lifecycle; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.io.IOException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.io.TempDir; + +/** + * Fixture classes. They are not named *Test, so Surefire never runs them on its own; + * the real tests execute them through the Launcher (see RunHelper) and assert on the outcome. + */ +final class Fixtures { + private Fixtures() {} + + // ---- 1. the default lifecycle: static hooks, one instance per test method -------------- + + static class StaticHooks { + @BeforeAll + static void setupAll() { Events.add("@BeforeAll static (no instance yet)"); } + + @Test + void a() { Events.add("a() instance=" + System.identityHashCode(this)); } + + @Test + void b() { Events.add("b() instance=" + System.identityHashCode(this)); } + + @AfterAll + static void teardownAll() { Events.add("@AfterAll static"); } + } + + // ---- 2. PER_CLASS: instance hooks, one shared instance ----------------------------- + + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + static class PerClassHooks { + private int sharedCounter; + + @BeforeAll + void setupAll() { sharedCounter = 100; Events.add("@BeforeAll instance, counter=" + sharedCounter); } + + @Test + void a() { sharedCounter++; Events.add("a() instance=" + System.identityHashCode(this) + " counter=" + sharedCounter); } + + @Test + void b() { sharedCounter++; Events.add("b() instance=" + System.identityHashCode(this) + " counter=" + sharedCounter); } + + @AfterAll + void teardownAll() { Events.add("@AfterAll instance, counter=" + sharedCounter); } + } + + // ---- 3. the mistake: non-static @BeforeAll without PER_CLASS ------------------------ + + static class NonStaticWithoutPerClass { + @BeforeAll + void setupAll() { Events.add("setupAll ran"); } + + @Test + void a() { Events.add("test ran"); } + } + + // ---- 4. @BeforeAll throws: tests are skipped, @AfterAll still runs ------------------ + + static class BeforeAllFails { + private static AutoCloseable resource; + + @BeforeAll + static void start() throws Exception { + Events.add("@BeforeAll start: about to fail"); + throw new IllegalStateException("container did not start"); + } + + @Test + void a() { Events.add("a() ran"); } + + @Test + void b() { Events.add("b() ran"); } + + @AfterAll + static void stop() throws Exception { + Events.add("@AfterAll stop: resource is " + (resource == null ? "null" : "set")); + if (resource != null) resource.close(); // the null check is what keeps this from masking the real failure + } + } + + static class BeforeAllFailsUnguarded { + private static AutoCloseable resource; + + @BeforeAll + static void start() { throw new IllegalStateException("container did not start"); } + + @Test + void a() { Events.add("a() ran"); } + + @AfterAll + static void stop() throws Exception { + Events.add("@AfterAll stop (unguarded)"); + resource.close(); + } + } + + // ---- 5. two @AfterAll methods, the first one throws --------------------------------- + + static class AfterAllThrows { + @Test + void a() { Events.add("a() ran"); } + + @AfterAll + static void closeDatabase() { Events.add("@AfterAll closeDatabase"); throw new IllegalStateException("db close failed"); } + + @AfterAll + static void deleteFiles() { Events.add("@AfterAll deleteFiles"); } + } + + // ---- 6. inheritance ------------------------------------------------------------------ + + static class Base { + @BeforeAll + static void baseBefore() { Events.add("Base @BeforeAll"); } + + @AfterAll + static void baseAfter() { Events.add("Base @AfterAll"); } + } + + static class Sub extends Base { + @BeforeAll + static void subBefore() { Events.add("Sub @BeforeAll"); } + + @Test + void a() { Events.add("Sub a()"); } + + @AfterAll + static void subAfter() { Events.add("Sub @AfterAll"); } + } + + static class HidingBase { + @BeforeAll + static void init() { Events.add("HidingBase.init"); } + } + + static class HidingSub extends HidingBase { + @BeforeAll + static void init() { Events.add("HidingSub.init"); } + + @Test + void a() { Events.add("HidingSub a()"); } + } + + static class TwoBeforeAllInOneClass { + @BeforeAll + static void first() { Events.add("first"); } + + @BeforeAll + static void second() { Events.add("second"); } + + @BeforeAll + static void third() { Events.add("third"); } + + @Test + void a() { Events.add("a()"); } + } + + // ---- 7. nested classes --------------------------------------------------------------- + + static class OuterWithNested { + @BeforeAll + static void outerBefore() { Events.add("Outer @BeforeAll"); } + + @AfterAll + static void outerAfter() { Events.add("Outer @AfterAll"); } + + @Test + void outerTest() { Events.add("outer test"); } + + @Nested + class Inner { + @BeforeAll + static void innerBefore() { Events.add("Inner static @BeforeAll"); } + + @AfterAll + static void innerAfter() { Events.add("Inner static @AfterAll"); } + + @Test + void innerTest() { Events.add("inner test"); } + } + } + + // ---- 8. shared temp directory -------------------------------------------------------- + + static Path capturedTempDir; + + static class SharedTempDir { + @TempDir + static Path shared; + + @BeforeAll + static void seed() throws IOException { + capturedTempDir = shared; + Files.writeString(shared.resolve("seed.txt"), "seeded once"); + Events.add("@BeforeAll seeded " + shared.getFileName().toString().replaceAll("\\d{6,}", "")); + } + + @Test + void a() throws IOException { Events.add("a() reads: " + Files.readString(shared.resolve("seed.txt"))); } + + @Test + void b() throws IOException { Events.add("b() reads: " + Files.readString(shared.resolve("seed.txt"))); } + + @AfterAll + static void check() { Events.add("@AfterAll dir still exists: " + Files.exists(shared)); } + } + + // ---- 9. shared resource owned by an extension, closed by JUnit ------------------------- + + @org.junit.jupiter.api.extension.ExtendWith(SharedServerExtension.class) + static class UsesServerA { + @Test + void a() { Events.add("UsesServerA test"); } + } + + @org.junit.jupiter.api.extension.ExtendWith(SharedServerExtension.class) + static class UsesServerB { + @Test + void b() { Events.add("UsesServerB test"); } + } +} diff --git a/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/RunHelper.java b/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/RunHelper.java new file mode 100644 index 0000000..e17532f --- /dev/null +++ b/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/RunHelper.java @@ -0,0 +1,56 @@ +package com.ankurm.tutorials.junit.lifecycle; + +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; + +import java.util.ArrayList; +import java.util.List; +import org.junit.platform.engine.TestExecutionResult; +import org.junit.platform.launcher.LauncherDiscoveryRequest; +import org.junit.platform.launcher.TestExecutionListener; +import org.junit.platform.launcher.TestIdentifier; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.core.LauncherFactory; + +/** Runs fixture classes through the real Launcher and returns the events plus per-node results. */ +final class RunHelper { + private RunHelper() {} + + private static String describe(Throwable t) { + StringBuilder sb = new StringBuilder(" " + t.getClass().getSimpleName() + ": " + t.getMessage()); + for (Throwable s : t.getSuppressed()) + sb.append("\n suppressed ").append(s.getClass().getSimpleName()).append(": ").append(s.getMessage()); + return sb.toString(); + } + + static List run(Class... classes) { + return run(java.util.Map.of(), classes); + } + + static List run(java.util.Map config, Class... classes) { + Events.clear(); + var b = LauncherDiscoveryRequestBuilder.request(); + for (Class c : classes) b.selectors(selectClass(c)); + config.forEach(b::configurationParameter); + LauncherDiscoveryRequest request = b.build(); + List results = new ArrayList<>(); + TestExecutionListener l = new TestExecutionListener() { + @Override + public void executionSkipped(TestIdentifier id, String reason) { + results.add("SKIPPED " + id.getDisplayName()); + } + + @Override + public void executionFinished(TestIdentifier id, TestExecutionResult r) { + boolean root = id.getParentId().isEmpty(); + if (root && r.getStatus() == TestExecutionResult.Status.SUCCESSFUL) return; // engine root, nothing to say + String what = r.getThrowable().map(RunHelper::describe).orElse(""); + results.add(String.format("%-10s %s%s", r.getStatus(), root ? "" : id.getDisplayName(), what)); + } + }; + LauncherFactory.create().execute(request, l); + List all = new ArrayList<>(Events.snapshot()); + all.add("-- results --"); + all.addAll(results); + return all; + } +} diff --git a/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/SharedServerExtension.java b/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/SharedServerExtension.java new file mode 100644 index 0000000..8e8b497 --- /dev/null +++ b/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/SharedServerExtension.java @@ -0,0 +1,27 @@ +package com.ankurm.tutorials.junit.lifecycle; + +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; + +/** + * Starts an expensive shared resource once per test run, however many classes use it, and lets + * JUnit close it. Because the value lives in the ROOT store and implements AutoCloseable, + * no @AfterAll is needed anywhere. + */ +public class SharedServerExtension implements BeforeAllCallback { + private static final Namespace NS = Namespace.create(SharedServerExtension.class); + + /** Stand-in for a container, embedded database or mock HTTP server. */ + public static class FakeServer implements AutoCloseable { + FakeServer() { Events.add("server STARTED"); } + + @Override + public void close() { Events.add("server CLOSED"); } + } + + @Override + public void beforeAll(ExtensionContext context) { + context.getRoot().getStore(NS).computeIfAbsent(FakeServer.class); + } +} diff --git a/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/StaticLifecycleTest.java b/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/StaticLifecycleTest.java new file mode 100644 index 0000000..205a2cf --- /dev/null +++ b/lifecycle/src/test/java/com/ankurm/tutorials/junit/lifecycle/StaticLifecycleTest.java @@ -0,0 +1,53 @@ +package com.ankurm.tutorials.junit.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Default lifecycle: static @BeforeAll/@AfterAll, one new instance per test method. */ +class StaticLifecycleTest { + + @Test + void staticHooksRunOnceAndInstancesDiffer() { + List log = RunHelper.run(Fixtures.StaticHooks.class); + Events.write("01-static-default.txt", log); + assertEquals(1, log.stream().filter(s -> s.startsWith("@BeforeAll")).count()); + assertEquals(1, log.stream().filter(s -> s.startsWith("@AfterAll")).count()); + assertEquals("@BeforeAll static (no instance yet)", log.get(0)); + String a = log.stream().filter(s -> s.startsWith("a() instance=")).findFirst().orElseThrow(); + String b = log.stream().filter(s -> s.startsWith("b() instance=")).findFirst().orElseThrow(); + assertNotEquals(a.substring(a.indexOf('=')), b.substring(b.indexOf('='))); + } + + @Test + void perClassAllowsInstanceHooksAndSharesOneInstance() { + List log = RunHelper.run(Fixtures.PerClassHooks.class); + Events.write("02-per-class.txt", log); + String a = log.stream().filter(s -> s.startsWith("a() instance=")).findFirst().orElseThrow(); + String b = log.stream().filter(s -> s.startsWith("b() instance=")).findFirst().orElseThrow(); + assertEquals(a.substring(a.indexOf('='), a.indexOf(" counter")), b.substring(b.indexOf('='), b.indexOf(" counter"))); + assertEquals(1, log.stream().filter(s -> s.startsWith("@BeforeAll")).count()); + assertTrue(log.contains("a() instance=" + a.substring(a.indexOf('=') + 1, a.indexOf(" counter")) + " counter=101")); + assertTrue(log.contains("@AfterAll instance, counter=102")); + } + + @Test + void nonStaticBeforeAllWithoutPerClassIsRejected() { + List log = RunHelper.run(Fixtures.NonStaticWithoutPerClass.class); + Events.write("03-non-static-rejected.txt", log); + assertEquals(0, log.stream().filter(s -> s.startsWith("test ran")).count()); + assertTrue(log.stream().anyMatch(s -> s.contains("must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS)"))); + } + + @Test + void globalDefaultPerClassMakesNonStaticHooksLegal() { + List log = RunHelper.run(Map.of("junit.jupiter.testinstance.lifecycle.default", "per_class"), + Fixtures.NonStaticWithoutPerClass.class); + Events.write("13-default-per-class-config.txt", log); + assertEquals(List.of("setupAll ran", "test ran"), log.subList(0, 2)); + } +}