How do I create a custom JUnit extension?

JUnit 5 provides a powerful extension model that lets you hook into the test lifecycle. Here’s a comprehensive guide to creating custom extensions.

1. Choose the Right Extension Interface

JUnit 5 offers several extension interfaces depending on what you want to do:

Interface Purpose
BeforeAllCallback / AfterAllCallback Run code before/after all tests in a class
BeforeEachCallback / AfterEachCallback Run code before/after each test
BeforeTestExecutionCallback / AfterTestExecutionCallback Wrap the actual test method execution
ParameterResolver Inject parameters into test methods
TestExecutionExceptionHandler Handle exceptions thrown by tests
TestWatcher Observe test results (passed, failed, skipped)
InvocationInterceptor Intercept method invocations

2. Basic Example: A Timing Extension

Here’s an extension that measures test execution time:

package com.example.testing;

import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store;

import java.lang.reflect.Method;

public class TimingExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

    private static final Namespace NAMESPACE = Namespace.create(TimingExtension.class);
    private static final String START_TIME = "start_time";

    @Override
    public void beforeTestExecution(ExtensionContext context) {
        getStore(context).put(START_TIME, System.currentTimeMillis());
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        Method testMethod = context.getRequiredTestMethod();
        long startTime = getStore(context).remove(START_TIME, long.class);
        long duration = System.currentTimeMillis() - startTime;

        System.out.printf("Method [%s] took %d ms.%n", testMethod.getName(), duration);
    }

    private Store getStore(ExtensionContext context) {
        return context.getStore(NAMESPACE);
    }
}

3. Parameter Resolver Example

Injecting custom parameters into test methods:

package com.example.testing;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;

import java.util.UUID;

public class RandomUuidParameterResolver implements ParameterResolver {

    @Override
    public boolean supportsParameter(ParameterContext parameterContext,
                                     ExtensionContext extensionContext)
            throws ParameterResolutionException {
        return parameterContext.getParameter().getType() == UUID.class;
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext,
                                   ExtensionContext extensionContext)
            throws ParameterResolutionException {
        return UUID.randomUUID();
    }
}

4. TestWatcher Example

Observing test outcomes:

package com.example.testing;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestWatcher;

import java.util.Optional;

public class TestResultLogger implements TestWatcher {

    @Override
    public void testSuccessful(ExtensionContext context) {
        System.out.println("✔ " + context.getDisplayName() + " passed");
    }

    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        System.out.println("✘ " + context.getDisplayName() + " failed: " + cause.getMessage());
    }

    @Override
    public void testAborted(ExtensionContext context, Throwable cause) {
        System.out.println("⚠ " + context.getDisplayName() + " aborted");
    }

    @Override
    public void testDisabled(ExtensionContext context, Optional<String> reason) {
        System.out.println("⊘ " + context.getDisplayName() + " disabled: " + reason.orElse("no reason"));
    }
}

5. Registering the Extension

There are three ways to register your extension:

a) Declarative with @ExtendWith

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

import java.util.UUID;

@ExtendWith({TimingExtension.class, RandomUuidParameterResolver.class})
class MyServiceTest {

    @Test
    void shouldDoSomething(UUID uniqueId) {
        System.out.println("Test running with ID: " + uniqueId);
    }
}

b) Programmatic with @RegisterExtension

Useful when the extension needs configuration:

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

class MyServiceTest {

    @RegisterExtension
    static TimingExtension timingExtension = new TimingExtension();

    @Test
    void shouldDoSomething() {
        // ...
    }
}

c) Automatic Registration via ServiceLoader

Create the file src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension containing:

com.example.testing.TimingExtension

Then enable auto-detection in junit-platform.properties:

junit.jupiter.extensions.autodetection.enabled=true

6. Creating a Custom Annotation (Meta-Annotation)

You can bundle extensions into a custom annotation:

package com.example.testing;

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

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith({TimingExtension.class, TestResultLogger.class})
public @interface IntegrationTest {
}

Usage:

@IntegrationTest
class MyIntegrationTest {
    @Test
    void something() { /* ... */ }
}

7. Sharing State with the Store

The ExtensionContext.Store lets you share state between callbacks safely across parallel tests. Always use a unique Namespace to avoid collisions:

Namespace ns = Namespace.create(MyExtension.class, context.getRequiredTestMethod());
Store store = context.getStore(ns);
store.put("key", value);

Key Tips

  • Prefer composition — implement multiple interfaces on the same class if the concerns are related.
  • Use Store for state rather than instance fields, because JUnit may create new instances or use the extension across parallel tests.
  • Respect lifecycle order — class-level callbacks fire before method-level ones.
  • Make extensions stateless when possible to be safe in parallel execution.

How do I use JUnit extensions with @ExtendWith?

JUnit Jupiter (JUnit 5) provides a powerful extension model that lets you plug custom behavior into the test lifecycle. Instead of the old JUnit 4 approach of using @RunWith (which allowed only one runner) or @Rule, JUnit 5 uses @ExtendWith — and you can apply as many extensions as you want.

Let’s break this down step by step.


What Is an Extension?

An extension is a class that hooks into the JUnit Jupiter test lifecycle to add behavior such as:

  • setting up resources before tests
  • cleaning up after tests
  • injecting parameters into test methods
  • intercepting test execution
  • handling exceptions
  • conditionally disabling tests

Extensions implement one or more interfaces from the org.junit.jupiter.api.extension package.


The @ExtendWith Annotation

The @ExtendWith annotation registers an extension with a test class or method.

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

@ExtendWith(MyExtension.class)
class MyTest {
    // ...
}

You can apply it at the class level, the method level, or even on custom annotations.


Example 1: Using a Built-in Extension (Mockito)

One of the most common uses of @ExtendWith is enabling Mockito’s annotation-based mocking:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    void findsUserById() {
        when(userRepository.findById(1L))
            .thenReturn(new User(1L, "Alice"));

        User user = userService.findUser(1L);

        assertNotNull(user);
    }
}

The MockitoExtension handles @Mock and @InjectMocks for you automatically.


Example 2: Applying Multiple Extensions

You can register multiple extensions in a single annotation:

import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@ExtendWith({SpringExtension.class, MockitoExtension.class})
class OrderServiceTest {
    // ...
}

Or, stack them as separate annotations (JUnit 5 supports @ExtendWith as a repeatable annotation):

@ExtendWith(SpringExtension.class)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    // ...
}

Example 3: Writing Your Own Extension

Let’s write a simple extension that logs how long each test takes.

Step 1 — Create the Extension

import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store;

public class TimingExtension
        implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

    private static final Namespace NAMESPACE = Namespace.create(TimingExtension.class);
    private static final String START_TIME = "startTime";

    @Override
    public void beforeTestExecution(ExtensionContext context) {
        getStore(context).put(START_TIME, System.currentTimeMillis());
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        long startTime = getStore(context).remove(START_TIME, long.class);
        long duration = System.currentTimeMillis() - startTime;

        System.out.printf("Test '%s' took %d ms%n",
                context.getRequiredTestMethod().getName(), duration);
    }

    private Store getStore(ExtensionContext context) {
        return context.getStore(NAMESPACE);
    }
}

Step 2 — Use the Extension

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

@ExtendWith(TimingExtension.class)
class SlowServiceTest {

    @Test
    void slowOperation() throws InterruptedException {
        Thread.sleep(100);
    }
}

When you run this test, you’ll see something like:

Test 'slowOperation' took 103 ms

Extension Points You Can Implement

Extensions implement one or more of these interfaces:

Interface Purpose
BeforeAllCallback Runs once before all tests in a class
AfterAllCallback Runs once after all tests in a class
BeforeEachCallback Runs before each test method
AfterEachCallback Runs after each test method
BeforeTestExecutionCallback Runs immediately before test execution
AfterTestExecutionCallback Runs immediately after test execution
ParameterResolver Injects parameters into test methods
TestExecutionExceptionHandler Handles exceptions thrown during tests
ExecutionCondition Conditionally enables/disables tests
TestInstancePostProcessor Post-processes test instances after creation

