Unit Testing with JUnit in Java

Naveen AnamaneniUpdated · 10 min read

A teacher marking a hundred exam papers doesn't solve every question again for each paper. They prepare an answer key once, and checking each paper against it takes seconds. If a paper says 16,000 where the key says 16,500, the mistake jumps out. Unit tests are an answer key for your code: small programs that call your methods with known inputs and check the answers. Once written, they run in seconds on every build, so a mistake made today is caught today, not by a user next month. In this lesson, you will write tests with JUnit, the standard testing framework for Java, run them with Maven, read a failing test report, and learn the lifecycle and parameterized tests.

What Is Unit Testing?

Unit testing is the practice of writing code that is used to check one small unit of a program, usually one method or one class, in isolation, by calling it with chosen inputs and comparing the result with the expected answer. JUnit is the framework that finds these test methods, runs them, and reports which passed and which failed.

Kind of testWhat it checksSpeed
Unit testOne class or method, aloneMilliseconds
Integration testSeveral parts together, such as code plus a real databaseSeconds
End-to-end testThe whole application, as a user would use itMinutes

A healthy project has many unit tests, fewer integration tests and only a few end-to-end tests.

Setting Up JUnit

Add one dependency with test scope (see "Build Tools in Java: Maven and Gradle"):

pom.xml
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>6.1.3</version>
  <scope>test</scope>
</dependency>

Tests live in src/test/java, in the same package as the class they test, and their class names end in Test.

Your First Test Class

This is the class we will test:

FeeCalculator.java
package school.fees;
 
public class FeeCalculator {
    private final int finePerDay;
 
    public FeeCalculator(int finePerDay) {
        if (finePerDay < 0) {
            throw new IllegalArgumentException("finePerDay < 0");
        }
        this.finePerDay = finePerDay;
    }
 
    public int totalFee(int baseFee, int lateDays) {
        if (lateDays < 0) {
            throw new IllegalArgumentException("lateDays < 0");
        }
        return baseFee + lateDays * finePerDay;
    }
}

And this is its test class:

FeeCalculatorTest.java
package school.fees;
 
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 static org.junit.jupiter.api.Assertions.assertTrue;
 
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
 
@DisplayName("FeeCalculator")
class FeeCalculatorTest {
    private FeeCalculator calc;
 
    @BeforeEach
    void setUp() {
        calc = new FeeCalculator(500);
    }
 
    @Test
    @DisplayName("adds 500 for each late day")
    void addsFineForEachLateDay() {
        assertEquals(16500, calc.totalFee(15000, 3));
    }
 
    @Test
    @DisplayName("charges no fine when paid on time")
    void noFineWhenOnTime() {
        assertEquals(15000, calc.totalFee(15000, 0));
    }
 
    @Test
    @DisplayName("rejects negative late days")
    void rejectsNegativeLateDays() {
        IllegalArgumentException e = assertThrows(
                IllegalArgumentException.class,
                () -> calc.totalFee(15000, -1));
        assertEquals("lateDays < 0", e.getMessage());
    }
 
    @ParameterizedTest(name = "{0} late days cost {1}",
            quoteTextArguments = false)
    @CsvSource({"0, 15000", "1, 15500", "10, 20000", "45, 37500"})
    void feeForLateDays(int lateDays, int expected) {
        assertEquals(expected, calc.totalFee(15000, lateDays));
    }
 
    @Test
    @DisplayName("checks several facts together")
    void severalFacts() {
        assertAll(
                () -> assertEquals(15000, calc.totalFee(15000, 0)),
                () -> assertTrue(calc.totalFee(15000, 1) > 15000));
    }
 
    @Test
    @Disabled("waiting for the sibling discount rule")
    void siblingDiscount() {
    }
}

