ScreenshotNeo

BlogGuides

JUnit 5 (Jupiter): A Practical Guide

Learn how JUnit 5 is structured, add Jupiter to Maven or Gradle, write maintainable tests, use extensions, and migrate from JUnit 4.

By the ScreenshotNeo team4 October 202615 min read

JUnit 5 is the generation of JUnit made up of the JUnit Platform, JUnit Jupiter, and JUnit Vintage. For new JUnit 5 tests, add the Jupiter API and engine (the junit-jupiter aggregate is the simplest choice), configure your build to run tests on the Platform, and write tests with Jupiter annotations such as @Test. Vintage is optional: add it only when you need to run legacy JUnit 3 or 4 tests alongside Jupiter.

Version context: examples below are pinned to JUnit 5.11.0 so their dependency coordinates and build configuration are explicit. This is a versioned JUnit 5 guide, not an instruction to use JUnit 5 as the latest release: JUnit 5.13.1 was released June 7, 2025, and the JUnit team repository reports JUnit 6.1.3 GA on August 7, 2026. Check the version line your project intends to use before copying the examples. JUnit 5.13.1 release notes; JUnit framework repository and release information.

1. JUnit 5 architecture: Platform, Jupiter, and Vintage

“JUnit 5” names a modular generation, not one standalone runner. The three parts have separate jobs:

Part Purpose When you need it
JUnit Platform Launches JVM test engines and provides the engine and launcher integration layer used by build tools and IDEs. Usually supplied or integrated by the test runner. It is the layer that lets different test engines run in a common platform.
JUnit Jupiter Provides the programming and extension model for authoring JUnit 5 tests, plus the engine that discovers and executes them. Use it to write and run new Jupiter tests.
JUnit Vintage Provides an engine for running legacy JUnit 3 and JUnit 4 tests on the Platform. Add it only during a staged migration or when old tests still need to run.

The official JUnit 5.11 User Guide describes JUnit 5 as Platform + Jupiter + Vintage. It also notes that Jupiter is both the programming model and extension model and provides an engine for Jupiter-based tests. You do not need every module in every project.

2. Add JUnit 5.11.0 to a project

JUnit 5 requires Java 8 or higher at runtime. The examples use Java source that works with Java 8. The 5.11.0 guide recommends aligning JUnit modules with the JUnit BOM; the examples use the BOM so the dependency versions do not drift apart. If Spring Boot manages your test dependencies, follow its dependency management instead of adding a competing JUnit BOM.

Maven setup

Put this in pom.xml. The Jupiter aggregate includes the API, parameterized-test support, and engine, so it is a convenient default for ordinary Jupiter use.

<properties>
    <maven.compiler.release>8</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.junit</groupId>
            <artifactId>junit-bom</artifactId>
            <version>5.11.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.3.1</version>
        </plugin>
        <plugin>
            <artifactId>maven-failsafe-plugin</artifactId>
            <version>3.3.1</version>
        </plugin>
    </plugins>
</build>

Surefire runs tests during the test phase. Failsafe is commonly used for integration tests in the integration-test and verify phases; configuring the plugin alone does not turn a unit test into an integration test. Follow the build’s test naming and execution conventions when adding integration tests.

Gradle setup, Groovy DSL

For a Groovy build.gradle using a modern Gradle version:

plugins {
    id 'java'
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation platform('org.junit:junit-bom:5.11.0')
    testImplementation 'org.junit.jupiter:junit-jupiter'
}

test {
    useJUnitPlatform()
}

Gradle setup, Kotlin DSL

For build.gradle.kts:

plugins {
    java
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation(platform("org.junit:junit-bom:5.11.0"))
    testImplementation("org.junit.jupiter:junit-jupiter")
}

tasks.test {
    useJUnitPlatform()
}

The critical Gradle switch is useJUnitPlatform(). Without it, the test task may not discover Jupiter tests even if the API compiles. See the versioned JUnit Gradle build support guidance for filtering by tags and engines.