Example 4: Parameter Injection with ParameterResolver

A common pattern is injecting objects directly into test methods:

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;

public class RandomIntExtension implements ParameterResolver {

    @Override
    public boolean supportsParameter(ParameterContext parameterContext,
                                     ExtensionContext extensionContext) {
        return parameterContext.getParameter().getType() == Integer.class;
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext,
                                   ExtensionContext extensionContext) {
        return (int) (Math.random() * 100);
    }
}

Usage:

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

@ExtendWith(RandomIntExtension.class)
class RandomTest {

    @Test
    void receivesRandomInt(Integer randomValue) {
        System.out.println("Random value: " + randomValue);
    }
}

JUnit will call resolveParameter automatically and pass the result into the test.


Example 5: Applying @ExtendWith at the Method Level

You don’t always need to apply extensions to the whole class:

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

class MixedTest {

    @Test
    void plainTest() {
        // no extensions
    }

    @Test
    @ExtendWith(TimingExtension.class)
    void timedTest() {
        // only this test uses the extension
    }
}

Example 6: Composing Custom Meta-Annotations

You can bundle @ExtendWith inside your own annotation to keep test classes clean:

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

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith({TimingExtension.class, MockitoExtension.class})
public @interface IntegrationTest {
}

Then in your tests:

@IntegrationTest
class OrderServiceTest {
    // both TimingExtension and MockitoExtension are active
}

This is a great way to standardize test setup across a codebase.


Alternative: Programmatic Registration with @RegisterExtension

If your extension needs configuration at runtime, use @RegisterExtension on a field instead of @ExtendWith:

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

class ConfigurableTest {

    @RegisterExtension
    static final MyConfigurableExtension extension =
            MyConfigurableExtension.builder()
                    .withTimeout(500)
                    .build();
}

Use @ExtendWith when the extension has no state to configure. Use @RegisterExtension when you need to configure the extension instance.


Summary

Concept Description
@ExtendWith(SomeExtension.class) Registers an extension declaratively
Applied to a class Extension is active for all tests in that class
Applied to a method Extension is active only for that test method
Multiple extensions Use @ExtendWith({A.class, B.class}) or stack them
Custom meta-annotations Bundle multiple extensions under one annotation
@RegisterExtension Alternative for stateful/configurable extensions

Rule of thumb:

Use @ExtendWith when you want to plug in behavior declaratively.
Use @RegisterExtension when the extension needs runtime configuration.

Extensions are the recommended replacement for JUnit 4’s @RunWith and @Rule — and unlike @RunWith, you can compose as many of them as you need.

How do I generate test reports for JUnit tests?

Generating test reports for JUnit tests helps you understand test results, track failures, analyze trends, and share results with your team or CI/CD pipeline. Here’s a comprehensive guide covering the most common approaches.

1. Using Maven Surefire Plugin (Default Reports)

The Maven Surefire Plugin automatically generates test reports when you run tests.

Basic Configuration

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.2.5</version>
        </plugin>
    </plugins>
</build>

Running Tests and Generating Reports

mvn test

After execution, reports are generated in:

  • target/surefire-reports/ — Contains .txt and .xml files for each test class.

2. Generating HTML Reports with Maven Surefire Report Plugin

For human-readable HTML reports, use the Surefire Report Plugin.

Configuration

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.2.5</version>
        </plugin>
    </plugins>
</build>

<reporting>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-report-plugin</artifactId>
            <version>3.2.5</version>
        </plugin>
    </plugins>
</reporting>

Generate the HTML Report

mvn surefire-report:report

Or generate a full site report:

mvn site

The HTML report will be available at:

  • target/site/surefire-report.html

3. Using Gradle for Test Reports

Gradle generates HTML and XML test reports automatically when you run tests.

Enable Reports in build.gradle

plugins {
    id 'java'
}

