Unit Testing with JUnit
How do you know your code works? Running the program and looking at the output works once. But every time you change something, you would have to check everything again by hand, and you would miss things. Unit tests are small programs that check one piece of your code (a "unit", usually a method) automatically. They run in seconds, every time you build, and they tell you immediately when a change breaks something that used to work.
JUnit is the standard testing framework for Java. The current major version is JUnit 6 (first released in September 2025; 6.1.3 at the time of writing), which needs Java 17 or later. In this lesson you will write tests with JUnit 6, check results and exceptions, run the same test with many inputs, and read a failure report.
The code under test
Tests live in src/test/java, in the same package as the class they test, so they can reach package-private methods. Here is the class we will test, in src/main/java/in/college/FeeCalculator.java:
package in.college;
public class FeeCalculator {
private final int tuitionPerSemester;
public FeeCalculator(int tuitionPerSemester) {
if (tuitionPerSemester <= 0) {
throw new IllegalArgumentException("tuition must be positive");
}
this.tuitionPerSemester = tuitionPerSemester;
}
/** Total fee for some semesters, with a scholarship discount in percent (0-100). */
public int totalFee(int semesters, int scholarshipPercent) {
if (semesters < 1) {
throw new IllegalArgumentException("semesters must be at least 1");
}
if (scholarshipPercent < 0 || scholarshipPercent > 100) {
throw new IllegalArgumentException("scholarship must be 0-100");
}
int gross = tuitionPerSemester * semesters;
return gross - gross * scholarshipPercent / 100;
}
public static String grade(int marks) {
if (marks >= 90) return "A+";
if (marks >= 75) return "A";
if (marks >= 60) return "B";
if (marks >= 33) return "C";
return "F";
}
}To use JUnit, add the org.junit.jupiter:junit-jupiter dependency with test scope (Maven) or the JUnit BOM with testImplementation (Gradle), exactly as shown in Maven & Gradle. The junit-jupiter artifact includes the API, the parameterized-test support and the engine that runs the tests.
Your first tests
A test class is an ordinary class. Each method marked @Test is one test. Inside, you call your code and then assert what the result should be. If an assertion fails, the test fails with a message showing what was expected and what actually happened.
package in.college;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class FeeCalculatorTest {
private FeeCalculator calculator;
@BeforeEach
void setUp() {
calculator = new FeeCalculator(40_000); // a fresh object before every test
}
@Test
void feeWithoutScholarship() {
int fee = calculator.totalFee(2, 0);
assertEquals(80_000, fee);
}
@Test
@DisplayName("25% scholarship takes a quarter off")
void feeWithScholarship() {
assertEquals(60_000, calculator.totalFee(2, 25));
}
@Test
void fullScholarshipAndOneSemester() {
assertAll(
() -> assertEquals(0, calculator.totalFee(4, 100)),
() -> assertEquals(40_000, calculator.totalFee(1, 0))
);
}
@Test
void rejectsZeroSemesters() {
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> calculator.totalFee(0, 10));
assertEquals("semesters must be at least 1", e.getMessage());
}
}- The JUnit annotations and assertions live in the package
org.junit.jupiter.api(JUnit 6 kept the same "Jupiter" package names as JUnit 5). Theimport staticlines let you writeassertEqualsinstead ofAssertions.assertEquals. assertEquals(expected, actual): put the expected value first. Other common assertions areassertTrue,assertFalse,assertNull,assertNotNullandassertIterableEquals.assertThrows(Type.class, () -> ...)passes only if the lambda throws that exception, and returns it so you can check its message.assertAllruns several assertions and reports every failure, instead of stopping at the first one.@BeforeEachruns before every test, so each test gets a fresh object and tests cannot affect each other. (@AfterEach,@BeforeAlland@AfterAllalso exist.)@DisplayNamegives a test a readable name in reports.- Test classes and methods do not need to be
public.
Run the tests with mvn test, ./gradlew test, or the green run button in your IDE. The build fails if any test fails, which is exactly what you want before code reaches users.
Parameterized tests
Testing a grading function needs many inputs, especially at the boundaries, where bugs hide (is 90 an A+ or an A?). Instead of copying a test eight times, write a parameterized test that runs once per row of data. @CsvSource supplies rows of comma-separated values; @ValueSource supplies single values.
package in.college;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
class GradeTest {
@ParameterizedTest(name = "{0} marks -> {1}")
@CsvSource({
"95, A+",
"90, A+",
"89, A",
"75, A",
"60, B",
"33, C",
"32, F",
"0, F"
})
void gradeBoundaries(int marks, String expected) {
assertEquals(expected, FeeCalculator.grade(marks));
}
@ParameterizedTest
@ValueSource(ints = {-1, 101, 150})
void rejectsBadScholarship(int percent) {
FeeCalculator calc = new FeeCalculator(40_000);
assertThrows(IllegalArgumentException.class, () -> calc.totalFee(1, percent));
}
}JUnit converts each CSV value to the parameter's type (int, String) automatically. The name pattern controls how each run appears in the report.
Reading a test report
You can also run tests without a build tool, using JUnit's console launcher JAR (junit-platform-console-standalone), after compiling your classes into out/main and out/test:
java -jar junit-platform-console-standalone-6.1.3.jar execute --class-path "out/main;out/test" --scan-class-path --details=tree(On Linux and macOS, separate class-path entries with : instead of ;.) Now suppose someone "simplifies" the grading code and writes marks > 90 instead of marks >= 90. Running GradeTest shows (shortened):
│ └─ GradeTest ✔ │ ├─ gradeBoundaries(int, String) ✔ │ │ ├─ "95" marks -> "A+" ✔ │ │ ├─ "90" marks -> "A+" ✘ expected: <A+> but was: <A> │ │ ├─ "89" marks -> "A" ✔ │ │ ├─ "75" marks -> "A" ✔ │ │ ├─ "60" marks -> "B" ✔ │ │ ├─ "33" marks -> "C" ✔ │ │ ├─ "32" marks -> "F" ✔ │ │ └─ "0" marks -> "F" ✔ │ └─ rejectsBadScholarship(int) ✔ │ ├─ [1] -1 ✔ │ ├─ [2] 101 ✔ │ └─ [3] 150 ✔
The boundary test caught the bug instantly and says exactly which input failed and why: expected: <A+> but was: <A>. The full report also gives the error type, org.opentest4j.AssertionFailedError, and the test line number. Without the test, a student with exactly 90 marks would have received the wrong grade. Maven and Gradle print the same information in their own format.
Writing good tests
- Arrange, Act, Assert: set up the data, call the method once, then check the result. Keep each test about one behaviour.
- Name tests after behaviour:
rejectsZeroSemesterstells you what broke when it fails;test3does not. - Test the edges: zero, negative numbers, empty lists,
null, the exact boundary values, and invalid input that must throw. - Keep tests fast and independent: no shared changing state, no real network or database in unit tests. Libraries such as Mockito create stand-in objects ("mocks") for those dependencies.
- Write the test when you fix a bug: first a test that fails because of the bug, then the fix. The bug can never return unnoticed. Writing tests before code in general is called test-driven development (TDD).
Common mistakes
- Swapping expected and actual.
assertEquals(fee, 80000)gives confusing failure messages; the expected value comes first. - Tests that depend on each other. Each test must work alone and in any order; set up state in
@BeforeEach. - Testing only the happy path. Most bugs live in edge cases and error handling.
- Catching the exception yourself. Use
assertThrowsinstead oftry/catchwith afail()call. - Comparing decimals exactly. For
doubleresults useassertEquals(expected, actual, 0.001)with a tolerance, or useBigDecimal.
Key points
- JUnit 6 is the current major version; its API is in
org.junit.jupiter.apiand it needs Java 17+. - Put tests in
src/test/java, in the same package as the code under test. - Mark tests with
@Test; check results withassertEquals(expected, actual),assertThrowsandassertAll. @BeforeEachcreates fresh test data before every test;@DisplayNamenames tests readably.@ParameterizedTestwith@CsvSourceor@ValueSourceruns one test over many inputs, ideal for boundaries.- Run tests with
mvn test,./gradlew testor your IDE, every time you build.
Exercise
Write a JUnit 6 test class TicketPriceTest for a static method TicketPrice.forAge(int age) that returns 0 for children under 5, 100 for ages 5 to 17, 200 for 18 to 59 and 120 for 60 and above, and throws IllegalArgumentException for negative ages. Write the method, a parameterized test covering every boundary, and a test for the exception.
Show solution
package in.college;
public class TicketPrice {
public static int forAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("age cannot be negative: " + age);
}
if (age < 5) return 0;
if (age < 18) return 100;
if (age < 60) return 200;
return 120;
}
}package in.college;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class TicketPriceTest {
@ParameterizedTest(name = "age {0} pays Rs {1}")
@CsvSource({"0, 0", "4, 0", "5, 100", "17, 100", "18, 200", "59, 200", "60, 120", "85, 120"})
void priceAtEveryBoundary(int age, int expected) {
assertEquals(expected, TicketPrice.forAge(age));
}
@Test
void negativeAgeIsRejected() {
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> TicketPrice.forAge(-1));
assertEquals("age cannot be negative: -1", e.getMessage());
}
}All 9 tests pass. Each boundary is tested from both sides (4 and 5, 17 and 18, 59 and 60), which is where off-by-one mistakes such as <= instead of < would show up.
Quick Quiz
1. Which annotation marks a method as a JUnit test?
JUnit runs every method annotated with @Test.
2. In assertEquals(a, b), which argument is the expected value?
The expected value comes first, so failure messages read correctly.
3. How do you check that a method throws IllegalArgumentException?
assertThrows passes only when the lambda throws the expected exception type.
4. What is the main benefit of @ParameterizedTest with @CsvSource?
Parameterized tests cover many inputs, such as boundary values, without copying code.
Learn Java with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
