Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Java  Module 14 – Testing, build tools and code quality

Unit testing with JUnit 6

Write your first JUnit 6 tests in Java: @Test methods and assertions, a Maven run with Surefire reports, and failures that say what broke.

  • Intermediate
  • 22 minutes
  • Examples run with Java 25.0.4 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 22 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Write JUnit 6 tests with @Test and assertions
  • Run tests with Maven and read the reports
  • Name and structure tests so that failures explain themselves

Before you start

A unit test is a few lines of code that run one small part of your program, such as a single method, with inputs you picked, and check that it returned exactly the answer you expected. JUnit is the standard framework for writing and running such tests in Java, and JUnit 6 is its current major version: the examples here run on JUnit 6.1.3 and JDK 25, and JUnit 6 itself needs at least Java 17 to run. You write a test as a method marked @Test, call your code inside it, and compare the result with an assertion such as assertEquals(expected, actual). A build tool such as Maven, your IDE or JUnit’s console launcher finds every test, runs it and reports which passed and which failed, with a message for each failure. Tests run in seconds, so you can run them after every change and learn at once when a change breaks something that used to work. This lesson writes tests for a small billing utility, runs them with Maven, plants a bug to see a failure, and shows how to name and structure tests so that a failure explains itself.

Your first test in one file

Since Java 25 a program can be a single compact source file, and JUnit 6.1 added an experimental module, org.junit.start, that turns such a file into a test program. JUnit.run() finds the methods marked @Test in the file and runs them:

QuickTests.java Java · quick/QuickTests.java
// A whole test program in one compact source file (JDK 25). JUnit.run() comes from the org.junit.start module,
// which JUnit 6.1 added as an experimental feature: it finds the @Test methods of this file and runs them.
import module org.junit.start;

void main() {
    JUnit.run();
}

long toPaise(long rupees, int paise) {
    return rupees * 100 + paise;
}

@Test
void rupeesAndPaiseBecomePaise() {
    Assertions.assertEquals(11_850, toPaise(118, 50));
}

@Test
void zeroRupeesIsZeroPaise() {
    Assertions.assertEquals(0, toPaise(0, 0));
}
The jars JUnit needs, then the two tests Python · quick_session.py
from session import from_repository, run

# JUnit 6.1.3 and what it needs, as modular jars from the Maven local repository (the files Maven downloads).
from_repository(
    "quick/lib",
    "org.junit:junit-start:6.1.3",
    "org.junit.jupiter:junit-jupiter:6.1.3",
    "org.junit.jupiter:junit-jupiter-api:6.1.3",
    "org.junit.jupiter:junit-jupiter-params:6.1.3",
    "org.junit.jupiter:junit-jupiter-engine:6.1.3",
    "org.junit.platform:junit-platform-commons:6.1.3",
    "org.junit.platform:junit-platform-engine:6.1.3",
    "org.junit.platform:junit-platform-launcher:6.1.3",
    "org.junit.platform:junit-platform-console:6.1.3",
    "org.junit.platform:junit-platform-reporting:6.1.3",
    "org.opentest4j.reporting:open-test-reporting-tooling-spi:0.2.5",
    "org.opentest4j:opentest4j:1.3.0",
    "org.apiguardian:apiguardian-api:1.1.2",
    "org.jspecify:jspecify:1.0.0",
)
run("""
cd quick
ls lib
java --module-path lib --add-modules org.junit.start QuickTests.java
""")

Output

$ cd quick
$ ls lib
apiguardian-api-1.1.2.jar
jspecify-1.0.0.jar
junit-jupiter-6.1.3.jar
junit-jupiter-api-6.1.3.jar
junit-jupiter-engine-6.1.3.jar
junit-jupiter-params-6.1.3.jar
junit-platform-commons-6.1.3.jar
junit-platform-console-6.1.3.jar
junit-platform-engine-6.1.3.jar
junit-platform-launcher-6.1.3.jar
junit-platform-reporting-6.1.3.jar
junit-start-6.1.3.jar
open-test-reporting-tooling-spi-0.2.5.jar
opentest4j-1.3.0.jar
$ java --module-path lib --add-modules org.junit.start QuickTests.java
╷
└─ JUnit Jupiter ✔
   └─ QuickTests ✔
      ├─ rupeesAndPaiseBecomePaise() ✔
      └─ zeroRupeesIsZeroPaise() ✔

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 quick_session.py