test {
    useJUnitPlatform()

    reports {
        html.required = true
        junitXml.required = true
    }

    // Optional: customize output location
    // reports.html.outputLocation = layout.buildDirectory.dir("customReports/html")
}

Run Tests

gradle test

Reports are generated at:

  • HTML: build/reports/tests/test/index.html
  • XML: build/test-results/test/

4. Using JUnit 5 with the Console Launcher

For standalone test execution, JUnit 5 provides a console launcher that can produce XML reports.

java -jar junit-platform-console-standalone.jar \
    --class-path target/classes:target/test-classes \
    --scan-class-path \
    --reports-dir=target/junit-reports

5. Using Allure for Rich Reports

Allure provides beautiful, interactive test reports with detailed insights.

Maven Configuration

<dependencies>
    <dependency>
        <groupId>io.qameta.allure</groupId>
        <artifactId>allure-junit5</artifactId>
        <version>2.27.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.2.5</version>
            <configuration>
                <argLine>
                    -javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/1.9.22/aspectjweaver-1.9.22.jar"
                </argLine>
            </configuration>
            <dependencies>
                <dependency>
                    <groupId>org.aspectj</groupId>
                    <artifactId>aspectjweaver</artifactId>
                    <version>1.9.22</version>
                </dependency>
            </dependencies>
        </plugin>
    </plugins>
</build>

Annotate Your Tests

import io.qameta.allure.Description;
import io.qameta.allure.Epic;
import io.qameta.allure.Feature;
import org.junit.jupiter.api.Test;

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

@Epic("Calculator")
@Feature("Basic Operations")
class CalculatorTest {

    @Test
    @Description("Verify that adding two positive numbers returns their sum")
    void addTwoNumbers() {
        Calculator calc = new Calculator();
        assertEquals(5, calc.add(2, 3));
    }
}

Generate and View Report

mvn test
allure serve target/allure-results

6. Using IntelliJ IDEA Built-in Test Reports

IntelliJ IDEA has excellent built-in reporting. When you run tests through the IDE:

  1. Run your tests (right-click test class → Run).
  2. In the Run tool window, click the Export Test Results icon (or use Ctrl+Alt+E / Cmd+Option+E to view options).
  3. Choose format: HTML, XML, or Custom.

You can also import external test results:

  • RunImport Tests from File…

7. Publishing Reports in CI/CD

GitHub Actions Example

name: Java CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK
        uses: actions/setup-java@v4
        with:
          java-version: '25'
          distribution: 'temurin'

      - name: Run tests
        run: mvn test

      - name: Publish Test Report
        uses: mikepenz/action-junit-report@v4
        if: always()
        with:
          report_paths: '**/target/surefire-reports/TEST-*.xml'

      - name: Upload Test Results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: target/surefire-reports/

Jenkins Example

Add a post-build step to publish JUnit test results:

post {
    always {
        junit 'target/surefire-reports/*.xml'
    }
}

Summary of Report Locations

Tool Command Report Location
Maven Surefire mvn test target/surefire-reports/
Surefire Report Plugin mvn surefire-report:report target/site/surefire-report.html
Gradle gradle test build/reports/tests/test/index.html
Allure allure serve target/allure-results Interactive browser view
IntelliJ IDEA Run tests in IDE Export via Run tool window

Best Practices

  1. Always publish XML reports in CI/CD — they are machine-readable and widely supported.
  2. Use HTML reports for local development and code reviews.
  3. Consider Allure for larger projects where detailed insights (trends, categories, steps) are valuable.
  4. Fail the build on test failures to prevent broken code from being deployed.
  5. Archive test reports as CI artifacts for historical reference.

How do I run JUnit tests in CI/CD pipelines?

Running JUnit tests automatically in a CI/CD pipeline ensures every commit is verified before it reaches production. The core idea is simple: your CI server checks out the code, builds the project, and runs mvn test (or gradle test) — the same commands you use locally.

Below are practical setups for the most common CI/CD platforms.

1. Prerequisites

