Skip to content
elephantoo

Unit testing with JUnit 5

Lesson 41 of 43 16 min read

@Test, assertions, assertThrows, lifecycle hooks, parameterised tests and test-driven habits.


How do you know your code works? Running it and eyeballing the output works once. But after the next change, and the one after that, you would have to re-check everything by hand. Automated tests check your code for you, in seconds, every time you build. JUnit is the standard testing framework for Java; this lesson teaches JUnit 5 (Jupiter), the API you'll find in almost every modern codebase and in Spring Boot.

JUnit 6 (released in 2025, requires Java 17+) is the newest major version. It keeps the same Jupiter programming model, so everything in this lesson works unchanged: just bump the BOM version from 5.14.4 to 6.x.

Why write unit tests?#

  • Catch bugs early, while the code is fresh in your mind.
  • Refactor fearlessly: if the tests still pass, you didn't break anything.
  • Document behaviour: a test named rejectsZeroQuantity explains the rules better than a comment.
  • Better design: code that is easy to test tends to be small, focused and loosely coupled.
  • CI safety net: the build fails before broken code reaches production.

A unit test checks one small unit (usually one class or method) in isolation, runs in milliseconds, and needs no database or network.

Setting up#

Use the Maven or Gradle project from the previous lesson. The test dependencies are:

XML
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.junit</groupId>
            <artifactId>junit-bom</artifactId>
            <version>5.14.4</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

(Gradle: testImplementation(platform("org.junit:junit-bom:5.14.4")), testImplementation("org.junit.jupiter:junit-jupiter"), testRuntimeOnly("org.junit.platform:junit-platform-launcher") and tasks.test { useJUnitPlatform() }.)

Tests go in src/test/java, in the same package as the class under test, so they can also access package-private members. Run them with mvn test or ./gradlew test, or click the green arrow in your IDE.

The class under test#

src/main/java/com/elephantoo/shop/ShoppingCart.java
package com.elephantoo.shop;

import java.util.ArrayList;
import java.util.List;

public class ShoppingCart {
    public record Item(String name, int pricePaise, int qty) { }

    private final List<Item> items = new ArrayList<>();
    private int discountPercent = 0;

    public void add(String name, int pricePaise, int qty) {
        if (qty <= 0) throw new IllegalArgumentException("Quantity must be positive: " + qty);
        items.add(new Item(name, pricePaise, qty));
    }

    public void applyDiscount(int percent) {
        if (percent < 0 || percent > 50) throw new IllegalArgumentException("Discount out of range: " + percent);
        this.discountPercent = percent;
    }

    public int itemCount() {
        return items.stream().mapToInt(Item::qty).sum();
    }

    public int totalPaise() {
        int subtotal = items.stream().mapToInt(i -> i.pricePaise() * i.qty()).sum();
        return subtotal - subtotal * discountPercent / 100;
    }

    public boolean isEmpty() {
        return items.isEmpty();
    }
}

Prices are stored in paise (integers) to avoid floating-point rounding errors.

Your first tests#

src/test/java/com/elephantoo/shop/ShoppingCartTest.java
package com.elephantoo.shop;

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.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

@DisplayName("ShoppingCart")
class ShoppingCartTest {
    private ShoppingCart cart;

    @BeforeEach
    void setUp() {
        cart = new ShoppingCart();          // a fresh cart for EVERY test
    }

    @Test
    @DisplayName("a new cart is empty")
    void newCartIsEmpty() {
        assertTrue(cart.isEmpty());
        assertEquals(0, cart.totalPaise());
    }

    @Test
    void totalAddsPriceTimesQuantity() {
        // Arrange
        cart.add("Pen", 2500, 4);
        cart.add("Notebook", 6000, 1);
        // Act
        int total = cart.totalPaise();
        // Assert
        assertEquals(16000, total);
    }