The lib folder holds JUnit 6.1.3 and the small libraries it depends on, the same files a build tool downloads. The run printed a tree: the engine, JUnit Jupiter, then the class, then one line per test, each with a tick. Both tests compared a value the code computed with the value they expected, and both values matched.

A test method returns nothing and takes no parameters here; it passes when it finishes and fails when an assertion inside it fails or it throws an exception nobody expected. One compact file is the quickest way to try an idea. Real projects keep their code and their tests in separate files and let a build tool run them, which is the rest of this lesson.

What runs your tests

JUnit 6 is three parts. The JUnit Platform launches tests: it finds them, runs them and reports results to whoever asked. JUnit Jupiter is what you write tests with, the @Test annotation, the assertions and the rest of the API, and it brings a test engine that runs those tests on the platform. JUnit Vintage is an engine that runs old JUnit 3 and JUnit 4 tests; it is deprecated and meant only for projects that are still moving their tests over.

A test run: Maven, an IDE, the console launcher or JUnit.run() start the JUnit Platform, whose engines run each test on a new instance.What starts a runJUnit Platformfinds tests, runs them,reports resultsTest enginesFor each @Test methodmvn test(Maven Surefire)Your IDE'sRun buttonConsole launcher(one jar)JUnit.run()in a compact fileJUnit Jupiter@Test methodsJUnit VintageJUnit 3 and 4 tests(deprecated)a new instanceof the test classthe test method runspassed, or failedwith a message

What happens when you run JUnit 6 tests

Text description of the diagram

The diagram reads from top to bottom.

  1. Four things can start a run: mvn test (Maven's Surefire plugin), the Run button of an IDE, the console launcher jar, or JUnit.run() inside a compact source file.
  2. All of them hand the run to the JUnit Platform, which finds the tests, runs them and reports the results.
  3. The platform finds tests through test engines: JUnit Jupiter runs @Test methods, and JUnit Vintage, which is deprecated, runs old JUnit 3 and JUnit 4 tests.
  4. For each @Test method, JUnit Jupiter creates a new instance of the test class, runs the method, and records it as passed, or as failed with a message.

Maven, Gradle, IntelliJ IDEA, Eclipse, Visual Studio Code and the console launcher all start the same platform, so a test behaves the same whichever of them runs it. Before each test method, Jupiter creates a new instance of the test class, so a value that one test stores in a field never leaks into the next. The order of the tests is not random: the JUnit documentation calls it “deterministic but intentionally nonobvious”, so the same suite runs in the same order every time, and no test should rely on another having run first.

A test class for a real utility

GstSplit takes a price that already includes the tax and splits it into the base price and the tax, all in paise, because a whole number of paise is exact, where a double amount picks up rounding errors. Its rule: the base is the price divided by (1 + rate), rounded to the nearest paisa; the tax is what remains; and the tax is shared between two halves, with an odd paisa going to the second.

India-specific: the two halves of GST

For a sale within one state, India charges GST as two taxes, central GST (CGST) and state GST (SGST), as the CBIC’s frequently asked questions on GST explain, and GstSplit gives each of them half of the tax. At 18 %, a price of ₹118.00 holds ₹100.00 of base price and ₹18.00 of tax. How a real invoice computes and rounds each tax is set by the seller’s accounting rules; the example fixes one rule so that its tests can check it.

GST Calculator (India) Add GST to a price or take it out, with the CGST and SGST halves, to check a split by hand.
The utility and its tests

gst-split/src/main/java/com/example/billing/GstSplit.java

package com.example.billing;

/**
 * Splits a GST-inclusive price into the base price and the tax, all in paise (one rupee is 100 paise).
 *
 * <p>The rule of this example: the base is the price divided by (1 + rate), rounded to the nearest paisa; the tax is
 * what remains; the tax is shared between CGST and SGST, and an odd paisa goes to SGST.
 */
public final class GstSplit {

    /** The parts of one price, in paise. */
    public record Split(long base, long cgst, long sgst) {
        public long tax() {
            return cgst + sgst;
        }
    }

    private GstSplit() {
    }

    public static Split ofInclusive(long inclusivePaise, int ratePercent) {
        if (inclusivePaise < 0) {
            throw new IllegalArgumentException("price must not be negative: " + inclusivePaise);
        }
        if (ratePercent < 0 || ratePercent > 100) {
            throw new IllegalArgumentException("rate must be 0 to 100 per cent: " + ratePercent);
        }
        long divisor = 100L + ratePercent;
        long base = (inclusivePaise * 100 + divisor / 2) / divisor; // adding half the divisor rounds to the nearest paisa
        long tax = inclusivePaise - base;
        long cgst = tax / 2;
        return new Split(base, cgst, tax - cgst);
    }
}

gst-split/src/test/java/com/example/billing/GstSplitTest.java

package com.example.billing;

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

import org.junit.jupiter.api.Test;

class GstSplitTest {

    @Test
    void splitsAPriceThatDividesExactly() {
        // Arrange: ₹118.00 at 18 % GST
        long price = 118_00;

        // Act
        GstSplit.Split split = GstSplit.ofInclusive(price, 18);

        // Assert: ₹100.00 base, ₹9.00 CGST, ₹9.00 SGST
        assertEquals(100_00, split.base());
        assertEquals(9_00, split.cgst());
        assertEquals(9_00, split.sgst());
    }

    @Test
    void roundsTheBaseToTheNearestPaisa() {
        // 100 paise / 1.18 = 84.75... paise, which rounds to 85
        assertEquals(85, GstSplit.ofInclusive(100, 18).base());
    }

    @Test
    void givesTheOddPaisaOfTheTaxToSgst() {
        GstSplit.Split split = GstSplit.ofInclusive(100, 18); // 15 paise of tax
        assertEquals(7, split.cgst());
        assertEquals(8, split.sgst());
    }

    @Test
    void rejectsANegativePrice() {
        IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> GstSplit.ofInclusive(-1, 18));
        assertEquals("price must not be negative: -1", e.getMessage());
    }
}

