beginner~4h

JUnit 5 Fundamentals

How JUnit 5's three-module architecture (Platform, Jupiter, Vintage) actually discovers and runs your tests, the core lifecycle annotations that control setup and teardown, the assertion library that decides pass or fail, and exactly how many test-class instances get created and when.

Learning objectives

  • Describe the three JUnit 5 subsystems (Platform, Jupiter, Vintage) and what each one is responsible for
  • Use @Test, @BeforeEach, @AfterEach, @BeforeAll, and @AfterAll correctly, including static requirements
  • Choose the right assertion (assertEquals, assertThrows, assertAll) for a given verification need
  • Explain why assertAll() is preferable to several sequential assertions for multi-field checks
  • Trace the default PER_METHOD test instance lifecycle and why it guarantees test isolation

The Story: One Loading Dock, Many Trucks

Picture a warehouse with a single loading dock built to a standard spec: any truck that meets that spec can back in and unload, regardless of who manufactured it. The warehouse itself doesn't know or care whether a truck is carrying modern pallets or old-style crates — it just needs the truck to speak the dock's protocol. JUnit 5 is built around exactly this separation. Instead of one monolithic tool that is simultaneously the thing discovering tests, the thing running them, and the thing asserting on them, JUnit 5 splits those jobs into three distinct modules, each replaceable without touching the others.

Core Mechanics: Platform, Jupiter, and Vintage

JUnit 4, released in 2006, bundled everything — test discovery, execution, and assertions — into a single JAR. That monolith had real consequences: IDEs and build tools had to reach into JUnit's internals to discover tests, because there was no stable, documented contract for "how do I find and run tests" independent of the assertion library itself. It also enforced a single-runner limitation: a test class could carry exactly one @RunWith(...), which made it awkward to combine, say, Spring's test support with Mockito's in the same class.

JUnit 5 fixes this by being expressed, almost literally, as an equation: JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage.

The JUnit Platform is the foundation layer — a Launcher API and a TestEngine service-provider interface that IDEs (IntelliJ, Eclipse, VS Code) and build tools (Maven Surefire, Gradle) talk to. The Platform itself has no opinion about how a test should be written; it only knows how to discover and launch whatever TestEngine implementations are on the classpath, and report their results back in a uniform format. This is the "loading dock spec" — any engine that implements the TestEngine interface can plug in.

JUnit Jupiter is the modern engine, and the one almost all new Java code is written against. It provides the annotations (@Test, @BeforeEach, and the rest), the assertion library (org.junit.jupiter.api.Assertions), and the extension model (@ExtendWith) that replaced JUnit 4's single-runner limitation with a composable alternative — a test class can now stack multiple extensions (Spring's SpringExtension, Mockito's MockitoExtension) side by side.

JUnit Vintage is a compatibility engine that lets old JUnit 3 and JUnit 4 tests keep running unmodified on the same Platform, side by side with new Jupiter tests. This exists purely for migration: a large legacy codebase does not need a flag-day rewrite of every existing @Test method — Vintage lets old and new tests coexist in the same build, and teams migrate file by file at their own pace.

At the practical level, this architecture means your Maven or Gradle build declares dependencies on junit-jupiter (for writing and running modern tests) and, only if legacy JUnit 4 tests still exist, junit-vintage-engine (so the Platform picks those up too). When a build runs, the Platform's Launcher asks every registered TestEngine to discover tests matching the current classpath, merges their results, and reports one unified pass/fail summary back to the IDE or CI pipeline — regardless of whether a given test was written against Jupiter's modern API or JUnit 4's older one.

Quick Recap

