Skip to main content
JUnit · lesson 1 of 7
JUnit beginner Lesson 1 of 7

Your First JUnit 6 Test with Maven

Write and run your first JUnit 6 test with Maven: the pom.xml you need, real Surefire output, reading a failing test, and running a single test method.

By SkillByExample · Published

junit java maven testing

JUnit is the standard test framework for Java. You write a test as an ordinary method marked @Test, and a build tool runs every such method and reports which ones failed. This lesson sets up a Maven project for JUnit 6, the current line, then runs it, breaks it and reads the result.

The project and the one command

A Maven project keeps production code in src/main/java and tests in src/test/java. The pom.xml below is everything JUnit needs; later lessons only add to it.

<!-- File: pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>junit-demo</artifactId>
  <version>1.0</version>

  <properties>
    <maven.compiler.release>21</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>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.6.0</version>
      </plugin>
    </plugins>
  </build>
</project>

Three parts matter. The junit-bom import pins every JUnit module to 6.1.3, so the junit-jupiter dependency needs no version of its own. <scope>test</scope> keeps JUnit off the production class path. And Surefire, the plugin that runs tests during mvn test, is pinned to 3.6.0, because JUnit 6 needs Surefire 3 or newer and an unpinned build may pick an older one.

JUnit 6 needs Java 17 or later. Every run on this site uses Java 21 and Maven inside Docker, so nothing has to be installed except Docker. Define this alias once in your shell:

alias mvn='docker run --rm -v "$PWD":/w -v m2:/root/.m2 -w /w maven:3.10.0-eclipse-temurin-21-noble mvn'

From then on, mvn runs in a container that sees the current directory as /w. The named m2 volume keeps downloaded jars between runs. If Maven and JDK 17+ are installed locally, drop the alias; the commands are the same.

A first passing test

Here is a small class to test, and a test class beside it under src/test/java.

// File: src/main/java/PriceCalculator.java
public class PriceCalculator {
    public int total(int unitPrice, int quantity) {
        return unitPrice * quantity;
    }

    public int withDiscount(int amount, int percent) {
        return amount - amount * percent / 100;
    }
}
// File: src/test/java/PriceCalculatorTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class PriceCalculatorTest {
    PriceCalculator calc = new PriceCalculator();

    @Test
    void totalMultipliesPriceByQuantity() {
        assertEquals(1500, calc.total(500, 3));
    }

    @Test
    void tenPercentOff() {
        assertEquals(900, calc.withDiscount(1000, 10));
    }

    @Test
    void zeroQuantityCostsNothing() {
        assertEquals(0, calc.total(500, 0));
    }
}
$ mvn -B -ntp test
[INFO] Scanning for projects...
[INFO] Loaded 24096 auto-discovered prefixes for remote repository central (prefixes-central.txt)
[INFO] 
[INFO] -----------------------< com.example:junit-demo >-----------------------
[INFO] Building junit-demo 1.0
[INFO]   from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO] 
[INFO] --- resources:3.5.0:resources (default-resources) @ junit-demo ---
[INFO] skip non existing resourceDirectory /w/src/main/resources
[INFO] 
[INFO] --- compiler:3.16.0:compile (default-compile) @ junit-demo ---
[INFO] Loaded 74 auto-discovered prefixes for remote repository apache.snapshots (prefixes-apache.snapshots.txt)
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 1 source file with javac [debug release 21] to target/classes
[INFO] 
[INFO] --- resources:3.5.0:testResources (default-testResources) @ junit-demo ---
[INFO] skip non existing resourceDirectory /w/src/test/resources
[INFO] 
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ junit-demo ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 1 source file with javac [debug release 21] to target/test-classes
[INFO] 
[INFO] --- surefire:3.6.0:test (default-test) @ junit-demo ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO] 
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running PriceCalculatorTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.184 s -- in PriceCalculatorTest
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  16.710 s
[INFO] Finished at: 2026-10-15T16:03:06Z
[INFO] ------------------------------------------------------------------------

Maven compiled src/main/java, compiled src/test/java, then handed the test classes to Surefire. Surefire printed Using auto detected provider ... JUnitPlatformProvider: it saw JUnit on the test class path and launched the JUnit Platform, which found the three @Test methods. The line to look for in any build log is Tests run: 3, Failures: 0, Errors: 0, Skipped: 0. Time elapsed and Total time change from run to run; everything else is the same each time.

Notice what the test class did not need: it isn’t public, its methods aren’t public, and it has no main. Surefire picks up classes whose names end in Test (or start with Test, or end in Tests or TestCase), and JUnit calls every method annotated with @Test.

Reading a failing test

A second test class with a wrong expected value: 25% off 1000 is 750, but the test says 760.

