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.
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
What you’ll learn · Before you start
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
On this page
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:
// 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));
} 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.
What happens when you run JUnit 6 tests
Text description of the diagram
The diagram reads from top to bottom.
- 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.
- All of them hand the run to the JUnit Platform, which finds the tests, runs them and reports the results.
- 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.
- 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-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.
<?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> 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.
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:
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");
}
} 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.assertEqualstakes 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()usedassertTrueon a comparison. The message can only say that it expectedtrueand gotfalse; 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.
roundsTheBaseToTheNearestPaisasays what must hold.testOfInclusive2says 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
ifin 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.
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.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:
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));
}
} 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.
-
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?
-
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?
Key takeaways
- A JUnit 6 test is a non-private method marked
@Testthat calls your code and checks the result with assertions; JUnit 6 needs Java 17 or later, andorg.junit.startruns 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 testcompiles the code and the tests and runs Surefire, which picks classes named like…Testand writes text and XML reports totarget/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
- JUnit User Guide 6.1.3: overview, supported Java versions (The JUnit Team)
- JUnit User Guide 6.1.3: test classes and methods (The JUnit Team)
- JUnit User Guide 6.1.3: assertions (The JUnit Team)
- JUnit User Guide 6.1.3: test instance lifecycle (The JUnit Team)
- JUnit User Guide 6.1.3: test execution order (The JUnit Team)
- JUnit User Guide 6.1.3: console launcher and exit codes (The JUnit Team)
- JUnit User Guide 6.1.3: source launcher (org.junit.start) (The JUnit Team)
- JUnit User Guide 6.1.3: build support for Maven (The JUnit Team)
- JUnit 6.1.3 API: Assertions (The JUnit Team)
- Maven Surefire Plugin: introduction and report formats (The Apache Software Foundation)
- Maven Surefire Plugin: inclusions and exclusions of tests (The Apache Software Foundation)
- Introduction to the Build Lifecycle (The Apache Software Foundation)
- JEP 512: Compact Source Files and Instance Main Methods (OpenJDK)
- JEP 511: Module Import Declarations (OpenJDK)
- Math (Java SE 25): abs(long) (Oracle)
- Frequently asked questions on GST: intra-State supply and CGST/SGST (Central Board of Indirect Taxes and Customs, Government of India)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress