JUnit 5 & Jupiter: Lifecycle, Nested & Parameterized Tests

Explore JUnit 5 Jupiter features: master test lifecycle annotations, organize tests with @Nested, and parameterize tests with @CsvSource and @MethodSource.

published: reading time: 28 min read author: GeekWorkBench
Quick Summary

Explore JUnit 5 Jupiter features: master test lifecycle annotations, organize tests with @Nested, and parameterize tests with @CsvSource and @MethodSource. The guide uses practical examples to explain introduction to junit 5 and jupiter, when to use and when to avoid each feature and shows how to apply the ideas in a Spring Boot project.

JUnit 5 & Jupiter: Test Lifecycle, Nested Tests, Parameterized Tests

JUnit 5 broke with the monolithic design of its predecessors and introduced a modular architecture. The part you actually write tests with is called Jupiter. If you have been using JUnit 4 annotations up until now, Jupiter feels familiar but ships with some patterns that genuinely make test code less repetitive and easier to navigate.

This post covers three areas that come up constantly in real JUnit 5 codebases: lifecycle annotations for setup and teardown, nested classes for grouping related assertions, and parameterized tests for running the same logic against different inputs. There is some overlap between them, and knowing when to reach for which one is half the battle.

Introduction to JUnit 5 and Jupiter

Introduction

JUnit 5’s Jupiter programming model provides the lifecycle, grouping, and data-driven testing features used in modern Java projects. This guide shows how @BeforeAll and @BeforeEach control setup, how @Nested organizes related cases, and how parameterized tests cover varied inputs without duplicating test logic.

When to Use and When to Avoid Each Feature

@BeforeAll is the right choice when you need to initialise something expensive once: a database connection, a shared client, reference data that does not change between tests. If the setup cost is high and you do not need a fresh copy per test, run it once at the class level.

@BeforeEach is for per-test preparation. Reset a mock, populate a temporary file, seed a list with test data. The catch is that it runs before every single @Test method, so if your setup is heavier than the actual test work, your suite will feel slow.

A common mistake is using @BeforeEach when @BeforeAll would do. If every test uses the same pre-populated list and none of them modify it, initialise it once. Only reach for per-test setup when tests genuinely need isolation from each other.

@Nested is useful when a group of tests shares setup that other tests do not need. If you have a repository test with ten methods for add() and five methods for delete(), nesting lets you put add tests in one inner class with their own setup and delete tests in another. It communicates intent: these tests belong together.

Do not nest when there is no meaningful shared setup, and do not nest more than two or three levels. If you find yourself writing @Nested class InnerClass3LevelDeep, step back and ask whether those should be separate test classes instead.

Parameterized tests work when you want to assert the same thing against different inputs: boundary values, equivalence partitions, multiple constructor arguments. If the inputs produce different assertions, parameterization adds noise. When you catch yourself writing if (inputType == X) assertSomethingElse() inside a parameterized test, split it into named test methods instead.

Test Lifecycle Phases

The diagram below shows the order in which lifecycle methods fire for a single test class with two test methods.

graph TD
    Start([Test Class Loaded]) --> BeforeAll
    BeforeAll --> Test1[Test Method 1]
    BeforeAll --> Test2[Test Method 2]
    Test1 --> BeforeEach1["@BeforeEach"]
    BeforeEach1 --> Test1Execute["Execute @Test"]
    Test1Execute --> AfterEach1["@AfterEach"]
    AfterEach1 --> Test2
    Test2 --> BeforeEach2["@BeforeEach"]
    BeforeEach2 --> Test2Execute["Execute @Test"]
    Test2Execute --> AfterEach2["@AfterEach"]
    AfterEach2 --> AfterAll["@AfterAll"]
    AfterAll --> End([Test Class Unloaded])

@BeforeAll and @AfterAll run once per class. Every test method gets its own @BeforeEach and @AfterEach. This matters for resource management: if you open a connection in @BeforeAll, close it in @AfterAll exactly once, not in @AfterEach.

Common Mistakes with Lifecycle Annotations

The one that bites everyone at least once: @BeforeAll must be static. If you write void setUp() instead of static void setUp(), JUnit throws an exception at runtime saying the method must be static. This is because @BeforeAll runs before the first test, before any test instance exists.