Explanation:

  1. @Test marks a test method. It returns void, takes no arguments, and needn't be public.
  2. assertEquals(expected, actual) passes if the two are equal. The expected value comes first, which matters for the failure message.
  3. @BeforeEach runs before every test, so each test gets a fresh FeeCalculator and tests can't affect each other.
  4. assertThrows passes only if the code inside the lambda throws that exception, and returns it so you can check its message.
  5. @ParameterizedTest with @CsvSource runs the same test once per row: 4 runs from one method. quoteTextArguments = false stops JUnit from putting quotes around values that came from text in the display name.
  6. assertAll runs every check even if an earlier one fails, and reports all failures together.
  7. @Disabled skips a test and shows the reason in the report.

Running the Tests

mvn test compiles everything and runs every test class in src/test/java:

Terminal
mvn test
Terminal output
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running FeeCalculator
[WARNING] Tests run: 9, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.051 s -- in FeeCalculator
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 1
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

Nine tests ran: five plain tests, four parameterized runs, and one of them was skipped ([WARNING] only because something was skipped). IDEs show the same results as a tree. JUnit's own console launcher, a single JAR you can download from Maven Central, prints that tree in a terminal:

Terminal
java -jar junit-platform-console-standalone-6.1.3.jar execute \
    --disable-banner -cp target/classes:target/test-classes \
    --select-class school.fees.FeeCalculatorTest
Terminal output
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│  └─ FeeCalculator ✔
│     ├─ charges no fine when paid on time ✔
│     ├─ adds 500 for each late day ✔
│     ├─ rejects negative late days ✔
│     ├─ siblingDiscount() ↷ waiting for the sibling discount rule
│     ├─ checks several facts together ✔
│     └─ feeForLateDays(int, int) ✔
│        ├─ 0 late days cost 15000 ✔
│        ├─ 1 late days cost 15500 ✔
│        ├─ 10 late days cost 20000 ✔
│        └─ 45 late days cost 37500 ✔
└─ JUnit Vintage ✔

The @DisplayName texts make the report read like a list of rules. Without them, JUnit shows the method names.

When a Test Fails

Suppose someone "simplifies" the fee formula and slips in an off-by-one bug:

FeeCalculator.java (bug)
return baseFee + (lateDays - 1) * finePerDay;   // wrong
Terminal output
[ERROR] Failures:
[ERROR]   FeeCalculatorTest.addsFineForEachLateDay:27 expected: <16500> but was: <16000>
[ERROR]   FeeCalculatorTest.feeForLateDays:49 expected: <15000> but was: <14500>
[ERROR]   FeeCalculatorTest.feeForLateDays:49 expected: <15500> but was: <15000>
[ERROR]   FeeCalculatorTest.feeForLateDays:49 expected: <20000> but was: <19500>
[ERROR]   FeeCalculatorTest.feeForLateDays:49 expected: <37500> but was: <37000>
[ERROR]   FeeCalculatorTest.noFineWhenOnTime:33 expected: <15000> but was: <14500>
[ERROR]   FeeCalculatorTest.severalFacts:55 Multiple Failures (2 failures)
        org.opentest4j.AssertionFailedError: expected: <15000> but was: <14500>
        org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
[INFO]
[ERROR] Tests run: 9, Failures: 7, Errors: 0, Skipped: 1
[INFO]
[INFO] ------------------------------------------------------------------------
[ERROR] BUILD FAILURE

Explanation: each line gives the test method, the line number in the test, and expected versus actual. Every result is exactly 500 too low, which points straight at the formula. The build fails, so no JAR is produced from broken code. The assertAll test reported both of its failures, not just the first. A failure means an assertion was false; an error means the test threw an unexpected exception.

Assertions

All assertions are static methods in org.junit.jupiter.api.Assertions.

MethodDescription
assertEquals(expected, actual)It is used to check that two values are equal (with equals for objects).
assertNotEquals(a, b)It checks that two values differ.
assertTrue(cond), assertFalse(cond)They check a boolean condition.
assertNull(x), assertNotNull(x)They check for null.
assertSame(a, b)It checks that two references point to the same object.
assertArrayEquals(a, b)It compares arrays element by element.
assertEquals(0.3, x, 0.0001)It compares doubles within a tolerance.
assertThrows(Type.class, () -> ...)It checks that the code throws that exception, and returns it.
assertAll(() -> ..., () -> ...)It runs several checks and reports every failure.
assertTimeout(Duration, () -> ...)It checks that the code finishes in time.
fail("message")It fails the test immediately.