// File: src/test/java/DiscountTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class DiscountTest {
    @Test
    void quarterOff() {
        PriceCalculator calc = new PriceCalculator();
        assertEquals(760, calc.withDiscount(1000, 25));
    }
}
$ mvn -B -ntp test | grep -E "Tests run|FAIL|expected|DiscountTest.java"
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.236 s <<< FAILURE! -- in DiscountTest
[ERROR] DiscountTest.quarterOff -- Time elapsed: 0.143 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <760> but was: <750>
	at DiscountTest.quarterOff(DiscountTest.java:9)
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.010 s -- in PriceCalculatorTest
[ERROR]   DiscountTest.quarterOff:9 expected: <760> but was: <750>
[ERROR] Tests run: 4, Failures: 1, Errors: 0, Skipped: 0
[ERROR] BUILD FAILURE

Read the output from the bottom up. The summary says 4 tests ran and 1 failed, and the build failed. The line above it, DiscountTest.quarterOff:9 expected: <760> but was: <750>, is the one to read first: the class, the method, the line number in the test file, then the expected and actual values. assertEquals always takes the expected value first; swap the arguments and the message reads backwards. The at DiscountTest.quarterOff(DiscountTest.java:9) stack line points to the same assertion.

A failing test is Failures; a test that throws an unexpected exception is counted under Errors instead. Either one fails the build, so nothing after the test phase (such as package) runs.

Running one class or one method

-Dtest tells Surefire which tests to run. It takes a class name, Class#method, or a pattern.

$ mvn -B -ntp test -Dtest=PriceCalculatorTest | grep -E "Tests run:|BUILD"
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.181 s -- in PriceCalculatorTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ mvn -B -ntp test -Dtest='PriceCalculatorTest#total*' | grep -E "Tests run:|BUILD"
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.166 s -- in PriceCalculatorTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

The first run skipped DiscountTest, so the build passed. The second ran only the methods whose names start with total: one of the three. The quotes stop the shell from expanding # and *. This is how you rerun the one failing test while you fix it, instead of the whole suite.

Fix the expected value in DiscountTest to 750 before going on.

// File: src/test/java/DiscountTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class DiscountTest {
    @Test
    void quarterOff() {
        PriceCalculator calc = new PriceCalculator();
        assertEquals(750, calc.withDiscount(1000, 25));
    }
}

Adding Mockito, and its JDK 21 warning

Most tests also need Mockito to fake a class’s dependencies. Add it to <dependencies>:

<!-- pom.xml: inside <dependencies> -->
    <dependency>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-junit-jupiter</artifactId>
      <version>5.24.0</version>
      <scope>test</scope>
    </dependency>

A Checkout depends on a TaxRates lookup; the test replaces the lookup with a mock.

// File: src/main/java/Checkout.java
public class Checkout {
    public interface TaxRates { int percentFor(String country); }

    private final TaxRates rates;

    public Checkout(TaxRates rates) { this.rates = rates; }

    public int withTax(int amount, String country) {
        return amount + amount * rates.percentFor(country) / 100;
    }
}
// File: src/test/java/CheckoutTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;

class CheckoutTest {
    @Test
    void addsTaxForCountry() {
        Checkout.TaxRates rates = mock(Checkout.TaxRates.class);
        when(rates.percentFor("DE")).thenReturn(19);

        assertEquals(1190, new Checkout(rates).withTax(1000, "DE"));
        verify(rates).percentFor("DE");
    }
}
$ mvn -B -ntp test -Dtest=CheckoutTest 2>&1 | grep -E "Mockito is|WARNING: A Java|Dynamic loading|Tests run: 1,"
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
WARNING: A Java agent has been loaded dynamically (/root/.m2/repository/net/bytebuddy/byte-buddy-agent/1.17.7/byte-buddy-agent-1.17.7.jar)
WARNING: Dynamic loading of agents will be disallowed by default in a future release
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.780 s -- in CheckoutTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

The test passed, but the run printed warnings from two sources. To fake a class, Mockito attaches a Java agent to the running JVM (its “inline mock maker”). JDK 21 still allows that, but warns that dynamically loaded agents will be disallowed by default in a future release, and Mockito adds its own note asking you to load it as an agent at startup instead. The 2>&1 is there because those warnings go to standard error, not standard output.

The fix is to pass Mockito to the test JVM with -javaagent. The maven-dependency-plugin’s properties goal exposes every dependency’s jar path as a property, and Surefire’s argLine uses it:

<!-- pom.xml: inside <plugins> -->
      <plugin>
        <artifactId>maven-dependency-plugin</artifactId>
        <version>3.11.0</version>
        <executions>
          <execution>
            <goals><goal>properties</goal></goals>
          </execution>
        </executions>
      </plugin>
<!-- pom.xml: inside the surefire <plugin> -->
        <configuration>
          <argLine>-javaagent:${org.mockito:mockito-core:jar} -Xshare:off</argLine>
        </configuration>
$ mvn -B -ntp test -Dtest=CheckoutTest 2>&1 | grep -E "Mockito is|WARNING: A Java|Dynamic loading|Tests run: 1,"
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.410 s -- in CheckoutTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