A subtler mistake: assuming @BeforeEach runs once per nested class. It does not. The outer @BeforeEach runs for every test in the class, including tests in nested classes. Each nested class can also declare its own @BeforeEach, which runs in addition to the outer one. If this surprises you, write a small test class with logging and run it to see the actual sequence.

The third issue is assuming a fixed execution order across classes in parallel mode. JUnit 5 can run tests in parallel, and while @BeforeAll within a class runs sequentially, classes do not have a guaranteed order. If your tests depend on each other, that is a design problem, not something lifecycle annotations can fix.

Trade-Off Comparison

Feature Best For Avoid When Key Consideration
@BeforeAll Expensive shared resources Need per-test isolation Must be static or class-level instance
@BeforeEach Fresh per-test state Setup cost > test cost Runs for every test method
@Nested Grouping related assertions Deep nesting, unrelated tests Inner classes inherit outer lifecycle
@ParameterizedTest Data-driven equivalence tests Different assertion logic per input Keep parameter count reasonable
@CsvSource Inline small datasets Large data files Use for under 20 rows
@MethodSource External or computed data Simple inline data Better for complex object construction

Implementation Examples

Lifecycle Annotations

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class LifecycleDemoTest {

    @BeforeAll
    static void setUpClass() {
        System.out.println("Running once before all tests");
    }

    @AfterAll
    static void tearDownClass() {
        System.out.println("Running once after all tests");
    }

    @BeforeEach
    void setUp() {
        System.out.println("Running before each test");
    }

    @AfterEach
    void tearDown() {
        System.out.println("Running after each test");
    }

    @Test
    void firstTest() {
        System.out.println("Executing firstTest");
    }

    @Test
    void secondTest() {
        System.out.println("Executing secondTest");
    }
}

Running this prints: @BeforeAll once, then for each test @BeforeEach, the test itself, then @AfterEach. After both tests, @AfterAll fires once.

Nested Tests

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;

class NestedListTest {

    List<String> list;

    @BeforeEach
    void setUp() {
        list = new ArrayList<>();
    }

    @Nested
    class WhenEmpty {

        @BeforeEach
        void setUpEmpty() {
            list.clear();
        }

        @Test
        void sizeShouldBeZero() {
            assert list.size() == 0;
        }

        @Test
        void addShouldWork() {
            list.add("item");
            assert list.size() == 1;
        }
    }

    @Nested
    class WhenPopulated {

        @BeforeEach
        void setUpPopulated() {
            list.add("initial");
            list.add("items");
        }

        @Test
        void sizeShouldBeTwo() {
            assert list.size() == 2;
        }

        @Test
        void clearShouldWork() {
            list.clear();
            assert list.isEmpty();
        }
    }
}

The outer setUp() runs first, creating an empty list. For WhenEmpty tests, the inner setUpEmpty() clears it, so tests see an empty list. For WhenPopulated tests, the inner setup adds two items, so tests see a list with two elements. Both inner setups run on top of the outer setup, not instead of it.

Parameterized Tests with @CsvSource

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;

class CsvSourceDemoTest {

    @ParameterizedTest
    @CsvSource({
        "1, 2, 3",
        "4, 5, 9",
        "10, 20, 30"
    })
    void shouldAddTwoNumbers(int a, int b, int expected) {
        assertEquals(expected, a + b);
    }
}

Three rows, three test invocations. JUnit reports each one separately, so a failure tells you exactly which row caused it.

Parameterized Tests with @MethodSource

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;

class MethodSourceDemoTest {

    static Stream<Arguments> stringCombinations() {
        return Stream.of(
            Arguments.of("hello", "world", "hello world"),
            Arguments.of("junit", "5", "junit 5"),
            Arguments.of("", "", ""),
            Arguments.of("a", "b", "a b")
        );
    }

    @ParameterizedTest
    @MethodSource("stringCombinations")
    void shouldConcatenateStrings(String first, String second, String expected) {
        assertEquals(expected, first + " " + second);
    }
}

The factory method must be static and return Stream<Arguments>. Wrap multiple parameters in Arguments.of(…). Single-parameter cases can return Stream<String> directly.