Choose individual modules when necessary

Artifact Role Typical use
junit-jupiter Aggregate dependency that brings in API, params, and engine. Simple default for Jupiter tests in Maven or Gradle.
junit-jupiter-api Annotations and assertion API used to compile tests. When dependencies are deliberately separated by compile/runtime role.
junit-jupiter-engine Runtime engine that executes Jupiter tests. Needed at test runtime if using the API artifact directly.
junit-jupiter-params Parameterized test features. Needed directly if using parameterized tests without the aggregate.
junit-vintage-engine Engine for legacy JUnit 3/4 tests. Only when old tests must remain runnable on the Platform.
junit-platform-launcher Launcher API used by tooling and custom launchers. Usually managed by the build or IDE integration; add explicitly when the chosen tooling requires it.

Use the same JUnit release for Platform, Jupiter, and Vintage artifacts. Prefer the BOM over manually repeating versions. Do not add the Vintage engine to a Jupiter-only project without a compatibility need.

3. Write a first Jupiter test

Jupiter test classes and methods do not need to be public. Put test sources in the build tool’s test source directory: normally src/test/java for Maven and Gradle. Here is a complete small example with a production class and its test.

// src/main/java/example/Calculator.java
package example;

public class Calculator {
    public int add(int left, int right) {
        return left + right;
    }
}

// src/test/java/example/CalculatorTest.java
package example;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

class CalculatorTest {
    private final Calculator calculator = new Calculator();

    @Test
    void addsTwoNumbers() {
        assertEquals(7, calculator.add(3, 4));
    }
}

Run it with mvn test or ./gradlew test. If Maven or Gradle reports zero tests, check the test path, class and method names, engine dependency, and Platform configuration before assuming the assertion is wrong.

Assertions, setup, and lifecycle

Use assertions to state one behavior clearly. Jupiter’s Assertions includes equality, truth, nullness, identity, exception, timeout, and grouped assertions. For example:

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;

class AccountTest {
    @Test
    void rejectsNegativeOpeningBalance() {
        IllegalArgumentException error = assertThrows(
            IllegalArgumentException.class,
            () -> new Account(-1)
        );
        assertEquals("Balance cannot be negative", error.getMessage());
    }

    @Test
    void reportsAccountSummary() {
        Account account = new Account(25);
        assertAll(
            () -> assertEquals(25, account.balance()),
            () -> assertEquals("USD", account.currency())
        );
    }
}

The example assumes an Account type with the illustrated contract. Replace it with the production API under test. assertThrows returns the exception so its message can also be checked. Use that only when the message is part of the behavior you intend to preserve.

Annotation When it runs or what it marks
@Test A regular test method.
@BeforeEach Before each test method, for fresh per-test setup.
@AfterEach After each test method, for cleanup.
@BeforeAll Once before all tests in the class; normally static under the default per-method test instance lifecycle.
@AfterAll Once after all tests in the class; normally static under the default lifecycle.
@DisplayName Sets a readable test or container name.
@Disabled Disables a test or container; include a reason and avoid using it to hide unresolved failures.
@Nested Groups related tests in a nested test class, often around a state or scenario.
@Tag Labels tests for build or IDE filtering.

By default, Jupiter creates a test class instance for each test method. This makes ordinary instance fields local to that test execution. If you switch to the per-class lifecycle to share an instance, be deliberate about mutable state and parallel execution; tests can begin depending on order or leaking state into each other.

Parameterized tests

Parameterized tests express the same behavior against multiple inputs. With the aggregate dependency they are available without another dependency:

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class SlugTest {
    @ParameterizedTest
    @CsvSource({
        "Hello World, hello-world",
        "JUnit 5, junit-5",
        "Already-clean, already-clean"
    })
    void normalizesWords(String input, String expected) {
        assertEquals(expected, Slug.from(input));
    }
}