Each test follows the same three steps, arrange, act, assert: set up the input, call the code once, then check the result. The first test spells the steps out in comments; the others are short enough not to need them. The test class sits in the same package as the class it tests, under src/test/java instead of src/main/java, so it can call package-private methods, and neither the class nor its methods are public: JUnit requires only that they are not private.

The four tests check four separate promises: a price that divides exactly, rounding, the odd paisa, and a price that must be refused. assertThrows passes only if the code throws the exception named, or a subclass of it, and returns that exception, so the last test can also check its message. assertEquals(100_00, split.base()) puts the expected value first. The digit grouping in 100_00 is only for the reader: Java accepts underscores between digits and ignores them.

Running the tests with Maven

A Maven project describes itself in pom.xml. This one declares one dependency, junit-jupiter 6.1.3 in the test scope, so it is available to the tests but never shipped with the code, and pins the three plugins the build uses. JUnit’s user guide gives Maven Surefire 3.0.0 as the oldest version that runs JUnit 6; this project uses 3.6.0.

gst-split/pom.xml XML · gst-split/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- A minimal Maven project with JUnit 6: the code in src/main/java, its tests in src/test/java. -->
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.example</groupId>
  <artifactId>gst-split</artifactId>
  <version>1.0</version>

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

  <dependencies>
    <!-- The JUnit Jupiter API to write tests, its engine to run them, and parameterised tests. -->
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>6.1.3</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <!-- Pinned, so that every machine builds with the same plugins. -->
      <plugin>
        <artifactId>maven-resources-plugin</artifactId>
        <version>3.5.0</version>
      </plugin>
      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.16.0</version>
      </plugin>
      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.6.0</version>
      </plugin>
    </plugins>
  </build>
</project>
mvn test on the project Python · maven_session.py
from session import run

run("""
cd gst-split
mvn test
""")

Output