Observability Checklist

A well-observable test suite makes debugging failures less painful. Work through this checklist when setting up your test classes.

  • Lifecycle methods log their invocation when running in verbose mode
  • Parameterized test invocations are reported with their input values
  • Nested test class names appear clearly in reports, not just method names
  • @Disabled tests appear as skipped, not silently omitted
  • Test execution order is deterministic within a class
  • Assertion failures include enough context to identify the failing input
  • @Tag annotations appear correctly in build tool reports

Security Notes

Tests that share mutable state are a correctness problem and a security problem. When a failing test corrupts shared state, it causes downstream tests to fail for the wrong reasons, masking real bugs. In parallel execution, shared mutable collections corrupt immediately, not eventually.

Keep tests isolated. Avoid static mutable fields in test classes. If you need a shared client or connection, inject it rather than storing it in a static field. Use mocks for external services, and use test accounts with limited permissions when you cannot mock.

Teardown matters. If an integration test logs sensitive data in setUp(), clear it in tearDown(). This is easy to overlook when the test itself “passes” but leaves data in logs or thread-local storage.

Common Pitfalls / Anti-Patterns

@BeforeAll being static trips up almost everyone. The reason is straightforward: the method runs before any test instance exists, so there is nothing to call it on. If you genuinely need instance-level @BeforeAll, annotate the class with @TestInstance(Lifecycle.PER_CLASS). The tradeoff is that all tests in the class then share one instance, so watch for state leakage between tests.

Parameter count mismatches are the other frequent issue. If your @CsvSource has five columns and your method takes four parameters, JUnit throws ParameterResolutionException at runtime. The message tells you which parameter failed to resolve, so the fix is usually obvious once you count the columns against the signature.

@MethodSource factory methods must be static. If you write the method as an instance method, JUnit cannot call it because no instance exists when the factory is evaluated. If your factory needs instance state, reconsider the design: the data should come from the test parameters or from a static source, not from mutable instance fields.

When NOT to Use

These features trip people up more often than you might expect.

Deeply Nested Classes

@Nested stops helping after two levels. Every level adds complexity: you have to remember which @BeforeEach hooks run, what scope your variables live in, and how inheritance interacts with lifecycle annotations. A three-level hierarchy like OuterTests > InnerTests > DeepTests ends up being harder to maintain than it is worth. If you catch yourself writing Assertions.assertAll chains to group assertions from nested classes, flatten the structure. Split test classes by concern and extract shared logic into utility methods. Nested classes work best when they group a handful of related assertions, not when they try to model every variation of your test scenarios.

@BeforeAll With Instance State

@BeforeAll methods that modify instance fields cause subtle failures that are difficult to track down. Because @BeforeAll runs once before any test in the class, any state it sets can be stale by the time most tests execute. A test that assumes freshly initialized state breaks silently when someone adds a new test with different expectations. If you need initialization before each test, use @BeforeEach. Save @BeforeAll for one-time setup like opening a database connection or loading static configuration. When you genuinely need @BeforeAll with instance state, write explicit assertions in @BeforeEach that fail with a clear message if the state is not what the test expects.

Parameterized Tests With Divergent Assertions

Parameterized tests work well when every invocation runs the same assertions against different inputs. They become unwieldy when different parameter sets require different assertions. Imagine a test that validates parsing for valid inputs but also checks error handling for invalid ones. Mixing both in a single parameterized method means some arguments trigger assertThrows while others trigger assertEquals. The resulting code is harder to read than separate test methods with descriptive names. Test reports also become confusing: did the “invalid input” case pass because parsing correctly threw an exception, or because it failed for the wrong reason? When your parameter combinations have different assertion logic, use separate test methods or separate test classes instead.

@RepeatedTest for Simple Repetition

@RepeatedTest verifies that behavior stays consistent across runs. It is not a replacement for proper stress testing or property-based testing. If you find yourself writing @RepeatedTest(1000) to catch intermittent failures, stop and think about what is actually happening. @RepeatedTest does not isolate test runs from each other, so if iteration 500 leaves state that iteration 501 depends on, you have hidden coupling between repetitions. Use @RepeatedTest for genuinely repeated scenarios like verifying a parser handles many variations of valid input. For everything else, use proper state management or dedicated stress testing tools.