Before wiring up CI/CD, make sure your project already:

  • Uses Maven Surefire or Gradle to run JUnit 5 tests
  • Follows standard test naming conventions (*Test.java, *Tests.java)
  • Runs mvn test or ./gradlew test successfully on your local machine

If mvn test doesn’t work locally, it won’t work in CI either.

2. GitHub Actions

Create .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout source
        uses: actions/checkout@v4

      - name: Set up JDK 25
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '25'
          cache: maven

      - name: Run tests with Maven
        run: mvn -B test

      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: surefire-reports
          path: '**/target/surefire-reports/*.xml'

Key points:

  • -B runs Maven in batch mode (no interactive prompts)
  • cache: maven speeds up subsequent runs
  • if: always() uploads reports even if tests fail — critical for debugging

3. GitLab CI

Create .gitlab-ci.yml:

image: maven:3.9-eclipse-temurin-25

variables:
  MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository"

cache:
  paths:
    - .m2/repository

stages:
  - test

unit-tests:
  stage: test
  script:
    - mvn -B test
  artifacts:
    when: always
    reports:
      junit:
        - '**/target/surefire-reports/TEST-*.xml'
    paths:
      - '**/target/surefire-reports/'
    expire_in: 1 week

GitLab automatically renders JUnit XML reports in the merge request UI when you declare them under reports.junit.

4. Jenkins (Declarative Pipeline)

Create a Jenkinsfile in the project root:

pipeline {
    agent any

    tools {
        jdk 'jdk-25'
        maven 'maven-3.9'
    }

    stages {
        stage('Checkout') {
            steps { checkout scm }
        }

        stage('Test') {
            steps {
                sh 'mvn -B test'
            }
        }
    }

    post {
        always {
            junit '**/target/surefire-reports/*.xml'
        }
    }
}

The junit step in post.always publishes results to Jenkins’ Test Result view, whether the build passed or failed.

5. CircleCI

Create .circleci/config.yml:

version: 2.1

jobs:
  test:
    docker:
      - image: cimg/openjdk:25.0
    steps:
      - checkout
      - restore_cache:
          keys:
            - v1-deps-{{ checksum "pom.xml" }}
      - run:
          name: Run tests
          command: mvn -B test
      - save_cache:
          paths:
            - ~/.m2
          key: v1-deps-{{ checksum "pom.xml" }}
      - store_test_results:
          path: target/surefire-reports

workflows:
  version: 2
  build-and-test:
    jobs:
      - test

6. Failing the Build Correctly

By default, both Maven and Gradle exit with a non-zero status when tests fail, which causes the CI job to fail. Do not override that:

<!-- ❌ Never do this in CI -->
<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <testFailureIgnore>true</testFailureIgnore>
    </configuration>
</plugin>

If tests are ignored, broken code sneaks into main.

7. Publishing Test Reports

Surefire always produces XML reports here:

target/surefire-reports/TEST-*.xml

Every CI platform can read this format:

Platform How to publish
GitHub Actions actions/upload-artifact or a test-reporter action
GitLab CI artifacts.reports.junit
Jenkins junit '**/target/surefire-reports/*.xml'
CircleCI store_test_results
Azure Pipelines PublishTestResults@2 task with testResultsFormat: JUnit

8. Running Selective Tests in CI

For faster pipelines, split tests by JUnit tag:

mvn -B test -Dgroups=fast

Then run a separate slower stage for integration tests:

mvn -B verify -Dgroups=slow

This lets you get quick feedback on PRs while still running the full suite before merging.

9. Common Pitfalls

  • Different Java version in CI — always pin the JDK explicitly (java-version: '25').
  • Time-zone / locale issues — set TZ=UTC and LANG=en_US.UTF-8 in the job environment.
  • Flaky tests — never mask them with retries; fix them or tag them as @Tag("flaky") and quarantine.
  • No dependency cache — cache ~/.m2/repository (Maven) or ~/.gradle/caches (Gradle) or your pipeline will be needlessly slow.

Summary

