Unit testing with JUnit 5

Java · Practical · 14 min

Automated tests let you change code with confidence, and JUnit is the standard testing framework in Java.

JUnit 5 (Jupiter) is the standard unit-testing framework. Tests live under src/test/java, in the same package as the code they test, and run with mvn test or ./gradlew test (Gradle needs useJUnitPlatform() in its test task).

A test is a method annotated with @Test. It passes if it completes without throwing; assertions throw when expectations fail:

  • assertEquals(expected, actual) — expected value first, so failure messages read correctly.
  • assertTrue, assertFalse, assertNull, assertNotNull.
  • assertThrows(SomeException.class, () -> code()) returns the exception so you can check its message.
  • assertAll(...) reports several failures at once instead of stopping at the first.

Test classes and methods don't need to be public in JUnit 5. @BeforeEach runs before every test (a fresh setup for each), and @AfterEach after. JUnit creates a new instance of the test class per test method, so tests don't share instance fields.

Parameterized tests run one test with many inputs: annotate with @ParameterizedTest and supply data with @ValueSource, @CsvSource or @MethodSource.

Good unit tests are fast, independent and deterministic. Structure each as arrange–act–assert, give it a name that states the behaviour (rejectsNegativeAmounts), and test one behaviour per method. Code against interfaces so you can pass simple fakes; libraries like Mockito and AssertJ (fluent assertions) are common additions.

Don't chase 100% coverage. Focus on logic with branches, edge cases (empty, null, boundaries) and bugs you have fixed, so they stay fixed.

Example

package com.example;   // src/test/java/com/example/PriceCalculatorTest.java

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;

class PriceCalculatorTest {
    PriceCalculator calc;
    @BeforeEach
    void setUp() { calc = new PriceCalculator(0.20); }   // fresh object per test

    @Test
    void addsTax() { assertEquals(120.0, calc.withTax(100.0), 1e-9); }

    @Test
    void rejectsNegativeAmounts() {
        var e = assertThrows(IllegalArgumentException.class, () -> calc.withTax(-1));
        assertTrue(e.getMessage().contains("negative"));
    }

    @ParameterizedTest
    @CsvSource({"0, 0", "10, 12", "2.5, 3"})
    void manyInputs(double net, double gross) { assertEquals(gross, calc.withTax(net), 1e-9); }
}

Remember

  • Annotate with @Test; put expected before actual in assertEquals
  • assertThrows checks error paths; @ParameterizedTest covers many inputs
  • Each test gets a fresh instance — keep tests independent and fast

Watch out for

  • Comparing doubles with assertEquals(a, b) without a delta and failing on rounding noise.

Check yourself

  1. How does JUnit 5 treat instance fields of a test class between two @Test methods?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: junit.org/junit5/docs/current/user-guide/