Dynamic Tests for One-Off Checks

@TestFactory generates tests dynamically at runtime. It is genuinely useful for property-based testing or when the test structure itself comes from external data. For most ad-hoc checks, it creates more problems than it solves. Dynamic tests skip the JUnit lifecycle, which means no @BeforeEach, no @AfterEach, no extensions, and in many cases, no IDE test tree support. If you want a one-off test based on runtime data, a regular @Test method with a loop and @AfterEach cleanup is usually clearer. Save @TestFactory for cases where the test structure must come from external input, not just the test data.

Production Failure Scenarios

JUnit 5 tests that pass locally can still fail in production environments. These are the most common culprits.

Parallel Execution Exposing Shared State

JUnit 5 supports parallel test execution via junit.jupiter.execution.parallel.enabled=true. This speeds up CI runs, but it reveals any shared mutable state that sequential tests silently ignore. A static List collecting test results or a static Map caching test artifacts will cause sporadic failures when two tests write to it at the same time. The solution is not to disable parallel execution. Instead, ensure tests communicate only through controlled channels like thread-local storage, proper test isolation with fresh instances per method, or external test fixtures. If your test suite passes sequentially but fails under parallel execution, you have a data race to fix, not a flaky test to dismiss.

@BeforeAll Static Method Mistakes

Tests that pass in the IDE but fail in Maven or Gradle usually come from a missing static modifier on @BeforeAll. IDEs sometimes resolve this implicitly or cache results in a way that hides the problem. In a CI pipeline with a clean build, the failure is obvious. Another scenario: @BeforeAll throws an exception in a base class inherited by subclasses. The error propagates as a class initialization failure, and every test in the subclass vanishes from the report. When your test count drops unexpectedly, check for initialization errors in base classes.

Test Isolation Failures With Database State

Integration tests that share a database connection or schema across test methods corrupt each other’s data in production. A test that inserts a row and relies on that row existing for a later test fails when test execution order changes or parallel execution begins. The telltale sign is tests that pass individually but fail when run as a suite. Use transactional boundaries like Spring’s @Transactional, or give each test a fresh schema via containers or in-memory databases. If you cannot isolate at the database level, isolate at the connection level by ensuring each test method gets its own connection from the pool.

Parameter Resolution Exceptions in CI

ParameterResolutionException shows up when the test runs in an environment where classpath scanning behaves differently. A @CsvSource with an escaped comma in a quoted string might parse fine in your IDE but fail in Maven due to different character encoding or line endings. Similarly, a @MethodSource referring to a factory method by name fails if the method is not visible because of package-private access across modules. Use fully qualified factory method names in @MethodSource to avoid ambiguity. For CSV sources, keep quoted strings simple or use @NullSource and @EmptySource for edge cases instead of encoding them inline.

Extension Registration Missing in Production Build

JUnit 5 extensions registered via @ExtendWith in a superclass are inherited, but only when the superclass is on the classpath. In a multi-module project, if the module with the base test class is not a dependency of the module with the concrete tests, the extension never loads. This shows up as tests that run but skip critical setup logic: no mocked services, no populated fixtures, no custom assertions. The tests appear to pass because nothing fails, but they are not actually testing what you think they are. Verify extension registration in CI by checking that lifecycle methods actually run. Adding a logging assertion or a static counter in the extension confirms it was instantiated.

Quick Recap

  • @BeforeAll methods are static by default; switch to PER_CLASS lifecycle only when you need instance @BeforeAll
  • @BeforeEach runs for every test method, including those in nested classes
  • Outer @BeforeEach fires before inner @BeforeEach in nested classes, not instead of it
  • Use @CsvSource for small inline datasets, @MethodSource for anything complex
  • @MethodSource factory methods must be static and return Stream<Arguments>
  • Do not use static mutable fields; they break parallel execution
  • Tag tests for selective execution via build tools
  • Teardown resources in @AfterAll to prevent leaks across test classes

Interview Questions

1. Why does @BeforeAll need to be static by default in JUnit 5 Jupiter?

