JUnit · 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 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
- Add a test class
NoDiscountTestchecking 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
- 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.
- Run every test class whose name contains
Discountwith a single-Dtestpattern.
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.