Mockito’s note and the JVM’s agent warnings are gone, because the agent is now loaded when the JVM starts. -Xshare:off silences one more JVM note (“Sharing is only supported for boot loader classes”) that an agent on the boot class path otherwise triggers. Keep these two snippets in any Maven project that uses Mockito; the later lessons here don’t use mocks, so they leave them out.

JUnit 5 and JUnit 6

JUnit 6.0 (2025) kept the JUnit 5 programming model: the same org.junit.jupiter.api package, the same @Test and Assertions. What changed is around it: Java 17 is the minimum, Platform and Jupiter now share one version number (6.1.3 for both, where JUnit 5 had Platform 1.x and Jupiter 5.x), Surefire must be 3.0 or newer, and the JUnit 4 bridge is deprecated. The Java-track lessons Unit Testing with JUnit 5 and Mockito and Advanced Testing use JUnit 5.x; their code runs unchanged on 6. Migrating from JUnit 4 to JUnit 6 covers the breaking changes in detail.

Practice

  1. Add a test class NoDiscountTest checking that a 0% discount leaves 1000 unchanged, and run only that class.
Solution
// File: src/test/java/NoDiscountTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class NoDiscountTest {
    @Test
    void zeroPercentChangesNothing() {
        assertEquals(1000, new PriceCalculator().withDiscount(1000, 0));
    }
}
$ mvn -B -ntp test -Dtest=NoDiscountTest | grep -E "Tests run:|BUILD"
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.161 s -- in NoDiscountTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
  1. Write a test that throws an exception instead of failing an assertion, and find where Surefire counts it.
Solution
// File: src/test/java/ErrorVsFailureTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class ErrorVsFailureTest {
    @Test
    void perItemPriceOfEmptyOrder() {
        PriceCalculator calc = new PriceCalculator();
        int quantity = 0;
        assertEquals(0, calc.total(500, quantity) / quantity);
    }
}
$ mvn -B -ntp test -Dtest=ErrorVsFailureTest | grep -E "Tests run:|ErrorVsFailureTest\.|BUILD"
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.205 s <<< FAILURE! -- in ErrorVsFailureTest
[ERROR] ErrorVsFailureTest.perItemPriceOfEmptyOrder -- Time elapsed: 0.115 s <<< ERROR!
	at ErrorVsFailureTest.perItemPriceOfEmptyOrder(ErrorVsFailureTest.java:10)
[ERROR]   ErrorVsFailureTest.perItemPriceOfEmptyOrder:10 Arithmetic / by zero
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
[ERROR] BUILD FAILURE

The ArithmeticException is not an assertion failure, so it is counted under Errors, not Failures. The build fails either way.

  1. Run every test class whose name contains Discount with a single -Dtest pattern.
Solution
$ mvn -B -ntp test -Dtest='*Discount*' | grep -E "Tests run:|BUILD"
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.228 s -- in DiscountTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.017 s -- in NoDiscountTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

*Discount* matched DiscountTest and NoDiscountTest but not PriceCalculatorTest, CheckoutTest or ErrorVsFailureTest.

Frequently Asked Questions

What do I need in pom.xml to run JUnit 6 tests with Maven?
Import org.junit:junit-bom 6.1.3 in dependencyManagement, add org.junit.jupiter:junit-jupiter with test scope, and pin maven-surefire-plugin to a 3.x version such as 3.6.0. The BOM keeps every JUnit module on the same version, and Surefire 3 detects the JUnit Platform automatically. JUnit 6 also needs Java 17 or later.
How do I run a single JUnit test method with Maven?
Pass -Dtest with the class and method separated by a hash, for example mvn test -Dtest='PriceCalculatorTest#totalMultipliesPriceByQuantity'. Wildcards work too, so -Dtest='*Discount*' runs every class whose name contains Discount. Quote the value so the shell does not expand the hash or asterisk.
What is the difference between Failures and Errors in Surefire output?
A failure is an assertion that did not hold, such as assertEquals finding a different value. An error is an unexpected exception thrown by the test, such as an ArithmeticException or NullPointerException. Surefire counts them separately, but either one fails the Maven build.
Is JUnit 6 different from JUnit 5?
Tests are written the same way: JUnit 6 keeps the org.junit.jupiter.api package, @Test and the Assertions class. JUnit 6 raises the minimum to Java 17, gives the Platform and Jupiter modules one shared version number, requires Surefire 3 or newer and deprecates the Vintage engine for JUnit 4 tests. Most JUnit 5 test code runs on JUnit 6 unchanged.
Why does Mockito print a warning about a Java agent on JDK 21?
Mockito's inline mock maker attaches a Java agent to the running JVM, and JDK 21 warns that dynamically loaded agents will be disallowed by default in a future release. The fix is to load Mockito at JVM startup by adding -javaagent pointing at the mockito-core jar to Surefire's argLine. The maven-dependency-plugin properties goal provides that jar path.