To run JUnit tests in CI/CD:

  1. Make sure mvn test (or ./gradlew test) works locally.
  2. Add a CI configuration file for your platform (ci.yml, .gitlab-ci.yml, Jenkinsfile, etc.).
  3. Pin the JDK version and cache dependencies.
  4. Run mvn -B test on every push and pull request.
  5. Publish the Surefire XML reports so failures are visible in the CI UI.
  6. Let non-zero exit codes fail the build — never ignore test failures.

Once this pipeline is in place, every commit is automatically verified, and broken tests block merges before they can reach production.

How do I configure Gradle to run JUnit tests?

Gradle has excellent built-in support for running JUnit tests. Configuring it properly ensures your tests are discovered, executed, and reported correctly. Let’s walk through the setup step by step.


The Short Answer

To run JUnit 5 (Jupiter) tests with Gradle, you need to:

  1. Add the JUnit Jupiter dependency to testImplementation.
  2. Enable the JUnit Platform in the test task with useJUnitPlatform().

Minimal Groovy DSL example:

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4'
}

test {
    useJUnitPlatform()
}

That’s it — Gradle will now discover and run all JUnit 5 tests in src/test/java.


1. Understand the Standard Project Layout

Gradle follows a conventional directory layout:

my-project/
├── build.gradle
├── src/
│   ├── main/
│   │   └── java/
│   │       └── com/example/Calculator.java
│   └── test/
│       └── java/
│           └── com/example/CalculatorTest.java

Gradle automatically:

  • Compiles src/main/java into production classes.
  • Compiles src/test/java into test classes.
  • Runs everything under src/test/java when you execute the test task.

You do not need to tell Gradle where the tests are, as long as you follow this layout.


2. Apply the Java Plugin

The Java plugin provides the test task and the testImplementation configuration.

plugins {
    id 'java'
}

For a Kotlin DSL (build.gradle.kts) project:

plugins {
    java
}

3. Add JUnit 5 Dependencies

For a modern JUnit 5 (Jupiter) setup:

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4'
}

The junit-jupiter aggregate dependency brings in both:

  • junit-jupiter-api — the annotations and assertions you write with (@Test, assertEquals, etc.).
  • junit-jupiter-engine — the engine that actually runs your Jupiter tests.

If you also need to run older JUnit 4 tests, add the Vintage engine:

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4'
    testRuntimeOnly    'org.junit.vintage:junit-vintage-engine:5.13.4'
}

4. Enable the JUnit Platform

By default, Gradle uses the older JUnit 4 test runner. You must explicitly enable the JUnit Platform for JUnit 5:

test {
    useJUnitPlatform()
}

Kotlin DSL equivalent:

tasks.test {
    useJUnitPlatform()
}

Without this line, JUnit 5 tests will simply not run — a very common gotcha.


5. A Complete Minimal build.gradle

plugins {
    id 'java'
}

group = 'com.example'
version = '1.0.0'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4'
}

test {
    useJUnitPlatform()
}

Run tests from the command line:

./gradlew test

6. Using the Kotlin DSL (build.gradle.kts)

If you prefer Kotlin DSL:

plugins {
    java
}

group = "com.example"
version = "1.0.0"

repositories {
    mavenCentral()
}

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.13.4")
}

tasks.test {
    useJUnitPlatform()
}

7. Filtering Tests by Tags

If you use @Tag on your tests, you can include or exclude them:

test {
    useJUnitPlatform {
        includeTags 'fast'
        excludeTags 'slow', 'integration'
    }
}

Kotlin DSL:

tasks.test {
    useJUnitPlatform {
        includeTags("fast")
        excludeTags("slow", "integration")
    }
}

This is invaluable for separating unit tests from slower integration tests.


8. Improve Test Logging

By default, Gradle prints only a short summary. You will often want more detail:

test {
    useJUnitPlatform()

    testLogging {
        events "passed", "failed", "skipped"
        showStandardStreams = true
        exceptionFormat = "full"
    }
}

This makes failures much easier to diagnose.