Every assertion also accepts a message as the last argument, shown when it fails: assertEquals(16500, fee, "3 late days").

The Test Lifecycle

JUnit creates a new instance of the test class for every test method. Four annotations run code around the tests:

LifecycleTest.java
class LifecycleTest {
    @BeforeAll
    static void openDatabase() {
        System.out.println("BeforeAll: open test database");
    }
 
    @BeforeEach
    void setUp() {
        System.out.println("  BeforeEach: fresh calculator");
    }
 
    @Test
    void first() {
        System.out.println("    test one");
    }
 
    @Test
    void second() {
        System.out.println("    test two");
    }
 
    @AfterEach
    void tearDown() {
        System.out.println("  AfterEach: clean up");
    }
 
    @AfterAll
    static void closeDatabase() {
        System.out.println("AfterAll: close test database");
    }
}
Terminal output
BeforeAll: open test database
  BeforeEach: fresh calculator
    test two
  AfterEach: clean up
  BeforeEach: fresh calculator
    test one
  AfterEach: clean up
AfterAll: close test database

Explanation:

  1. @BeforeAll and @AfterAll run once for the class and must be static, because no instance exists yet. Use them for expensive setup shared by all tests.
  2. @BeforeEach and @AfterEach run around every test.
  3. Look at the order: test two ran before test one. JUnit's order is fixed but deliberately not the order in the file. Tests must never depend on each other; each one sets up what it needs.
AnnotationDescription
@TestIt is used to mark a test method.
@BeforeEach, @AfterEachThey run before or after every test.
@BeforeAll, @AfterAllThey run once before or after all tests (static methods).
@DisplayName("...")It gives a readable name in reports.
@Disabled("reason")It skips a test or a class.
@ParameterizedTestIt runs a test once per set of arguments.
@CsvSource, @ValueSource, @MethodSourceThey supply the arguments for a parameterized test.
@NestedIt groups related tests in an inner class.
@Tag("slow")It labels tests so a build can include or exclude them.

Good Unit Tests Are FIRST

LetterMeaning
FastMilliseconds each, so the whole suite runs on every build
IndependentNo test depends on another or on run order
RepeatableSame result every time, on every machine: no real clock, network or random data
Self-checkingAssertions decide pass or fail; nobody reads printed output
TimelyWritten with the code (or before it, in test-driven development)

When a class talks to a database, a web service or an email server, those parts make tests slow and unrepeatable. The next lesson, "Mocking with Mockito in Java", shows how to replace them with stand-ins.

Common Mistakes

Swapping expected and actual. assertEquals(actual, 16500) passes or fails the same way, but the message says the opposite of the truth.

Tests with no assertion. A test that only calls a method passes even if the result is wrong.

Sharing changing state between tests. A static list filled by one test breaks another. Create fresh objects in @BeforeEach.

Testing private methods directly. Test the public behavior; private methods are covered through it.

Catching the exception yourself. A try/catch with an empty catch hides failures. Use assertThrows.

Practice

  1. Easy: Add a test that new FeeCalculator(-1) throws IllegalArgumentException with the message finePerDay < 0.
  2. Harder: Write a class GradeCalculator with a method grade(int marks) returning "A" for 90 and above, "B" for 75 to 89, "C" for 50 to 74 and "F" below 50, and throwing for marks outside 0 to 100. Test it with one @ParameterizedTest that covers every boundary (0, 49, 50, 74, 75, 89, 90, 100) and one test for the exceptions.

Quick check: JUnit

4 questions
1Which annotation runs before every test method?
2In assertEquals(a, b), which argument is the expected value?
3How do you check that a method throws an exception?
4Why must @BeforeAll methods be static?

Key Takeaways

Was this tutorial helpful?

Lesson 529 of 578