JUnit Maven Dependency: junit-bom, Surefire and pom.xml

Add the JUnit Maven dependency with junit-bom 6.1.3, junit-jupiter and Surefire 3.6.0, run one test, add Failsafe and fix version errors in pom.xml.

The pom.xml imports junit-bom 6.1.3 and declares junit-jupiter in test scope, which resolves to junit-jupiter-api, junit-jupiter-params, junit-jupiter-engine and the platform jars; Surefire runs *Test classes during mvn test and Failsafe runs *IT classes during mvn verify

The JUnit Maven dependency is org.junit.jupiter:junit-jupiter in the test scope, with its version taken from the imported junit-bom, and Maven Surefire 3.x runs the tests it finds during mvn test. The single junit-jupiter artifact brings the API we write tests with, the parameterized test support and the engine that runs the tests.

We add this setup to every new Maven project that has tests, and we revisit it when a build reports zero tests, fails with a NoClassDefFoundError from JUnit, or moves from JUnit 4 or JUnit 5 to the current JUnit 6.

The following example is the smallest pom.xml part that runs JUnit tests with JUnit 6.1.3 and Maven Surefire 3.6.0 on Java 25.

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.junit</groupId>
      <artifactId>junit-bom</artifactId>
      <version>6.1.3</version>                 <!-- one version for every JUnit artifact -->
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>      <!-- API + params + engine, no version -->
    <scope>test</scope>
  </dependency>
</dependencies>

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.6.0</version>                  <!-- JUnit 6 needs 3.0.0 or later -->
    </plugin>
  </plugins>
</build>
[INFO] Running com.howtodoinjava.junit.shipping.ShippingCalculatorTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.380 s -- in com.howtodoinjava.junit.shipping.ShippingCalculatorTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Notice that only the junit-bom carries a version. The BOM sets the version of junit-jupiter and of every other JUnit artifact we add later, so they always match. For projects still on JUnit 5, the same three blocks work with the BOM version 5.14.4, because the Jupiter API keeps its package org.junit.jupiter.api in both versions.

Next, we map the JUnit artifacts to the jobs they do, build a complete pom.xml with Failsafe, and run one test from the command line. The later sections reproduce the version errors that break JUnit builds, run JUnit 4 tests next to JUnit 6 tests, and set the JUnit version in a Spring Boot project.

1. Which JUnit Artifacts Does a Maven Project Need?

JUnit 6 is not one jar. It consists of the JUnit Platform, which launches test engines for build tools and IDEs, JUnit Jupiter, which is the API and engine for our tests, and JUnit Vintage, an engine for old JUnit 3 and JUnit 4 tests. Since JUnit 6.0, all three share one version number, so junit-jupiter 6.1.3 pulls junit-platform-engine 6.1.3 as well.

The pom.xml imports junit-bom 6.1.3 and declares junit-jupiter in test scope, which resolves to junit-jupiter-api, junit-jupiter-params, junit-jupiter-engine and the platform jars; Surefire runs *Test classes during mvn test and Failsafe runs *IT classes during mvn verify
One junit-jupiter dependency brings the API, the parameterized tests and the engine, and the BOM keeps every version at 6.1.3

Most projects need only the first row of the table. The other artifacts serve one feature each, and with the BOM imported, we add them without a version.

Maven artifactWhat it containsWhen we add it
org.junit.jupiter:junit-jupiterAggregate of junit-jupiter-api, junit-jupiter-params and junit-jupiter-engineAlways (recommended)
org.junit.jupiter:junit-jupiter-api@Test, lifecycle annotations, Assertions, AssumptionsOnly when we list the modules one by one
org.junit.jupiter:junit-jupiter-params@ParameterizedTest and the argument sourcesIncluded in junit-jupiter
org.junit.jupiter:junit-jupiter-engineThe test engine that runs Jupiter testsIncluded in junit-jupiter, needed at test runtime
org.junit.platform:junit-platform-suite@Suite, @SelectPackages and the suite engineFor test suites
org.junit.platform:junit-platform-launcherThe API that IDEs and build tools call to start testsSurefire adds it itself; Gradle needs it declared
org.junit.platform:junit-platform-reportingListeners that write the Open Test Reporting XMLFor JUnit XML reports
org.junit.vintage:junit-vintage-engineThe engine for JUnit 3 and JUnit 4 tests (deprecated in JUnit 6)While old JUnit 4 tests still exist
org.junit:junit-bomNo code, only version numbersAlways, in dependencyManagement