9. Configure Parallel Execution (Optional)

You can allow Gradle to run tests in parallel across multiple JVM forks:

test {
    useJUnitPlatform()
    maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1
}

Each fork runs in a separate JVM, which increases speed but also memory usage.


10. Set JVM Options and System Properties

Sometimes tests need specific JVM arguments or system properties:

test {
    useJUnitPlatform()

    jvmArgs '-Xmx1g', '-XX:+UseG1GC'

    systemProperty 'user.timezone', 'UTC'
    systemProperty 'file.encoding', 'UTC-8'
}

11. Enable Test Reports

Gradle generates HTML and XML test reports automatically:

build/reports/tests/test/index.html      ← Human-readable HTML report
build/test-results/test/*.xml            ← Machine-readable XML report

You can customize them:

test {
    useJUnitPlatform()

    reports {
        html.required = true
        junitXml.required = true
    }
}

The XML reports are what CI servers (Jenkins, GitHub Actions, etc.) consume.


12. Separate Integration Tests (Advanced)

For larger projects, you often want to separate unit tests from integration tests. A clean way to do this is with a dedicated source set and task:

sourceSets {
    integrationTest {
        java.srcDir 'src/integrationTest/java'
        resources.srcDir 'src/integrationTest/resources'
        compileClasspath += sourceSets.main.output + configurations.testRuntimeClasspath
        runtimeClasspath += output + compileClasspath
    }
}

tasks.register('integrationTest', Test) {
    description = 'Runs integration tests.'
    group = 'verification'

    testClassesDirs = sourceSets.integrationTest.output.classesDirs
    classpath = sourceSets.integrationTest.runtimeClasspath

    useJUnitPlatform()
    shouldRunAfter test
}

check.dependsOn integrationTest

Now:

./gradlew test              # Runs unit tests only
./gradlew integrationTest   # Runs integration tests only
./gradlew check             # Runs everything

13. Running Tests from the Command Line

Gradle supports many useful test-running options out of the box:

# Run all tests
./gradlew test

# Run tests in one class
./gradlew test --tests "com.example.CalculatorTest"

# Run a single test method
./gradlew test --tests "com.example.CalculatorTest.addsTwoNumbers"

# Run tests matching a pattern
./gradlew test --tests "*Calculator*"

# Continuous mode — rerun tests when files change
./gradlew test --continuous

# Skip tests entirely (not recommended)
./gradlew build -x test

14. Common Pitfalls

Problem Cause Fix
Tests are compiled but not run Missing useJUnitPlatform() Add it to the test block
@Test not recognized JUnit 4 imports used Use org.junit.jupiter.api.Test
Tests run twice Both Jupiter and Vintage engines present unintentionally Remove Vintage if you don’t need it
NoClassDefFoundError for engines Only added junit-jupiter-api Use the aggregate junit-jupiter or add junit-jupiter-engine
Tests not found Files outside src/test/java Move them or add a custom source set

15. Verifying Your Setup

Create a simple test to confirm everything works:

package com.example;

import org.junit.jupiter.api.Test;

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

class SanityCheckTest {

    @Test
    void oneEqualsOne() {
        assertEquals(1, 1);
    }
}

Run:

./gradlew test

You should see:

BUILD SUCCESSFUL

And a report at build/reports/tests/test/index.html.


Final Summary

To configure Gradle to run JUnit tests:

  1. Apply the java plugin.
  2. Add junit-jupiter to testImplementation.
  3. Call useJUnitPlatform() in the test task.
  4. (Optional) Configure logging, tags, parallelism, and reports.
  5. (Optional) Add the Vintage engine if you still have JUnit 4 tests.

A modern, well-configured build.gradle for JUnit 5 looks like this:

plugins {
    id 'java'
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4'
}

test {
    useJUnitPlatform()
    testLogging {
        events "passed", "failed", "skipped"
        exceptionFormat = "full"
    }
}

With that in place, ./gradlew test will discover, run, and report on your JUnit tests — everything you need for a clean, reliable testing workflow.