This example expects a Slug.from implementation with those outcomes. Common sources include @ValueSource for one argument, @CsvSource for small rows, @MethodSource for generated or richer arguments, and @EnumSource for enum values. Keep the source close to the test when that improves readability; move complicated fixtures into named methods or classes.

Assumptions, tags, and dynamic tests

  • Assumptions abort the current test when a precondition is not met, such as a platform-specific condition. They are not a substitute for assertions: an unmet assumption is not a passing verification.
  • Tags such as @Tag("slow") let a build include or exclude a group. Configure filters in Maven/Gradle or the IDE, and keep CI’s selected tags visible so tests are not silently omitted.
  • Dynamic tests, created by @TestFactory, are useful when the set of test cases is generated at runtime. Prefer ordinary or parameterized tests when their structure is sufficient because their reporting and lifecycle behavior is simpler.

4. Run and configure the test suite

Start with the build tool’s normal test task. Use IDE test runners for quick feedback, but make the command-line build the reproducible check used by automation. JUnit 5.11 documentation lists first-class Platform support across common IDEs and build tools; older IDE integrations may bundle incompatible JUnit components.

# Maven
mvn test

# Gradle wrapper
./gradlew test

Useful configuration choices include:

  • Test selection: filter by class, method, package, tag, or engine using the build tool or IDE. Gradle’s Platform integration supports tag and engine filters.
  • Configuration parameters: JUnit configuration can be supplied using junit-platform.properties on the test classpath or through build system properties. For Gradle, the 5.11 guide demonstrates passing properties such as extension autodetection through the test task.
  • Parallel execution: enable only after tests are isolated, shared resources are controlled, and the exact JUnit version’s parallel execution settings are reviewed. Parallel runs can expose races in tests and production code.
  • IDE alignment: when the IDE’s bundled Platform is older than the project’s JUnit version, align the IDE launcher/engine dependencies as described by its integration guidance. Avoid mixing JUnit module versions.
  • Suites and launcher APIs: use the Platform suite engine or Launcher API when you need programmatic test discovery, custom test plans, or an explicit suite across engines. These are optional for standard Maven/Gradle tests.

Read the official JUnit 5.11 running-tests documentation for the exact configuration supported by your selected release and runner.

5. Jupiter extensions: add reusable test behavior

An extension packages behavior that would otherwise be repeated in test setup or infrastructure code: for example, creating and cleaning a resource, injecting a parameter, observing test outcomes, or conditionally enabling a test. Jupiter has a unified extension model rather than JUnit 4’s separate runner and rule mechanisms.

The JUnit 5.9 guide documents three registration styles: declarative @ExtendWith, programmatic @RegisterExtension, and automatic Java ServiceLoader. Supported annotation locations and ordering details can vary by version, so check the guide for the version your project pins. See the versioned 5.9 extension documentation.

Declarative registration with @ExtendWith

Use this when a class or method needs an extension by type:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(TemporaryDirectoryExtension.class)
class FileServiceTest {
    @Test
    void writesAReport() {
        // Extension provides the test's temporary directory.
    }
}

TemporaryDirectoryExtension is illustrative; use an actual extension available to your project or write one that implements the needed extension callback interfaces. Check whether a built-in facility already solves the problem before adding a custom extension.

Programmatic registration with @RegisterExtension

Use this when the extension needs per-test-class configuration or a field instance:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

class ApiTest {
    @RegisterExtension
    static final ServerExtension server = ServerExtension.forPort(0);

    @Test
    void respondsToHealthRequest() {
        // Use the server configured by the extension.
    }
}

Lifecycle support depends on whether the registered field is static or an instance field and on which extension callbacks it implements. A non-static extension is registered only after the test instance exists, so it cannot provide every class-level callback. Verify the exact registration and ordering rules for your Jupiter release instead of relying on field order.

Automatic registration through ServiceLoader

Automatic registration is appropriate when a project intentionally wants a classpath extension enabled globally. The extension provider JAR supplies a service declaration at META-INF/services/org.junit.jupiter.api.extension.Extension containing the extension’s fully qualified class name. This affects every applicable run using that classpath, so it can surprise developers; prefer local registration when the behavior should be visible in a single test class.