$ cd gst-split
$ mvn test
[INFO] Scanning for projects...
[INFO] 
[INFO] -----------------------< com.example:gst-split >------------------------
[INFO] Building gst-split 1.0
[INFO]   from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO] Loaded 24063 auto-discovered prefixes for remote repository central (prefixes-central.txt)
[INFO] 
[INFO] --- resources:3.5.0:resources (default-resources) @ gst-split ---
[INFO] skip non existing resourceDirectory gst-split/src/main/resources
[INFO] 
[INFO] --- compiler:3.16.0:compile (default-compile) @ gst-split ---
[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 25] to target/classes
[INFO] 
[INFO] --- resources:3.5.0:testResources (default-testResources) @ gst-split ---
[INFO] skip non existing resourceDirectory gst-split/src/test/resources
[INFO] 
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ gst-split ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 1 source file with javac [debug release 25] to target/test-classes
[INFO] 
[INFO] --- surefire:3.6.0:test (default-test) @ gst-split ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO] 
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.example.billing.GstSplitTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: … s -- in com.example.billing.GstSplitTest
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: … s
[INFO] Finished at: …
[INFO] ------------------------------------------------------------------------

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 maven_session.py

mvn test runs every phase of Maven’s default lifecycle up to test, and the output names each step: copying resources, compiling the code into target/classes, compiling the tests into target/test-classes, and finally the Surefire plugin, which ran the four tests. The lines to look for are Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 and BUILD SUCCESS. Surefire counts a failed assertion as a failure and any other exception thrown by a test as an error, so Errors usually means the code crashed rather than computed a wrong value.

Surefire does not ask JUnit which classes hold tests; it picks test classes by name first. By default it runs the classes in src/test/java whose names start with Test or end with Test, Tests or TestCase. A class called GstSplitChecks would compile and then never run, without any warning, so stick to the …Test names.

Now the same project with a bug planted: the base is cut off instead of rounded. -q makes Maven quiet, so only problems are printed.

The same tests after one line changes Python · maven_red_session.py
from session import run

run("""
cd gst-split
# Plant a bug: the base is truncated instead of rounded to the nearest paisa.
cp ../bugs/truncating/GstSplit.java src/main/java/com/example/billing/GstSplit.java
mvn -q test
ls target/surefire-reports
""", fails=("mvn -q test",))

Output

$ cd gst-split
# Plant a bug: the base is truncated instead of rounded to the nearest paisa.
$ cp ../bugs/truncating/GstSplit.java src/main/java/com/example/billing/GstSplit.java
$ mvn -q test
[ERROR] Tests run: 4, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: … s <<< FAILURE! -- in com.example.billing.GstSplitTest
[ERROR] com.example.billing.GstSplitTest.roundsTheBaseToTheNearestPaisa -- Time elapsed: … s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <85> but was: <84>
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:673)
	at com.example.billing.GstSplitTest.roundsTheBaseToTheNearestPaisa(GstSplitTest.java:27)

[ERROR] com.example.billing.GstSplitTest.givesTheOddPaisaOfTheTaxToSgst -- Time elapsed: … s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <7> but was: <8>
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:673)
	at com.example.billing.GstSplitTest.givesTheOddPaisaOfTheTaxToSgst(GstSplitTest.java:33)