JUnit 5 creates test instances per method by default. Before the first test method in a class runs, no instance exists, so there is no object to call an instance method on. @BeforeAll runs at that point, which is why it must be static. If you need instance-level lifecycle, annotate the class with @TestInstance(Lifecycle.PER_CLASS). This creates one instance per class instead of one per method, allowing @BeforeAll on instance methods. The catch is that all tests then share the same instance, so you have to be careful about mutable state between tests.

2. How do lifecycle methods behave in nested test classes?

Outer class lifecycle methods apply to everything in the class, nested classes included. If the outer class has @BeforeEach, it runs before every test, including tests in nested classes. Nested classes can also declare their own lifecycle methods, which run in addition to the outer ones. The order for a nested test is: outer @BeforeAll, outer @BeforeEach, inner @BeforeEach, test, inner @AfterEach, outer @AfterEach. After all tests finish, outer @AfterAll fires once. This layering lets you set up shared context at the outer level and specific context per nested group.

3. What is the difference between @CsvSource and @MethodSource for parameterized tests?

@CsvSource takes inline comma-separated values where each row maps to one test invocation and columns map to method parameters by position. It works well for small datasets that you want to see directly in the test code. @MethodSource references a factory method by name and is better suited for complex object construction, data from external sources, or computed test data that would make the inline CSV hard to read. The factory method must be static and return Stream<Arguments>. For straightforward primitive inputs with fewer than about 20 rows, @CsvSource is cleaner. For anything more involved, @MethodSource keeps the test readable.

4. What are the security implications of shared state in test classes?

Shared mutable state breaks two things. First, test execution order is not guaranteed, so a test that modifies shared state can cause other tests to fail for the wrong reasons. Second, JUnit 5 supports parallel execution, which corrupts shared mutable collections immediately. From a security angle, tests that write sensitive data into shared structures risk contaminating other tests and potentially exposing that data if the structures are logged or inspected. The safest approach is isolated tests: each test sets up its own state, uses mocks for external calls, and tears down completely in @AfterEach or @AfterAll.

5. How would you debug a ParameterResolutionException in a @ParameterizedTest?

The exception means the number or type of arguments from your source does not match your test method parameters. Start by counting the CSV columns or Arguments.of() entries against your method signature. With @CsvSource, verify every row has the same number of columns. With @MethodSource, confirm the factory returns Stream<Arguments> with the right number of values per call. Type mismatches also trigger this exception, so check that a value like "true" can be parsed to your target type, such as boolean. The exception message usually names the parameter that failed, which makes the fix straightforward once you compare the data against the signature.

6. What is @TestInstance(Lifecycle.PER_CLASS) and when would you use it?

By default, JUnit 5 creates a new test instance for each test method. @TestInstance(Lifecycle.PER_CLASS) changes this to one instance per test class. The main reason to use it is when you need instance-level @BeforeAll and @AfterAll methods instead of static ones. This is useful when your setup involves something that cannot be static, such as calling an instance method on a dependency injected object, or when you want to share expensive state across all test methods without resorting to static fields. The tradeoff is that tests must not modify shared state, or they must reset it in @BeforeEach.

7. How does @Nested interact with inheritance and test execution order?

Nested classes are inner classes of the outer test class, so they inherit the outer class lifecycle methods. If the outer class has @BeforeEach, it runs before every test including those in nested classes. Nested classes can also declare their own lifecycle methods, which layer on top of the outer ones. Regarding inheritance, nested classes do not inherit tests from parent classes in the Java sense, they are just contained within the outer class. For execution order, JUnit 5 does not guarantee a fixed order across test methods, but within a class the methods run in a deterministic order unless explicitly configured otherwise.

8. What are the tradeoffs between @CsvSource and @MethodSource?

@CsvSource is inline and visible directly in the test, making it ideal for small datasets of fewer than 20 rows with simple types like primitives or strings. The downside is that large inline data clutters the test method. @MethodSource references a separate factory method, which keeps the test clean for complex data but requires navigating to the factory to understand the inputs. @MethodSource also supports external data sources, computed values, and complex object construction that would be unwieldy in CSV format. Use @CsvSource for quick sanity checks and boundary value tests; use @MethodSource for anything that requires logic to generate the test cases.

9. Why should static mutable fields be avoided in test classes?