Do not make extensions hide meaningful test behavior. Keep setup, cleanup, callback ordering, and resource ownership easy to inspect. For extension authors, the 5.9 guide describes extension callbacks, extension contexts, and registration details.

6. Migrate from JUnit 4 in stages

JUnit Vintage can keep eligible JUnit 3/4 tests running on the JUnit Platform while new tests use Jupiter. That enables staged adoption; it does not automatically convert every JUnit 4 runner or rule into Jupiter behavior.

  1. Inventory the old suite. Find JUnit 4 dependencies, @RunWith runners, @Rule/@ClassRule usage, lifecycle annotations, custom runners, and test naming conventions.
  2. Choose a bridge or a direct conversion. If the old suite must keep running while you migrate, add JUnit 4 plus the matching JUnit Vintage engine under the JUnit BOM. If it is small and supported by your framework integrations, convert directly.
  3. Keep one assertion and lifecycle model per test during the transition. JUnit 4 uses org.junit.Test; Jupiter uses org.junit.jupiter.api.Test. Check imports carefully when both APIs are on the classpath.
  4. Convert infrastructure case by case. Map runners and rules to Jupiter extensions or framework-specific Jupiter support. Some legacy rules have migration support, but that support is limited and version-specific; it is not a universal adapter.
  5. Run both engines, then remove Vintage. Confirm the build discovers both old and new tests, compare expected test counts, and remove JUnit 4/Vintage only after the remaining legacy tests are converted or deliberately retired.
JUnit 4 concept Jupiter direction Migration caution
@Test org.junit.jupiter.api.Test Change imports and confirm the Jupiter engine is present.
@Before, @After @BeforeEach, @AfterEach Review inheritance and cleanup behavior.
@BeforeClass, @AfterClass @BeforeAll, @AfterAll These are static by default unless the test instance lifecycle is changed.
@RunWith Often an extension, suite, or framework-specific integration No single automatic conversion; investigate each runner’s behavior.
@Rule, @ClassRule Often a Jupiter extension or library’s Jupiter module Rule semantics and lifecycle may not map one-to-one. Check migration support for the exact rule.
JUnit 4 tests retained temporarily JUnit Vintage engine on the Platform Keep compatible JUnit 4 and Vintage dependencies aligned and remove the bridge when no longer needed.

Use the official migration guidance to verify conversions. The table is a planning aid, not a guarantee that a particular runner or rule has an equivalent.

7. Troubleshooting common JUnit 5 problems

Symptom Likely cause Fix
Build succeeds but reports zero tests. Wrong test source directory or naming pattern; missing Jupiter engine; Gradle is not using the Platform. Check src/test/java, the test class naming pattern expected by the runner, the junit-jupiter dependency, and Gradle’s useJUnitPlatform().
@Test is unresolved. JUnit 4 import used with Jupiter code, or the Jupiter API is absent from test compile dependencies. Use org.junit.jupiter.api.Test and add the aggregate or API dependency with test scope.
Tests compile but Jupiter tests do not execute. API is present but engine is missing at runtime, or the build/IDE uses an incompatible runner. Add junit-jupiter or the matching runtime engine; align Platform and Jupiter versions. For Gradle, enable the Platform test task.
JUnit 4 tests disappear after switching the runner. JUnit 4 tests are not executed by the Jupiter engine. Add JUnit 4 and the matching junit-vintage-engine if the old tests must remain during migration.
IDE run works but CI fails, or the reverse. Different JDK, dependency graph, test filters, or bundled IDE launcher. Compare Java versions, resolved JUnit modules, tags, naming filters, and test task. Reproduce with the project wrapper command.
NoSuchMethodError or linkage errors in test runtime. JUnit Platform, Jupiter, Vintage, or launcher artifacts from incompatible versions. Use the JUnit BOM and inspect the resolved dependency tree; remove stale explicit versions and IDE-bundled conflicts where applicable.
Parameterized test annotations cannot be resolved. junit-jupiter-params is missing when using individual modules. Use junit-jupiter or add the matching params artifact.
A test unexpectedly runs more than once or is skipped. Repeated/parameterized test semantics, tags, assumptions, or conditional extensions. Inspect the test report’s invocation names, active tags, assumptions, and execution conditions; distinguish aborted tests from successful assertions.
Shared state makes tests order-dependent. Mutable static state, per-class lifecycle, or shared resources leak between tests. Reset state, isolate resources, avoid ordering assumptions, and enable parallel execution only after isolation is established.
Migration fails around a rule or runner. Legacy behavior has no automatic Jupiter equivalent. Identify what the rule/runner actually does, find its Jupiter integration or replace it with a focused extension, then verify callback and cleanup behavior.