[ERROR] Failures: 
[ERROR]   GstSplitTest.givesTheOddPaisaOfTheTaxToSgst:33 expected: <7> but was: <8>
[ERROR]   GstSplitTest.roundsTheBaseToTheNearestPaisa:27 expected: <85> but was: <84>
[ERROR] Tests run: 4, Failures: 2, Errors: 0, Skipped: 0
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.6.0:test (default-test) on project gst-split: There are test failures.
[ERROR] 
[ERROR] See gst-split/target/surefire-reports for the individual test results.
[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream.
[ERROR] -> [Help 1]
[ERROR] 
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
[ERROR] 
[ERROR] For more information about the errors and possible solutions, please read the following articles:
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException
[exit status 1]
$ ls target/surefire-reports
com.example.billing.GstSplitTest.txt
TEST-com.example.billing.GstSplitTest.xml

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 maven_red_session.py

One wrong line made two tests fail, and each failure says something different. roundsTheBaseToTheNearestPaisa expected 85 and got 84, which points straight at the rounding. givesTheOddPaisaOfTheTaxToSgst expected 7 paise of CGST and got 8, because the smaller base left 16 paise of tax instead of 15. The two tests that passed are as useful to read: an exact division and a refused price still behave, so the bug is narrow. Every failure names the test method and its line, GstSplitTest.java:27, so you can go straight to it. Maven then stopped with There are test failures and exit status 1, which is what a build server reads to mark the build as broken.

Surefire also wrote two reports per test class into target/surefire-reports: a plain-text file with the same details, and an XML file that build servers and IDEs read to draw their test result pages.

Failure messages that explain themselves

The message is the first thing anyone reads when a test fails, often on a build server, hours after the change. Here are three checks of the same rounding rule, run against the version with the planted bug:

MessagesTest.java Java · messages/MessagesTest.java
package com.example.billing;

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

import org.junit.jupiter.api.Test;

/** Three checks of the same rounding rule, each run against the GstSplit that truncates instead of rounding. */
class MessagesTest {

    @Test
    void argumentsSwapped() {
        long base = GstSplit.ofInclusive(100, 18).base();
        assertEquals(base, 85); // the actual value first: the message gets the two the wrong way round
    }

    @Test
    void onlyTrueOrFalse() {
        assertTrue(GstSplit.ofInclusive(100, 18).base() == 85); // the message cannot say which numbers differed
    }

    @Test
    void expectedFirstWithAMessage() {
        long base = GstSplit.ofInclusive(100, 18).base();
        assertEquals(85, base, "base of a ₹1.00 price at 18 % GST");
    }
}
Three messages for one bug Python · messages_session.py
from session import console_jar, run

jar = console_jar("messages")
run(f"""
cd messages
javac -d classes -cp {jar} ../bugs/truncating/GstSplit.java MessagesTest.java
java -jar {jar} execute -cp classes --select-class com.example.billing.MessagesTest --include-engine junit-jupiter --disable-banner
""", fails=(f"java -jar {jar} execute -cp classes --select-class com.example.billing.MessagesTest --include-engine junit-jupiter --disable-banner",))

Output

$ cd messages
$ javac -d classes -cp junit-platform-console-standalone-6.1.3.jar ../bugs/truncating/GstSplit.java MessagesTest.java
$ java -jar junit-platform-console-standalone-6.1.3.jar execute -cp classes --select-class com.example.billing.MessagesTest --include-engine junit-jupiter --disable-banner
╷
└─ JUnit Jupiter ✔
   └─ MessagesTest ✔
      ├─ onlyTrueOrFalse() ✘ expected: <true> but was: <false>
      ├─ argumentsSwapped() ✘ expected: <84> but was: <85>
      └─ expectedFirstWithAMessage() ✘ base of a ₹1.00 price at 18 % GST ==> expected: <85> but was: <84>

Failures (3):
  JUnit Jupiter:MessagesTest:onlyTrueOrFalse()
    MethodSource [className = 'com.example.billing.MessagesTest', methodName = 'onlyTrueOrFalse', methodParameterTypes = '']
    => org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
       org.junit.jupiter.api.Assertions.assertTrue(Assertions.java:190)
       com.example.billing.MessagesTest.onlyTrueOrFalse(MessagesTest.java:19)
  JUnit Jupiter:MessagesTest:argumentsSwapped()
    MethodSource [className = 'com.example.billing.MessagesTest', methodName = 'argumentsSwapped', methodParameterTypes = '']
    => org.opentest4j.AssertionFailedError: expected: <84> but was: <85>
       org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:673)
       com.example.billing.MessagesTest.argumentsSwapped(MessagesTest.java:14)
  JUnit Jupiter:MessagesTest:expectedFirstWithAMessage()
    MethodSource [className = 'com.example.billing.MessagesTest', methodName = 'expectedFirstWithAMessage', methodParameterTypes = '']
    => org.opentest4j.AssertionFailedError: base of a ₹1.00 price at 18 % GST ==> expected: <85> but was: <84>
       org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:705)
       com.example.billing.MessagesTest.expectedFirstWithAMessage(MessagesTest.java:25)

Test run finished after … ms
[         2 containers found      ]
[         0 containers skipped    ]
[         2 containers started    ]
[         0 containers aborted    ]
[         2 containers successful ]
[         0 containers failed     ]
[         3 tests found           ]
[         0 tests skipped         ]
[         3 tests started         ]
[         0 tests aborted         ]
[         0 tests successful      ]
[         3 tests failed          ]