    @Test
    void rejectsZeroQuantity() {
        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
                () -> cart.add("Pen", 2500, 0));
        assertEquals("Quantity must be positive: 0", ex.getMessage());
    }

    @Nested
    @DisplayName("with a discount")
    class WithDiscount {
        @BeforeEach
        void fillCart() {
            cart.add("Headphones", 200000, 1);
        }

        @Test
        void tenPercentOff() {
            cart.applyDiscount(10);
            assertAll(
                () -> assertEquals(180000, cart.totalPaise()),
                () -> assertEquals(1, cart.itemCount()));
        }

        @Test
        void discountAboveFiftyIsRejected() {
            assertThrows(IllegalArgumentException.class, () -> cart.applyDiscount(60));
            assertEquals(200000, cart.totalPaise(), "total must be unchanged");
        }
    }
}

The essentials:

  • A test is a method annotated with @Test. It needs no public modifier, and returns void.
  • @BeforeEach runs before every test, so each test starts from a fresh ShoppingCart. Tests must never depend on each other or on execution order.
  • Assertions (statically imported from org.junit.jupiter.api.Assertions) check the results. The first failing assertion stops the test.
  • Structure each test as Arrange, Act, Assert (also called Given, When, Then).
  • @DisplayName gives readable names in reports; descriptive method names work just as well.
  • @Nested classes group related tests that share extra setup. The outer @BeforeEach runs first, then the inner one.

When a test fails

Suppose we wrote the wrong expected value, assertEquals(12000, cart.totalPaise(), "4 pens at Rs 25"):

Output
[ERROR] com.elephantoo.shop.FailDemoTest.totalAddsPriceTimesQuantity -- Time elapsed: 0.004 s <<< FAILURE!
org.opentest4j.AssertionFailedError: 4 pens at Rs 25 ==> expected: <12000> but was: <10000>
...
[ERROR] Tests run: 25, Failures: 1, Errors: 0, Skipped: 1
[INFO] BUILD FAILURE

The message tells you the test, the expectation and the actual value. Note the argument order: assertEquals(expected, actual). Swapping them produces confusing messages.

The assertion toolbox#

AssertionChecks
assertEquals(expected, actual)equality (uses equals); for doubles, pass a tolerance: assertEquals(0.3, x, 1e-9)
assertNotEquals(a, b)inequality
assertTrue(cond) / assertFalse(cond)a boolean condition
assertNull(x) / assertNotNull(x)null-ness
assertSame(a, b)the same object (==)
assertArrayEquals(a, b)array contents
assertIterableEquals(a, b)list contents in order
assertThrows(Type.class, executable)an exception is thrown; returns it
assertDoesNotThrow(executable)no exception
assertAll(executables...)runs all checks and reports every failure together
assertTimeout(duration, executable)finishes within a time limit
fail("message")fails immediately

Every assertion accepts an optional final message argument, shown on failure.

Many teams add AssertJ for fluent, very readable assertions: assertThat(cart.totalPaise()).isEqualTo(16000); and assertThat(names).containsExactly("Asha", "Ben");.

Parameterised tests: one test, many inputs#

Testing the same logic with many inputs? Don't copy-paste. Use @ParameterizedTest (in junit-jupiter-params, included in junit-jupiter):

src/main/java/com/elephantoo/shop/PasswordPolicy.java
package com.elephantoo.shop;

public class PasswordPolicy {
    public static boolean isStrong(String password) {
        if (password == null || password.length() < 8) return false;
        boolean upper = false, digit = false;
        for (char c : password.toCharArray()) {
            if (Character.isUpperCase(c)) upper = true;
            if (Character.isDigit(c)) digit = true;
        }
        return upper && digit;
    }
}
src/test/java/com/elephantoo/shop/PasswordPolicyTest.java
package com.elephantoo.shop;

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

import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

class PasswordPolicyTest {