8. Performance, reliability, and cost

JUnit itself is a test framework; the major time and reliability costs usually come from the code under test, fixture setup, external services, and build environment. Keep unit tests deterministic and small, avoid unnecessary network or process startup, and reuse expensive infrastructure only with clear ownership and cleanup.

  • Startup and discovery: the engine and classpath must be available to discover tests. A coherent BOM-managed dependency graph avoids runtime linkage surprises. Avoid adding Vintage or extra engines unless needed.
  • Parallelism: it can reduce elapsed time when tests are independent and the machine has capacity, but shared files, ports, databases, and static state can cause intermittent failures. Measure in the project’s own build and isolate before enabling it.
  • Retries: automatic retries can conceal flakiness. Diagnose and remove nondeterminism instead of treating a retry as proof of reliability.
  • External dependencies: isolate live network, browser, database, and cloud calls behind controlled fixtures or integration-test tasks. Set bounded timeouts and clean up resources even when assertions fail.
  • Cost: JUnit is an open-source framework; build compute, hosted test environments, paid IDEs, and external services may have separate costs. The dossier provides no adoption, effectiveness, or market-share statistic, so none is implied here.

9. Capture browser output used in a test or bug report

JUnit does not provide browser automation or website screenshot capture by itself. If a test workflow needs a browser image—for example, to attach a page snapshot to a failure report—choose and configure browser automation separately. Keep assertions in the test suite and treat screenshot capture as supporting evidence unless you have implemented explicit visual comparison logic.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. A single request can return an image or PDF; this is a separate capture service, not a JUnit extension. Use it when you need a screenshot artifact without configuring a browser runner:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the ScreenshotNeo API documentation for parameters and response details. ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before the shot; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots, and 1,000 screenshots a month are free with no card; paid plans start at $5 for 3,000. Sign up for 1,000 free screenshots a month, with no card required.

10. Frequently asked questions

Is JUnit Jupiter the same thing as JUnit 5?

No. Jupiter is the authoring and extension model plus its engine. JUnit 5 is the broader modular generation that also includes the Platform and Vintage.

Do I need JUnit Vintage for a new project?

No. Add Vintage only if the build still needs to execute legacy JUnit 3/4 tests on the Platform.

Can JUnit 5 test code compiled with an older JDK?

The JUnit 5.11 guide says JUnit 5 needs Java 8 or newer at runtime and can test code compiled with earlier JDK versions.

Should I use the newest JUnit major version for a new project?

Choose based on your supported Java version, build and framework compatibility, and the release line your team intends to maintain. This article’s examples are deliberately pinned to JUnit 5.11.0; consult current official documentation before selecting a different line.

Do JUnit 5 tests need public classes and methods?

No. Jupiter permits package-private test classes and test methods, as shown in the examples.

Can I run Jupiter and JUnit 4 tests in the same build?

Yes, when the Platform runner and dependencies include Jupiter and Vintage, along with JUnit 4 for the legacy tests. Confirm both engines are discovered by the actual build and IDE configurations.

Further reading