[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 messages_session.py

  • argumentsSwapped() passed the computed value first. assertEquals takes the expected value as its first argument, so the message said it expected 84 and got 85, the truth back to front. A reader would hunt for code that rounds up when it should round down.
  • onlyTrueOrFalse() used assertTrue on a comparison. The message can only say that it expected true and got false; the numbers are lost.
  • expectedFirstWithAMessage() put the expected value first and added a message, which JUnit prints before its own text: the reader learns which price and which rule failed without opening the code.

Use the assertion that matches the check: assertEquals for values, assertThrows for exceptions, assertNull and assertNotNull for references. When a message costs something to build, pass a lambda instead of a String, such as () -> "base of " + price, and JUnit builds it only if the assertion fails.

Naming and structuring tests

A test suite is read far more often than it is written, so these habits pay for themselves:

  • Name the behaviour, not the method. roundsTheBaseToTheNearestPaisa says what must hold. testOfInclusive2 says nothing when it fails.
  • One behaviour per test. Several assertions are fine when they check one behaviour, like the three parts of one split. Two behaviours in one test hide the second failure behind the first, because a test stops at its first failed assertion.
  • Arrange, act, assert, in that order. One call to the code under test, between the setup and the checks.
  • No logic in tests. A loop or an if in a test is code that can be wrong too. Write the expected values out.
  • Independent and fast. Each test sets up what it needs and does not depend on another test or on the order of tests, and a unit test needs no network, database or clock, so the whole suite runs in seconds.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 A test runs assertEquals(base, 85), where base holds the computed value 84. What does the failure message say?

    Choose one answer.

    Show the answer to question 1

    Answer: expected: <84> but was: <85>

    assertEquals treats its first argument as the expected value and its second as the actual one. With the computed value first, the message reports the right numbers in the wrong roles, which is what the argumentsSwapped() test printed in the MessagesTest session. Put the expected value first.

  2. Question 2 of 5 A test class has a field int count = 0 and three @Test methods that each add 1 to it and print it. With the default lifecycle, what do they print?

    Choose one answer.

    Show the answer to question 2

    Answer: 1, 1 and 1, because each test method runs on a new instance of the class

    JUnit Jupiter creates a new instance of the test class before each test method (the per-method lifecycle), so an instance field always starts at its initial value and one test cannot leave state behind for the next.

  3. Question 3 of 5 Which test class does Maven Surefire run by default, without any configuration?

    Choose one answer.

    Show the answer to question 3

    Answer: GstSplitTest in src/test/java

    Surefire runs the compiled classes of src/test/java whose names match Test*, *Test, *Tests or *TestCase. GstSplitCheck and VerifyGstSplit match none of those patterns, so their tests would silently never run, and a class in src/main/java is production code, not a test.

  4. Question 4 of 5 Why does it matter that mvn test and the console launcher end with exit status 1 when a test fails?

    Choose one answer.

    Show the answer to question 4

    Answer: A build server reads the exit status to mark the build as broken and stop the change from going further

    Scripts and build servers decide what to do next from a command's exit status: 0 means success, anything else stops the pipeline. Both sessions in the lesson ended with exit status 1 when a test failed.

  5. Question 5 of 5 In the exercise, the test with Long.MIN_VALUE failed on all three planted bugs. Why keep the other tests?

    Choose one answer.

    Show the answer to question 5

    Answer: When a single big test fails, it does not say which rule broke; a small test per rule names the rule in its failure

    A test's name and message are the first thing anyone reads when it fails. groupsALakhInTwos() failing points at the two-digit groups at once; a failure of handlesTheMostNegativeLong() alone could mean any of the three bugs.

Your turn: catch three planted bugs

IndianDigits.group(long) puts commas into a number the Indian way, so that the rightmost group has three digits and every group to its left has two. Java’s own formatter does not group numbers like that, which is why a project writes such a method itself. Three copies of the class each have one planted bug. Write tests that pass on the correct class and fail on every buggy copy. The starter has one test, and the checker runs your test class against all four versions:

India-specific: lakh and crore grouping

A hundred thousand is called a lakh in India and is written 1,00,000; ten million is a crore, written 1,00,00,000. Below a lakh nothing changes, and 12,345 is written the same way in both systems, which is why a test of a five-digit number alone proves little.

Lakh Crore Converter (to Million, Billion) Convert between lakh and crore and million and billion, with the digits grouped both ways.
The correct class, the starter test and the checker

exercise/versions/correct/IndianDigits.java

package com.example.format;

/** Writes a whole number with Indian digit grouping: the last three digits, then groups of two (12,34,567). */
public final class IndianDigits {

    private IndianDigits() {
    }

    public static String group(long n) {
        String digits = Long.toString(n);
        String sign = "";
        if (digits.startsWith("-")) {
            sign = "-";
            digits = digits.substring(1);
        }
        if (digits.length() <= 3) {
            return sign + digits;
        }
        StringBuilder out = new StringBuilder(digits.substring(digits.length() - 3));
        int end = digits.length() - 3;
        while (end > 0) {
            int start = Math.max(0, end - 2);
            out.insert(0, digits.substring(start, end) + ",");
            end = start;
        }
        return sign + out;
    }
}

starter/IndianDigitsTest.java · exercise/starter/IndianDigitsTest.java

package com.example.format;

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

import org.junit.jupiter.api.Test;

class IndianDigitsTest {

    @Test
    void groupsTheThousands() {
        assertEquals("12,345", IndianDigits.group(12_345));
    }

    // Add tests here until every planted bug makes at least one of them fail.
}

exercise/CheckTests.java

// Runs the tests of <folder>/IndianDigitsTest.java against the correct IndianDigits and against three versions with a
// planted bug each, and prints one line per version. Run it from this folder, with JUnit's console launcher jar on
// the class path:
//
//   java -cp junit-platform-console-standalone-6.1.3.jar CheckTests.java starter
//
// Good tests pass on the correct version and fail on every buggy one.
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;

import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import javax.tools.ToolProvider;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

void main(String[] args) throws Exception {
    Path tests = Path.of(args[0], "IndianDigitsTest.java");
    for (String version : List.of("correct", "bug-1", "bug-2", "bug-3")) {
        Path classes = Path.of("checked", args[0], version);
        Files.createDirectories(classes);
        int compiled = ToolProvider.getSystemJavaCompiler().run(null, null, null, "-d", classes.toString(),
                "-cp", System.getProperty("java.class.path"), "versions/" + version + "/IndianDigits.java", tests.toString());
        if (compiled != 0) {
            IO.println(version + ": the tests do not compile");
            continue;
        }
        try (URLClassLoader loader = new URLClassLoader(new URL[] {classes.toUri().toURL()}, getClass().getClassLoader())) {
            var request = LauncherDiscoveryRequestBuilder.request()
                    .selectors(selectClass(loader.loadClass("com.example.format.IndianDigitsTest")))
                    .build();
            var listener = new SummaryGeneratingListener();
            LauncherFactory.create().execute(request, listener);
            IO.println(verdict(version, listener.getSummary()));
        }
    }
}

String verdict(String version, TestExecutionSummary summary) {
    long failed = summary.getTotalFailureCount();
    long found = summary.getTestsFoundCount();
    String counts = found + (found == 1 ? " test, " : " tests, ") + failed + " failed";
    List<String> names = summary.getFailures().stream()
            .map(failure -> failure.getTestIdentifier().getDisplayName())
            .sorted()
            .toList();
    if (version.equals("correct")) {
        return "%-8s %-22s %s".formatted(version, counts, failed == 0 ? "passes, as it must" : "WRONG: a test fails on correct code " + names);
    }
    return "%-8s %-22s %s".formatted(version, counts, failed == 0 ? "missed: no test fails" : "caught by " + String.join(", ", names));
}

First hint: test the first number where each rule starts to matter, and the largest and smallest values a long can hold. A solution, once you have tried:

solution/IndianDigitsTest.java Java · exercise/solution/IndianDigitsTest.java
package com.example.format;

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

import org.junit.jupiter.api.Test;

class IndianDigitsTest {

    @Test
    void groupsTheThousands() {
        assertEquals("12,345", IndianDigits.group(12_345));
    }

    @Test
    void groupsALakhInTwos() {
        assertEquals("1,00,000", IndianDigits.group(1_00_000));
    }

    @Test
    void groupsCroresInTwos() {
        assertEquals("1,23,45,678", IndianDigits.group(1_23_45_678));
    }

    @Test
    void handlesTheMostNegativeLong() {
        assertEquals("-92,23,37,20,36,85,47,75,808", IndianDigits.group(Long.MIN_VALUE));
    }

    @Test
    void leavesThreeDigitsAlone() {
        assertEquals("999", IndianDigits.group(999));
    }
}
The checker on the starter, then on the solution Python · exercise_session.py
from session import console_jar, run

jar = console_jar("exercise")
run(f"""
cd exercise
# The starter test: one test, which every version passes.
java -cp {jar} CheckTests.java starter
# The solution: one test for each case a bug gets wrong.
java -cp {jar} CheckTests.java solution
""")

Output

$ cd exercise
# The starter test: one test, which every version passes.
$ java -cp junit-platform-console-standalone-6.1.3.jar CheckTests.java starter
correct  1 test, 0 failed       passes, as it must
bug-1    1 test, 0 failed       missed: no test fails
bug-2    1 test, 0 failed       missed: no test fails
bug-3    1 test, 0 failed       missed: no test fails
# The solution: one test for each case a bug gets wrong.
$ java -cp junit-platform-console-standalone-6.1.3.jar CheckTests.java solution
correct  5 tests, 0 failed      passes, as it must
bug-1    5 tests, 3 failed      caught by groupsALakhInTwos(), groupsCroresInTwos(), handlesTheMostNegativeLong()
bug-2    5 tests, 2 failed      caught by groupsCroresInTwos(), handlesTheMostNegativeLong()
bug-3    5 tests, 1 failed      caught by handlesTheMostNegativeLong()

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 exercise_session.py

The starter’s one test passed on every version, so it caught nothing: 12,345 is grouped the same way by all four. Each planted bug breaks one rule. bug-1 groups every three digits, so it fails from the first six-digit number, 1,00,000. bug-2 writes at most two commas, so it only fails from eight digits, 1,23,45,678. bug-3 takes Math.abs(n) first, and the Math Javadoc warns that Math.abs(Long.MIN_VALUE) is Long.MIN_VALUE itself, still negative, so the minus sign ends up grouped with the digits. Only the test with the most negative long catches it.

That test failed on all three bugs, which raises a fair question: why keep the others? Because a failure of groupsALakhInTwos() names the broken rule at once, while a failure of the big test alone could be any of the three. Small tests that each check one rule are what make a failure explain itself.

Interview questions

Fresher · What are the JUnit Platform, JUnit Jupiter and JUnit Vintage?

They are the three parts of JUnit 6. The JUnit Platform launches tests on the JVM: it discovers them, runs them through test engines and reports the results, and Maven, Gradle, IDEs and the console launcher all start it. JUnit Jupiter is the programming model you write tests with, @Test, the assertions, lifecycle annotations and the extension model, plus the engine that runs those tests on the platform. JUnit Vintage is an engine that runs old JUnit 3 and JUnit 4 tests on the platform; it is deprecated and meant only for the time a project needs to move its tests to Jupiter. Because tools talk to the platform rather than to one framework, other test engines can run beside Jupiter in the same build.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A test passes when you run its class alone in the IDE but fails in CI, where the whole suite runs, and nobody has changed it. What do you suspect, how do you prove it, and how do you stop it from coming back?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    Your team's build failures read like "expected: <true> but was: <false>" or "expected: <84> but was: <85>", and each one needs a debugger to understand. What would you change in the tests, and how would you check that the change worked?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A JUnit 6 test is a non-private method marked @Test that calls your code and checks the result with assertions; JUnit 6 needs Java 17 or later, and org.junit.start runs tests from one compact file on JDK 25.
  • The JUnit Platform runs tests for Maven, Gradle, IDEs and the console launcher; JUnit Jupiter is the API you write them with.
  • Jupiter creates a new instance of the test class for each test method, and the order of tests is fixed but not obvious, so tests must not depend on each other.
  • mvn test compiles the code and the tests and runs Surefire, which picks classes named like …Test and writes text and XML reports to target/surefire-reports. A failure ends the build with exit status 1.
  • Put the expected value first in assertEquals, use the assertion that fits the check, and add a message where the values alone do not say what broke.
  • Name each test after the behaviour it checks and keep one behaviour per test, so a failure names what broke.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.