JUnit 5 is Platform (the discovery and launching contract that IDEs and build tools speak to) plus Jupiter (the modern engine with today's annotations and assertions) plus Vintage (a compatibility engine for old JUnit 3/4 tests). This split replaced JUnit 4's single-runner limitation with a composable extension model and gave tooling a stable, documented way to discover tests without reaching into internals.

💻 Code example

// build.gradle (Groovy DSL) -- illustrates the dependency shape implied // by JUnit 5's Platform + Jupiter + Vintage architecture. // // dependencies { // // Jupiter: the modern engine -- annotations, assertions, extensions. // testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' // // // Vintage: only needed if the project still has legacy JUnit 4 tests // // that have not yet been migrated to Jupiter's API. // testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.10.2' // } // // test { // useJUnitPlatform() // tells Gradle's test task to launch via the // // JUnit Platform Launcher, which then discovers // // and runs tests from BOTH engines above. // } package testing.junit5; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; /** * A plain Jupiter test -- this class never mentions "Platform" or * "engine" directly. The Platform's Launcher is what IntelliJ, Maven * Surefire, or Gradle actually invoke; it is the Launcher that finds * this class via the Jupiter TestEngine and runs the method below. */ public class JupiterArchitectureDemo { @Test void additionShouldBeCorrect() { int result = 2 + 3; assertEquals(5, result, "2 + 3 must equal 5"); } }

The Story: Resetting the Kitchen Between Orders

A restaurant kitchen that reused yesterday's half-chopped vegetables for today's orders would produce inconsistent food — sometimes fresh, sometimes not, depending on what was left over. A disciplined kitchen resets its prep station before every single order: fresh ingredients, clean cutting board, same starting conditions every time. JUnit 5's lifecycle annotations exist to enforce exactly that discipline for test methods: a predictable, clean starting point before each test, and reliable cleanup after.

Core Mechanics: The Five Lifecycle Hooks

@Test marks a method as an actual test case — the only method type the JUnit Platform will report a pass/fail result for. Everything else in this topic exists to support methods marked this way.

@BeforeEach marks a method that runs immediately before every single @Test method in the class. This is where you reset mocks, construct a fresh instance of the object under test, and set up whatever baseline state every test in the class needs, ensuring no test accidentally inherits leftover state from a previous one.

@AfterEach marks a method that runs immediately after every @Test method completes, pass or fail. This is the natural place to release resources a single test acquired — closing a file handle it opened, clearing a thread-local value it set, or resetting a security context it modified — so the next test starts clean regardless of whether the previous one passed.

@BeforeAll marks a method that runs exactly once, before any test method in the class runs. Because it executes before any test instance exists, it must be static by default (unless the class opts into @TestInstance(Lifecycle.PER_CLASS), covered in a later subtopic). This is reserved for genuinely expensive one-time setup: starting a shared Testcontainers Docker container, for instance, where restarting it before every individual test would make the suite unacceptably slow.

@AfterAll marks a method that runs exactly once, after every test method in the class has finished. Like @BeforeAll, it must be static by default, and it is the natural place to stop whatever @BeforeAll started — shutting down that shared container, releasing a global socket, or closing a connection pool opened once for the whole class.

The ordering within a single test's execution is fixed and worth memorizing precisely: @BeforeAll (once, for the whole class) runs first, then for every test method in turn: @BeforeEach, then the @Test method itself, then @AfterEach. Once every test method has gone through that cycle, @AfterAll runs once at the very end. If a class has three @Test methods, @BeforeEach and @AfterEach each run three times — once bracketing each test — while @BeforeAll and @AfterAll each run exactly once no matter how many tests the class contains.

A common mistake is reaching for @BeforeAll purely out of a vague sense that "setup should only happen once" without considering the isolation cost. If a @BeforeAll-created object is mutated by one test, every later test in the class inherits that mutation, because the object was only created once and is now shared across every test method — silently reintroducing the exact kind of cross-test coupling the FIRST principles' Isolated property warns against. @BeforeAll should be reserved for genuinely expensive, genuinely immutable (or carefully reset) setup, with @BeforeEach remaining the default choice for anything a test might mutate.

Quick Recap

@Test marks a test case. @BeforeEach/@AfterEach bracket every individual test method, guaranteeing clean state per test. @BeforeAll/@AfterAll run exactly once for the whole class and must be static by default, reserved for expensive setup that is safe to share. The execution order is fixed: @BeforeAll once, then @BeforeEach → @Test → @AfterEach for every test method, then @AfterAll once.

💻 Code example

package testing.junit5; import org.junit.jupiter.api.*; import java.util.ArrayList; import java.util.List; /** * Demonstrates the exact execution order of all five core lifecycle * annotations by logging to a shared list and printing it at the end. */ public class LifecycleAnnotationsDemo { // Shared across all tests in the class -- safe here because it is // only ever appended to, never relied upon for test correctness. static final List<String> executionLog = new ArrayList<>(); private List<String> orderItems; // fresh per test, set in @BeforeEach @BeforeAll static void startSharedResource() { // Runs exactly ONCE before any test in this class. // Must be static: no test instance exists yet at this point. executionLog.add("@BeforeAll: shared resource started"); } @AfterAll static void stopSharedResource() { // Runs exactly ONCE after all tests in this class finish. executionLog.add("@AfterAll: shared resource stopped"); System.out.println(String.join(" -> ", executionLog)); } @BeforeEach void resetOrderItems() { // Runs before EVERY @Test method -- guarantees a fresh list, // so no test can see another test's leftover items. orderItems = new ArrayList<>(); executionLog.add("@BeforeEach"); } @AfterEach void clearOrderItems() { // Runs after EVERY @Test method, pass or fail. orderItems.clear(); executionLog.add("@AfterEach"); } @Test void firstTestAddsAnItem() { orderItems.add("widget"); executionLog.add("@Test firstTestAddsAnItem"); Assertions.assertEquals(1, orderItems.size()); } @Test void secondTestStartsWithEmptyList() { // Because @BeforeEach reset orderItems, this test does NOT see // the "widget" added by the previous test -- isolation guaranteed. executionLog.add("@Test secondTestStartsWithEmptyList"); Assertions.assertTrue(orderItems.isEmpty()); } }

The Story: The Judge Who Only Speaks Once

A courtroom where the judge interrupts the moment the first piece of evidence looks shaky, and ends the entire trial right there, might never hear the other nine pieces of evidence that also needed examining. That is almost exactly what happens with a naive sequence of assertion statements in a test: the first one to fail throws immediately, the test method stops executing on the spot, and every assertion written after it never runs at all — leaving you to fix one failure, rerun, discover the next, fix that, rerun again, one at a time. JUnit 5's assertAll() exists to let the "judge" hear all the evidence before delivering a single, combined verdict.

Core Mechanics: Equality, Exceptions, and Grouped Assertions

assertEquals(expected, actual) is the assertion you reach for most often. It calls expected.equals(actual) under the hood, so it works correctly for anything with a sensible equals() implementation — Strings, boxed numbers, records, and DTOs with equals() generated or overridden. A close cousin, assertSame(expected, actual), checks == reference identity instead — useful for proving two variables point at the literal same object in memory, such as confirming a cache returned a previously stored instance rather than constructing a new equal-but-distinct one.

assertThrows(ExpectedException.class, executable, message) is how JUnit 5 verifies that a specific exception is thrown, and it fixes a real flaw in how JUnit 4 handled this. JUnit 4's @Test(expected = SomeException.class) annotation had no way to pin down which line inside the test method was expected to throw — if line 2 of a ten-line test threw the exception during unrelated setup, the test still passed, even though the actual behavior on line 9 was never exercised. assertThrows fixes this by taking a lambda containing only the single call that is expected to fail, and it returns the caught exception object, which lets you assert further on its message or any custom fields it carries — confirming not just that something threw, but that the right exception, with the right payload, was thrown by the exact call under test.

assertAll(executables...) groups several independent assertions so that all of them run regardless of whether earlier ones fail, and reports every failure together in one combined error message. This matters enormously when verifying an object with several fields: checking a returned OrderResponse's id, total, and status as three separate sequential assertEquals calls means a failure on the id check hides whatever would have happened with the total and status checks — you fix the id, rerun, and only then discover the total was also wrong. Wrapping the same three checks in assertAll() reports every one of the three results in a single run, cutting the fix-rerun-discover loop down from potentially three cycles to one.

A detail worth knowing: assertions in JUnit 5 support lazy message evaluation via a Supplier<String> overload. Passing () -> "expensive message " + buildDebugString() instead of a plain String means that expensive debug-string construction only happens if the assertion actually fails — for a suite with thousands of passing assertions, this avoids paying a real performance cost for messages that are, in the common case, never displayed.

Choosing the right assertion is itself a skill: assertEquals for value equality, assertSame for reference identity (rare, but important when it matters), assertThrows for verifying failure behavior with a precise, scoped lambda, and assertAll whenever you are checking more than one independent fact about the same outcome.

Quick Recap

assertEquals checks value equality via .equals(); assertSame checks reference identity via ==. assertThrows takes a lambda scoped to exactly the call expected to fail, and returns the caught exception for further inspection — fixing JUnit 4's imprecise @Test(expected=...). assertAll groups independent checks so all of them run and report together, instead of stopping at the first failure and hiding the rest.

💻 Code example

package testing.junit5; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; public class AssertionsDeepDiveDemo { record OrderResponse(String id, double total, String status) {} static OrderResponse createOrder(double amount) { if (amount <= 0) { throw new IllegalArgumentException("Amount must be positive"); } return new OrderResponse("ORD-1001", amount, "PENDING"); } @Test void shouldCreateOrderWithExpectedValues() { OrderResponse response = createOrder(100.0); // assertAll groups three independent checks -- if the total were // wrong AND the status were wrong, both failures would be reported // together, instead of only ever seeing the first one. assertAll("order response fields", () -> assertEquals("ORD-1001", response.id()), () -> assertEquals(100.0, response.total()), () -> assertEquals("PENDING", response.status()) ); } @Test void shouldThrowOnNonPositiveAmount() { // The lambda contains ONLY the call expected to fail -- if any // earlier setup line threw instead, this assertion would correctly // report that the expected exception was never raised by this call. IllegalArgumentException thrown = assertThrows( IllegalArgumentException.class, () -> createOrder(-5.0) ); // assertThrows returns the caught exception, so we can inspect // its payload -- not just that SOME exception was thrown. assertEquals("Amount must be positive", thrown.getMessage()); } @Test void shouldReturnSameCachedInstance() { OrderResponse cached = createOrder(50.0); OrderResponse sameReference = cached; // same object, not a copy // assertSame checks reference identity (==), distinct from // assertEquals, which would only check field-by-field equality. assertSame(cached, sameReference); } }

The Story: A Fresh Notebook for Every Exam Question

Imagine an exam where a new, blank notebook is handed out for every single question, versus one notebook used for the entire exam. With a fresh notebook per question, nothing you wrote answering question one can accidentally leak into or influence your answer to question two — each answer stands completely on its own. JUnit 5's default behavior for test classes works exactly like the fresh-notebook version: unless told otherwise, a completely new instance of the test class is constructed for every single @Test method.

Core Mechanics: PER_METHOD Versus PER_CLASS

By default, JUnit 5 uses TestInstance.Lifecycle.PER_METHOD. If a test class declares five @Test methods, JUnit constructs five entirely separate instances of that class — one exclusively for each test method. Because each test method runs against its own fresh object, any instance variable set or mutated during test one has absolutely zero effect on test two, since test two is running on an entirely different object in memory. This is what guarantees that tests are independent of execution order by construction, rather than merely by good intentions: there is no shared instance state for one test to accidentally leak into another, because there is no shared instance at all.

The execution flow for a three-test class under PER_METHOD looks like this: @BeforeAll runs once (statically, since no instance exists yet). Then, for test one: a new instance is constructed, @BeforeEach runs on it, the test method runs, @AfterEach runs, and that instance becomes eligible for garbage collection. The exact same cycle repeats independently for test two and test three, each on its own fresh instance. Finally, @AfterAll runs once, after all three cycles complete.

TestInstance.Lifecycle.PER_CLASS, enabled with the class-level annotation @TestInstance(TestInstance.Lifecycle.PER_CLASS), inverts this: JUnit constructs exactly one instance of the test class and reuses it across every @Test method. The main practical benefit is that @BeforeAll and @AfterAll no longer need to be static — since a single instance already exists for the whole class, instance methods work fine for them, which can be convenient when @BeforeAll needs to use instance state set up in the constructor, or when a non-static language feature (like a non-static inner class) makes static methods inconvenient.

The cost of PER_CLASS is that it reopens the exact isolation risk PER_METHOD was designed to close. Any instance field mutated by one test method is now visible to every subsequent test method in the same class, because they are all running on the same shared object. A counter incremented in test one will still show that incremented value when test two runs, unless @BeforeEach explicitly resets it every time. This is not necessarily wrong to use, but it shifts the responsibility for isolation from "the framework guarantees it structurally" to "the developer must remember to reset everything in @BeforeEach, every time" — a much easier discipline to accidentally break as a class grows.

The practical guidance most teams follow: default to PER_METHOD (the framework default — no annotation needed) unless there is a specific, deliberate reason to share instance state, such as wanting non-static @BeforeAll for convenience with Kotlin-style constructor injection, or genuinely expensive instance-level setup that benefits from being computed once per class rather than once per test method and is safe to share because it is never mutated.

Quick Recap

PER_METHOD (the default) constructs a brand-new test class instance for every @Test method, which is what makes @BeforeAll/@AfterAll need to be static and what guarantees tests cannot leak instance state into one another. PER_CLASS, opted into explicitly, reuses a single instance across all tests in the class, allowing non-static @BeforeAll/@AfterAll but reintroducing the risk of one test's mutations silently affecting another unless @BeforeEach resets shared state every time.

💻 Code example

package testing.junit5; import org.junit.jupiter.api.*; /** * Demonstrates how PER_METHOD (the default) prevents instance state from * leaking between tests, by showing an instance field that would "leak" * under PER_CLASS without a reset -- but cannot under the default. */ public class TestInstanceLifecycleDemo { // An instance field -- under PER_METHOD, every test method gets its // own fresh copy of this field (starting at 0 again) because every // test method runs on a brand-new object. private int callCount = 0; @Test void firstTestIncrementsCallCount() { callCount++; // This instance's callCount is 1 -- but this object is discarded // after this test; it is never seen by any other test method. Assertions.assertEquals(1, callCount); } @Test void secondTestSeesFreshCallCount() { // A brand-new instance was constructed for THIS test method. // callCount starts at 0 again here, proving no leakage occurred, // even though firstTestIncrementsCallCount() ran moments earlier. Assertions.assertEquals(0, callCount); callCount++; Assertions.assertEquals(1, callCount); } // --- Contrast: PER_CLASS would require an explicit reset like this --- @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) class SharedInstanceExample { private int sharedCounter = 0; @BeforeEach void resetCounter() { // Without this explicit reset, sharedCounter would keep // climbing across every test method, because PER_CLASS reuses // the SAME instance for all of them. sharedCounter = 0; } @Test void incrementOnce() { sharedCounter++; Assertions.assertEquals(1, sharedCounter); } @Test void incrementAgainStartsFromResetValue() { sharedCounter++; Assertions.assertEquals(1, sharedCounter); // thanks to @BeforeEach reset } } }

Want a visual for this concept?

Generate a diagram tailored to “JUnit 5 Fundamentals” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to JUnit 5 Advanced Features →← Back to all Testing — JUnit, Mockito & Spring Boot chapters