    @ParameterizedTest
    @ValueSource(strings = {"Secret123", "Elephant00", "JAVA2026rocks"})
    void acceptsStrongPasswords(String password) {
        assertTrue(PasswordPolicy.isStrong(password));
    }

    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {"short1A", "alllowercase1", "NoDigitsHere"})
    void rejectsWeakPasswords(String password) {
        assertFalse(PasswordPolicy.isStrong(password));
    }

    @ParameterizedTest(name = "{0} -> {1}")
    @CsvSource({
        "Abcdefg1, true",
        "abcdefg1, false",
        "ABCDEFGH, false"
    })
    void tableOfCases(String password, boolean expected) {
        assertEquals(expected, PasswordPolicy.isStrong(password));
    }

    static Stream<Arguments> generated() {
        return Stream.of(
            Arguments.of("x".repeat(7) + "A1", true),
            Arguments.of("A1", false));
    }

    @ParameterizedTest
    @MethodSource("generated")
    void fromFactoryMethod(String password, boolean expected) {
        assertEquals(expected, PasswordPolicy.isStrong(password));
    }
}
SourceSupplies
@ValueSource(strings/ints/...)one literal argument per run
@NullSource, @EmptySource, @NullAndEmptySourcenull and/or ""
@CsvSource({"a, 1", "b, 2"})several arguments per run, converted to the parameter types
@CsvFileSource(resources = "/cases.csv")rows from a CSV file in src/test/resources
@MethodSource("factory")arguments from a static method returning a Stream<Arguments>
@EnumSource(Size.class)every enum constant

The PasswordPolicyTest class alone produces 13 test runs, each reported separately.

Lifecycle, temporary files, timeouts and disabling#

src/test/java/com/elephantoo/shop/FilesTest.java
package com.elephantoo.shop;

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

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

class FilesTest {
    @BeforeAll
    static void beforeAll() { System.out.println("once, before all tests in this class"); }

    @AfterAll
    static void afterAll() { System.out.println("once, after all tests in this class"); }

    @Test
    void writesAndReadsAFile(@TempDir Path dir) throws IOException {   // JUnit creates and deletes it
        Path file = dir.resolve("note.txt");
        Files.writeString(file, "hello");
        assertEquals("hello", Files.readString(file));
    }

    @Test
    void finishesQuickly() {
        assertTimeout(Duration.ofMillis(500), () -> Thread.sleep(10));
    }

    @Test
    @Disabled("flaky on CI, see ticket SHOP-42")
    void notRunYet() { }
}
Output
once, before all tests in this class
once, after all tests in this class
[WARNING] Tests run: 3, Failures: 0, Errors: 0, Skipped: 1 -- in com.elephantoo.shop.FilesTest
AnnotationRuns
@BeforeAll / @AfterAllonce per class (static methods): expensive shared setup
@BeforeEach / @AfterEacharound every test: fresh objects, cleanup
@TempDirinjects a temporary directory that JUnit deletes afterwards
@Disabled("reason")skips a test (always give a reason)
@Tag("slow")labels tests so builds can include/exclude groups
@RepeatedTest(5)runs a test several times

Testing with dependencies: mocks#

Real classes depend on things you don't want in a unit test: payment gateways, databases, email servers, the clock. Design the class to receive its dependencies (dependency injection) through an interface:

src/main/java/com/elephantoo/shop/OrderService.java
package com.elephantoo.shop;

public class OrderService {
    public interface PaymentGateway {
        boolean charge(String customerId, int amountPaise);
    }

    private final PaymentGateway gateway;

    public OrderService(PaymentGateway gateway) {   // dependency is injected
        this.gateway = gateway;
    }

    public String checkout(String customerId, ShoppingCart cart) {
        if (cart.isEmpty()) return "EMPTY_CART";
        boolean paid = gateway.charge(customerId, cart.totalPaise());
        return paid ? "CONFIRMED" : "PAYMENT_FAILED";
    }
}

In the test, Mockito provides a fake PaymentGateway. Add org.mockito:mockito-junit-jupiter:5.24.0 with test scope:

src/test/java/com/elephantoo/shop/OrderServiceTest.java
package com.elephantoo.shop;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock
    OrderService.PaymentGateway gateway;          // a fake, programmable implementation

    @Test
    void confirmsWhenPaymentSucceeds() {
        ShoppingCart cart = new ShoppingCart();
        cart.add("Pen", 2500, 2);
        when(gateway.charge("cust-1", 5000)).thenReturn(true);

        String result = new OrderService(gateway).checkout("cust-1", cart);

        assertEquals("CONFIRMED", result);
        verify(gateway).charge("cust-1", 5000);   // it was called with exactly these arguments
    }

    @Test
    void reportsFailedPayment() {
        ShoppingCart cart = new ShoppingCart();
        cart.add("Pen", 2500, 1);
        when(gateway.charge(anyString(), anyInt())).thenReturn(false);

        assertEquals("PAYMENT_FAILED", new OrderService(gateway).checkout("cust-2", cart));
    }

    @Test
    void neverChargesForAnEmptyCart() {
        assertEquals("EMPTY_CART", new OrderService(gateway).checkout("cust-3", new ShoppingCart()));
        verify(gateway, never()).charge(anyString(), anyInt());
    }
}
  • @Mock creates a fake whose methods return defaults (false, 0, null) until you stub them with when(...).thenReturn(...).
  • verify(mock).method(args) checks that an interaction happened; never() checks that it didn't.
  • Argument matchers such as anyString() and anyInt() match any value.

On recent JDKs Mockito attaches an agent at runtime and prints a warning ("A Java agent has been loaded dynamically"). The Mockito documentation shows how to configure it as a -javaagent in Surefire or Gradle to silence the warning; the tests work either way.

Mock roles you own (your interfaces), not value objects like String or records. If a test needs many mocks, the class under test probably does too much.

The full run#

Output
[INFO] Tests run: 24, Failures: 0, Errors: 0, Skipped: 1
[INFO] BUILD SUCCESS

24 test executions across four classes, all in about a second. That speed is what lets you run them after every change.

What makes a good unit test (F.I.R.S.T.)#

  • Fast: milliseconds, so you run them constantly.
  • Independent: no shared mutable state, no order dependence.
  • Repeatable: same result every time, everywhere. Avoid real clocks, random numbers and networks; inject a Clock or a seeded Random.
  • Self-validating: pass or fail automatically, with no reading of logs.
  • Timely: written alongside the code, or before it.

Other habits that pay off:

  • One behaviour per test, with a name that states it: rejectsDiscountAboveFifty.
  • Test edge cases: empty input, zero, negative numbers, null, maximum values, duplicates.
  • Test behaviour through the public API, not private implementation details.
  • Keep test code as clean as production code.

Test-driven development (TDD) in one paragraph#

TDD flips the order: Red (write a failing test for the next small behaviour), Green (write the simplest code that passes), Refactor (clean up while the tests stay green), and repeat. Even if you don't practise strict TDD, writing the test first for bug fixes, reproducing the bug before fixing it, is a great habit.

Beyond unit tests#

  • Code coverage with JaCoCo shows which lines your tests execute. Useful for spotting gaps, but 100% coverage doesn't mean bug-free.
  • Integration tests check several parts together, e.g. your DAO against a real MySQL started by Testcontainers in Docker.
  • Spring Boot adds @SpringBootTest, @WebMvcTest and @DataJpaTest on top of JUnit 5.

Common mistakes#

  • Swapping expected and actual in assertEquals.
  • Tests that depend on each other or on shared static state.
  • Comparing doubles without a tolerance.
  • Catching exceptions in tests instead of using assertThrows.
  • Tests that pass even when the code is broken (no assertions, or asserting the wrong thing). Make a test fail once to see it work.
  • Mixing JUnit 4 (org.junit.Test, @Before) with JUnit 5 (org.junit.jupiter.api.Test, @BeforeEach) imports.
  • Gradle without useJUnitPlatform(): zero tests run and the build still "passes".

What's next#

You can now build and test like a professional. Next, we survey the most important language features of modern Java 17-21: sealed classes, pattern matching, record patterns, text blocks, virtual threads and more.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Which annotation runs a method before EACH test method in a JUnit 5 test class?

  2. 2.How do you check that cart.add("Pen", 2500, 0) throws an IllegalArgumentException?

  3. 3.What is the main purpose of a mock object, e.g. created with Mockito?

Finished reading?

Mark this lesson complete to track your progress.