Static mutable fields break test isolation in two ways. First, they persist across test method executions, so state from one test can leak into the next. Second, when parallel execution is enabled, multiple test threads can write to the same static field simultaneously, causing data races that produce sporadic failures. Even when tests run sequentially, a test that modifies a static collection and fails to clean it up can cause a subsequent test to fail for the wrong reason, making debugging harder. The fix is to use instance fields with fresh setup per test via @BeforeEach, or to use thread-local storage if you genuinely need shared state across tests.

10. How do you handle tests that need resources from external systems like databases?

For database integration tests, the safest approach is transaction isolation: wrap each test in a transaction and roll it back after the test completes. Spring's @Transactional annotation on test classes does this automatically. Alternatively, use containers like Testcontainers to provision a fresh database per test or per test class. Another option is in-memory databases like H2 for simpler cases. Regardless of the approach, ensure each test starts with a known state, executes its assertions, and leaves no side effects for subsequent tests. If you cannot isolate at the database level, isolate at the connection level by acquiring a fresh connection from the pool per test.

11. What is the difference between @BeforeEach and @BeforeAll at the nested class level?

At the nested class level, @BeforeEach in the nested class runs after the outer class @BeforeEach but before the test methods in the nested class. The outer @BeforeEach is not replaced, it still fires for every test across all nested classes. Each nested class can have its own @BeforeEach that contributes to the setup for its tests. Similarly, @AfterEach in the nested class runs before the outer @AfterEach. @BeforeAll and @AfterAll at the nested class level only apply to tests within that nested class, not to the outer class tests.

12. How would you structure a test class for a repository with multiple operations?

Use nested classes to group tests by operation, with each nested class having its own setup relevant to that operation. For a CRUD repository, you might have an outer class with a shared connection, an @Nested class CreateTests with setup that produces an empty state, an @Nested class ReadTests with setup that populates known data, an @Nested class UpdateTests with setup that inserts a record to update, and a @Nested class DeleteTests with setup that inserts records to delete. Each nested class sets up only what its tests need, and the outer class provides common infrastructure. This makes the test structure mirror the actual operations and keeps setup code close to the tests that use it.

13. What are the limitations of @RepeatedTest compared to proper stress testing?

@RepeatedTest runs the same test method multiple times but does not isolate the iterations from each other. If iteration N modifies shared state, iteration N+1 sees that modified state. This means @RepeatedTest cannot catch intermittent failures caused by state coupling between iterations, and it cannot replace proper stress testing tools that reset state between runs. @RepeatedTest is useful for verifying that a single operation is idempotent or that a parser handles many variations of valid input consistently. For anything involving concurrent access, memory leaks under sustained load, or resource pool exhaustion, use dedicated stress testing tools instead.

14. What causes tests to pass in the IDE but fail in Maven or Gradle?

The most common cause is a missing static modifier on @BeforeAll methods. IDEs sometimes resolve this implicitly or cache results in a way that masks the problem, while build tools enforce the requirement strictly. Another cause is differences in classpath or resource loading: a test that reads a file from src/test/resources might work via IDE classpath but fail when the build tool uses a different resource directory. Encoding differences for CSV files are also common on Windows, where line endings differ between IDE and build tool processing. Check that your build runs a clean build with no cached results to surface these issues early.

15. How do you prevent extension registration from failing silently in multi-module projects?

Extensions registered via @ExtendWith in a base class are only inherited when that base class is on the classpath of the module containing the concrete tests. In multi-module projects, if the module dependency is missing, the extension never loads and the tests run without the setup logic. To catch this, add a logging assertion or static counter in the extension constructor and lifecycle methods, then verify those logs appear in CI output. You can also write a test that explicitly asserts the extension was instantiated. Another approach is to use auto-registration via junit-platform.properties or service loading instead of relying on inheritance from a base class.

16. What is the purpose of @Tag in JUnit 5 and when should you use it?

@Tag lets you label tests so they can be filtered during execution. This is useful in CI pipelines where you might want to run fast unit tests on every commit but reserve slower integration tests for certain triggers. Tags also help organise test reports, letting you see coverage by category like unit, integration, or database. To use tags, annotate test methods or classes with @Tag("category") and configure your build tool to include or exclude specific tags. Avoid putting business logic in tag names; use descriptive categories that reflect how you want to group test execution.