We declare the aggregate junit-jupiter artifact instead of junit-jupiter-api plus junit-jupiter-engine. It gives us parameterized tests as well, and there is no second dependency to keep in sync. The dependency:tree goal shows what the single dependency resolves to, all at version 6.1.3 except the small helper libraries.

[INFO] com.howtodoinjava:junit-maven-dependency:jar:1.0.0
[INFO] \- org.junit.jupiter:junit-jupiter:jar:6.1.3:test
[INFO]    +- org.junit.jupiter:junit-jupiter-api:jar:6.1.3:test
[INFO]    |  +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO]    |  +- org.junit.platform:junit-platform-commons:jar:6.1.3:test
[INFO]    |  +- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO]    |  \- org.jspecify:jspecify:jar:1.0.0:test
[INFO]    +- org.junit.jupiter:junit-jupiter-params:jar:6.1.3:test
[INFO]    \- org.junit.jupiter:junit-jupiter-engine:jar:6.1.3:test
[INFO]       \- org.junit.platform:junit-platform-engine:jar:6.1.3:test

All JUnit artifacts use the test scope. Maven puts them on the classpath when it compiles and runs the tests, and leaves them out of the packaged jar.

2. A Complete pom.xml With Surefire and Failsafe

A typical service runs fast unit tests in the test phase and slower integration tests, such as tests against a database, later in the build. The Maven Surefire plugin runs the first group, and the Maven Failsafe plugin runs the second group in the integration-test phase and fails the build in verify. Both plugins launch the JUnit Platform, so they use the same JUnit dependencies.

By default, Surefire picks the classes whose names start with Test or end with Test, Tests or TestCase. Failsafe picks the classes whose names start with IT or end with IT or ITCase. The following example is the full pom.xml of a small shipping-cost module that compiles for Java 25.

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

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.junit</groupId>
      <artifactId>junit-bom</artifactId>
      <version>6.1.3</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>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>3.16.0</version>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.6.0</version>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-failsafe-plugin</artifactId>
      <version>3.6.0</version>
      <executions>
        <execution>
          <goals>
            <goal>integration-test</goal>
            <goal>verify</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

The unit test class ShippingCalculatorTest has two @Test methods and one parameterized test with three rows, so Surefire reports 5 tests. The parameterized test needs no extra dependency, because junit-jupiter-params comes with junit-jupiter.

class ShippingCalculatorTest {

  private final ShippingCalculator calculator = new ShippingCalculator();

  @Test
  void freeShippingFromFiftyDollars() {
    assertEquals(new BigDecimal("0.00"), calculator.cost(new BigDecimal("50.00"), 3.0));
  }

  @ParameterizedTest
  @CsvSource({"0.4, 6.49", "1.0, 6.49", "2.5, 9.49"})
  void chargesPerStartedKilogram(double weightKg, BigDecimal expected) {
    assertEquals(expected, calculator.cost(new BigDecimal("20.00"), weightKg));
  }

  @Test
  void rejectsZeroWeight() {
    assertThrows(IllegalArgumentException.class, () -> calculator.cost(BigDecimal.TEN, 0));
  }
}

The integration test ShippingCalculatorIT uses the same JUnit annotations. Only its class name tells Maven to run it with Failsafe instead of Surefire. We run both groups with mvn verify.

[INFO] --- surefire:3.6.0:test (default-test) @ junit-maven-dependency ---
[INFO] Running com.howtodoinjava.junit.shipping.ShippingCalculatorTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- failsafe:3.6.0:integration-test (default) @ junit-maven-dependency ---
[INFO] Running com.howtodoinjava.junit.shipping.ShippingCalculatorIT
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- failsafe:3.6.0:verify (default) @ junit-maven-dependency ---
[INFO] BUILD SUCCESS

Notice the order in the log. Surefire runs during test, Maven builds the jar, and Failsafe runs the integration test after the package phase. The same pom works for coverage too, and the JaCoCo code coverage article adds the JaCoCo plugin to it.

3. Running One Test Class or One Test Method

While we fix a failing test, running the whole suite on every change wastes time. Surefire reads the test system property, which takes a class name, a class and method separated by #, or a comma-separated list with wildcards. The property overrides the include and exclude patterns of the plugin.