17. How does conditional test execution work in JUnit 5?

JUnit 5 supports conditional execution via annotations like @EnabledOnOs, @EnabledOnJre, @EnabledIf, and @DisabledIf. These let you enable or disable tests based on system properties, environment variables, or custom conditions. For custom conditions, implement ExecutionCondition and register it via @ExtendWith. Conditional execution is useful for platform-specific tests, tests that should only run on CI, or tests that depend on feature flags. Be careful not to abuse it for test logic that should be expressed as assertions; disabled tests still represent gaps in coverage.

18. What are the advantages of the Jupiter extension model over JUnit 4 rules?

JUnit 4 rules required wrapping test methods in a Statement and implementing TestRule, which was verbose and awkward for anything beyond simple setup/teardown. JUnit 5 extensions use callback interfaces like BeforeEachCallback and AfterAllCallback that integrate cleanly with the lifecycle without requiring you to wrap statements. Extensions can also participate in test parameter resolution, custom assertions via TestReporter, and dynamic test registration via TestFactory. The extension model is also more composable: multiple extensions can be registered on the same class and execute in a defined order without interfering with each other.

19. How do you write a @MethodSource factory for complex object parameters?

Return Stream<Arguments> from a static factory method and wrap each parameter set in Arguments.of(). For complex objects, construct them inside the factory method or use a builder pattern. The factory method must be static and can take no parameters or parameters that JUnit resolves from test context like TestInfo. For example: static Stream<Arguments> personProvider() { return Stream.of(Arguments.of(new Person("Alice", 30), new Person("Bob", 25)); }. If the factory needs external data, load it inside the factory method or use a static initializer. Avoid returning instance state from the factory or storing mutable state between test invocations.

20. Why is it important to clean up resources in @AfterAll rather than @AfterEach?

If you open a resource like a database connection or file handle in @BeforeAll, close it in @AfterAll exactly once. Using @AfterEach for teardown of shared resources is wrong because it runs after every test method, and closing a shared connection after the first test would cause subsequent tests to fail. The pattern is: @BeforeAll opens the resource once, @AfterAll closes it once, and @BeforeEach resets per-test state without touching the shared resource. For per-test resources, open and close in @BeforeEach and @AfterEach respectively.

Further Reading

Conclusion

JUnit 5 Jupiter gives you a cleaner model for writing tests than JUnit 4, but the benefits only materialise if you understand how the lifecycle actually works. @BeforeAll and @AfterAll run once per class and must be static by default; @BeforeEach and @AfterEach wrap every individual test. Nested classes inherit the outer lifecycle, so both run before nested tests. Parameterized tests handle the mechanical work of running the same assertion against different inputs, with @CsvSource for small inline datasets and @MethodSource for anything that would clutter the test signature.

The patterns interact in ways that trip people up. A static @BeforeAll method that tries to use instance state is a compile-time warning most IDEs will catch, but the nested lifecycle inheritance is something you have to reason through. The practical takeaway is simple: keep lifecycle methods focused on setup and teardown, avoid shared mutable state, and use @Nested to express grouping rather than to build complex hierarchies.

For Spring Boot projects already on JUnit 5, most of this is available out of the box. The investment is understanding when each feature makes your tests clearer versus when it adds unnecessary indirection.

Category

Related Posts

Spring Boot Build Tools: Maven & Gradle

Configure Maven and Gradle for Spring Boot projects—plugins, dependency management, packaging JARs and WARs, and build automation essentials.

#spring-boot #spring-boot-roadmap #learning-path

Embedded Web Servers in Spring Boot: Tomcat, Jetty, Undertow

Configure embedded servers in Spring Boot: compare Tomcat, Jetty, and Undertow, tune thread pools, enable access logs, and switch implementations.

#spring-boot #spring-boot-roadmap #learning-path

Mockito: Mocking, Stubbing, Verifications, Spy vs Mock

Learn Mockito fundamentals: create mocks and spies, stub behavior with when().thenReturn(), verify interactions, and choose between Spy vs Mock wisely.

#spring-boot #spring-boot-roadmap #learning-path