mvn test -Dtest=ShippingCalculatorTest
mvn test -Dtest=ShippingCalculatorTest#rejectsZeroWeight
mvn test -Dtest='Shipping*Test'
mvn verify -Dit.test=ShippingCalculatorIT

The second command runs one method, so Surefire reports a single test.

[INFO] Running com.howtodoinjava.junit.shipping.ShippingCalculatorTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Failsafe uses its own property, it.test, so a -Dtest filter does not change which integration tests run. The Maven test commands article lists more filter forms, such as skipping tests with -DskipTests.

4. JUnit Version Errors in Maven Builds

Most broken JUnit setups in Maven come from a version mismatch. Either the Surefire plugin is too old for the JUnit release, or two JUnit artifacts end up with different versions on the test classpath. We reproduce both cases in the next two sections, because the error messages point to the cause once we know them.

4.1. Surefire Older Than 3.0.0 Fails With NoClassDefFoundError

JUnit 6 removed the support for Surefire and Failsafe versions below 3.0.0. When an old parent POM or a company template still pins Surefire 2.22.2, the forked test JVM cannot start the JUnit 6 Platform and the build fails before any test runs.

[INFO] --- surefire:2.22.2:test (default-cli) @ junit-maven-dependency ---
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE
[ERROR] There was an error in the forked process
[ERROR] java.lang.NoClassDefFoundError: org/junit/platform/commons/util/PreconditionViolationException

The fix is to declare Surefire 3.6.0 in our own pom.xml. If we do not declare any version, Maven takes the version bound in its default lifecycle. Maven 3.9.16 binds Surefire 3.5.4, which works with JUnit 6, but Maven releases before 3.9.0 bind Surefire 2.12.4. So the declared version keeps the build independent of the Maven installation on each machine.

[INFO] --- surefire:3.5.4:test (default-test) @ junit-maven-dependency ---

4.2. Mixed JUnit Versions Fail Test Discovery

The Jupiter engine needs the Platform classes of its own release. When we list the artifacts one by one with versions and one of them is older, for example junit-jupiter-api 6.1.3 next to junit-jupiter-engine 5.14.4, the engine calls a method that does not exist in the newer Platform jar. Surefire reports zero tests and a failed build.

SEVERE: TestEngine with ID 'junit-jupiter' encountered a critical issue during test discovery:
    Cause: java.lang.NoSuchMethodError: 'java.util.stream.Collector org.junit.platform.commons.util.CollectionUtils.toUnmodifiableList()'
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE

The same error shows up when a library or a parent POM pulls an older JUnit artifact into the build. The junit-bom prevents both cases, because Maven takes the version of every org.junit artifact from the BOM, including the transitive ones. In the broken build, mvn dependency:tree shows the odd version at once.

[INFO] +- org.junit.jupiter:junit-jupiter-api:jar:6.1.3:test
[INFO] |  \- org.junit.platform:junit-platform-commons:jar:6.1.3:test
[INFO] +- org.junit.jupiter:junit-jupiter-params:jar:6.1.3:test
[INFO] \- org.junit.jupiter:junit-jupiter-engine:jar:5.14.4:test
[INFO]    \- org.junit.platform:junit-platform-engine:jar:1.14.4:test

Notice the junit-platform-engine version 1.14.4 in the last line. JUnit 5 used 1.x numbers for the Platform, so any 1.x Platform artifact in a JUnit 6 build is a leftover from JUnit 5.

5. Running JUnit 4 Tests With the Vintage Engine

A large project rarely migrates all its JUnit 4 tests in one go. The JUnit Vintage engine runs tests written with org.junit.Test on the JUnit Platform, so Surefire runs JUnit 4 and JUnit 6 tests in the same build. We add JUnit 4.13.2, the last JUnit 4 release, and the junit-vintage-engine, whose version comes from the BOM.

<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>junit</groupId>
  <artifactId>junit</artifactId>
  <version>4.13.2</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.junit.vintage</groupId>
  <artifactId>junit-vintage-engine</artifactId>
  <scope>test</scope>
</dependency>

The class LegacyDiscountTest uses the JUnit 4 annotation, and DiscountServiceTest uses the Jupiter one. Surefire finds both engines on the test classpath and runs both classes.

public class LegacyDiscountTest {

  @Test                                   // org.junit.Test (JUnit 4)
  public void newCustomerGetsNoDiscount() {
    assertEquals(0, new DiscountService().percentFor(0));
  }
}
[INFO] Running com.howtodoinjava.junit.vintage.DiscountServiceTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.124 s -- in com.howtodoinjava.junit.vintage.DiscountServiceTest
[INFO] Running com.howtodoinjava.junit.vintage.LegacyDiscountTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.041 s -- in com.howtodoinjava.junit.vintage.LegacyDiscountTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0

The Vintage engine is deprecated in JUnit 6, so it is a bridge for the time of the migration, not a permanent setup. We remove the two extra dependencies once the last JUnit 4 test is gone. The JUnit 5 vs JUnit 4 comparison lists the annotations that change during that migration.

6. Setting the JUnit Version in a Spring Boot Project

In a Spring Boot application, the spring-boot-starter-test starter already contains junit-jupiter, and the Spring Boot parent imports the junit-bom for us. So we add no JUnit dependency and no BOM of our own. Spring Boot 4.1.1 manages JUnit 6.0.3, which we can read in the junit-jupiter.version property of spring-boot-dependencies.

When we need a newer JUnit release in a Spring Boot project, we set the junit-jupiter.version property in our pom.xml, for example to 6.1.3, instead of declaring JUnit artifacts with versions. Spring Boot imports the BOM with this property, so all JUnit artifacts change together. The JUnit with Spring Boot article covers the starter and the version override in detail.

7. JUnit 5 or JUnit 6 in the pom.xml?

JUnit 6 keeps the Jupiter API of JUnit 5, so @Test, Assertions and the extensions stay the same. For the pom.xml, the differences are the Java baseline, the version numbers and the oldest Surefire version that works.

JUnit 5 (5.14.4)JUnit 6 (6.1.3)
Minimum Java version817
Version numbersJupiter and Vintage 5.x, Platform 1.x6.x for all artifacts
Lowest Surefire / Failsafe2.22.03.0.0
JUnit Vintage engineSupportedDeprecated
Spring Boot line3.x4.x
Maven setupjunit-bom 5.14.4 + junit-jupiterjunit-bom 6.1.3 + junit-jupiter

A project on Java 8 to 16 or on Spring Boot 3 stays on JUnit 5.14.4. A project on Java 17 or later moves to JUnit 6 by changing the BOM version, as long as Surefire is 3.0.0 or newer. The JUnit 5 vs JUnit 6 comparison lists the API removals that matter for that step, and the JUnit Gradle dependency article shows the same setup for Gradle builds.

8. JUnit Maven Dependency FAQs

A first JUnit setup in Maven raises doubts about artifacts, scopes, versions and the launcher.

8.1. Do We Need Both junit-jupiter-api and junit-jupiter-engine?

No. The single junit-jupiter artifact contains both, plus junit-jupiter-params. Declaring the aggregate also puts the engine on the test classpath, which IDEs need when they run the tests without Surefire.

8.2. Why Does mvn test Run Zero Tests?

The cause is one of three things. The test classes do not match the Surefire naming patterns (*Test, Test*, *Tests, *TestCase) or are not in src/test/java, the methods use the JUnit 4 org.junit.Test without the Vintage engine, or the JUnit versions are mixed as in section 4.2.

8.3. Which Scope Should JUnit Dependencies Use?

The test scope. Maven compiles and runs the tests with JUnit on the classpath and keeps JUnit out of the jar or war we ship.

8.4. What Is the Latest JUnit Version for Maven?

JUnit 6.1.3 is the latest release, and the matching BOM is org.junit:junit-bom:6.1.3. The last JUnit 5 release is 5.14.4, and the last JUnit 4 release is 4.13.2.

8.5. Do We Need the junit-platform-launcher Dependency With Maven?

No. Surefire and Failsafe 3.x add a launcher that matches the JUnit version on the test classpath. We add junit-platform-launcher only when our own code starts tests through the Launcher API.

9. Conclusion

A JUnit Maven setup needs three things. The junit-bom goes in dependencyManagement, the junit-jupiter dependency goes in the test scope, and the Surefire plugin is declared with version 3.0.0 or newer, today 3.6.0. Failsafe uses the same JUnit dependencies for the integration test classes.

When a build breaks, the cause is either a Surefire version below 3.0.0, which fails with a NoClassDefFoundError, or mixed JUnit versions, which fail test discovery with a NoSuchMethodError. The BOM prevents the second problem. In Spring Boot projects, the Spring Boot parent manages the version, and the JUnit tutorial continues with writing the tests themselves.

10. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.