Skip to main content

    Lesson 37 • Advanced

    Unit Testing with JUnit & Mockito

    By the end of this lesson you'll be able to write JUnit 5 tests that prove your code works — using assertions, lifecycle hooks, parameterized cases, and Mockito mocks — so you can refactor without fear and ship code teams trust.

    What You'll Learn

    • You'll be able to write a JUnit 5 test with @Test and assertEquals
    • You'll be able to test exceptions with assertThrows and group checks with assertAll
    • You'll be able to use @BeforeEach, @AfterEach, and @BeforeAll lifecycle hooks
    • You'll be able to cover many inputs with @ParameterizedTest, @ValueSource, @CsvSource
    • You'll be able to name tests clearly with @DisplayName and arrange-act-assert
    • You'll be able to isolate code with Mockito (mock, when, verify)

    Before You Start

    You should be comfortable with OOP (classes and interfaces, which Mockito mocks) and Annotations (JUnit is annotation-driven). A look at Exception Handling helps for the assertThrows parts.

    🧪 A Real-World Analogy

    💡 Analogy: A unit test is a smoke alarm for one room of your code. You set it up once, and from then on it watches that room silently. The day you change the wiring (refactor) and something starts to smoulder (a bug), the alarm goes off immediately — long before the fire reaches a user. A good test suite is a house full of alarms: cheap, automatic, and the reason you can sleep at night after deploying.

    Manually re-checking your whole app after every change is like walking every room with a candle. Tests do that walk for you, in milliseconds, every time — and they never forget a room.

    1️⃣ The Anatomy of a JUnit 5 Test

    A unit test is a small method that runs one slice of your code and checks the result is what you expect. In JUnit 5 you mark such a method with @Test, and inside it you call an assertion — a check that fails the test if it isn't true.

    The three assertions you'll use constantly:

    • assertEquals(expected, actual) — fails unless the two are equal. Expected comes first.
    • assertThrows(Exception.class, () -> code()) — passes only if that code throws.
    • assertAll(...) — runs several checks and reports all failures, not just the first.

    2️⃣ Lifecycle Hooks & Arrange–Act–Assert

    Tests must be independent — running test B should never depend on test A having run first. Lifecycle hooks give each test a clean slate:

    HookWhen it runsTypical use
    @BeforeEachBefore every @TestBuild fresh objects
    @AfterEachAfter every @TestClean up, reset state
    @BeforeAllOnce, before all (static)Expensive one-time setup
    @AfterAllOnce, after all (static)Shut down shared resources

    Inside each test, follow Arrange → Act → Assert:

    1. Arrange: set up the data and objects (often in @BeforeEach).

    2. Act: call the one method you're testing.

    3. Assert: check the result with assertions.

    Worked Example: a complete JUnit 5 test class
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.BeforeAll;
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Test;
    import static org.junit.jupiter.api.Assertions.*;   // assertEquals, assertThrows, assertAll...
    
    import java.util.ArrayList;
    import java.util.List;
    
    // ━━━ The class we want to test ━━━
    class ShoppingCart {
        record Item(String name, double price, int qty) {}
        private final List<Item> items = new ArrayList<>();
    
        void addItem(String name, double price, int qty) {
            if (price < 0) throw new IllegalArgumentException("Price cannot be negative");
            if (qty < 1) throw new IllegalArgumentException("Quantity must be at least 1");
            items.add(new Item(name, price, qty));
        }
        int distinctItems() { return items.size(); }
        int getItemCount() { return items.stream().mapToInt(Item::qty).sum(); }
        double getTotal() { return items.stream().mapToDouble(i -> i.price() * i.qty()).sum(); }
    }
    
    // ━━━ The JUnit 5 test class — note the name ends in "Test" ━━━
    class ShoppingCartTest {
        private ShoppingCart cart;
    
        @BeforeAll
        static void banner() {            // runs ONCE, before any test
            System.out.println("Starting ShoppingCart tests");
        }
    
        @BeforeEach
        void setUp() {                    // runs before EACH @Test
            cart = new ShoppingCart();    // a fresh cart, so tests can't pollute each other
        }
    
        @AfterEach
        void tearDown() {                 // runs after EACH @Test (close files, reset state...)
            cart = null;
        }
    
        @Test
        @DisplayName("a brand-new cart starts empty")   // human-readable name in the report
        void newCartIsEmpty() {
            // Arrange happened in setUp(); Act + Assert below
            assertEquals(0, cart.distinctItems());   // expected FIRST, actual SECOND
            assertEquals(0, cart.getTotal());        // -> passes: cart is empty
        }
    
        @Test
        void addingItems_updatesCountAndTotal() {
            cart.addItem("Laptop", 999.99, 1);                 // Act
            cart.addItem("Mouse", 29.99, 2);
            assertEquals(2, cart.distinctItems());             // 2 distinct products
            assertEquals(3, cart.getItemCount());              // 1 + 2 units
            assertEquals(1059.97, cart.getTotal(), 0.001);     // delta: doubles are inexact
        }
    
        @Test
        void addingBadInput_throws() {
            // assertThrows captures the exception so the test PASSES when it is thrown
            assertThrows(IllegalArgumentException.class, () -> cart.addItem("X", -10, 1));
            assertThrows(IllegalArgumentException.class, () -> cart.addItem("X", 10, 0));
        }
    }
    Output
    $ mvn -q test
    
    [INFO]  T E S T S
    [INFO] -------------------------------------------------------
    [INFO] Running ShoppingCartTest
    Starting ShoppingCart tests
    [INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
    [INFO]
    [INFO] BUILD SUCCESS
    This is real JUnit 5 (and Mockito) code. Add the JUnit/Mockito dependencies and run it with mvn test or gradle test in a project — the test runner compiles and executes each @Test method and reports pass/fail. It will not run in a plain online compiler that lacks the test libraries.

    3️⃣ Mockito — Testing a Class in Isolation

    Real classes have dependencies — a UserService needs a UserRepository that talks to a database. You don't want a real database in a unit test: it's slow and flaky. A mock is a fake stand-in you control.

    Mockito gives you three verbs:

    • mock / @Mock — create a fake of an interface or class.
    • when(mock.call()).thenReturn(value) — program ("stub") what the fake returns.
    • verify(mock).call() — assert the fake was actually used as expected.

    Mark the class under test with @InjectMocks and Mockito wires the mocks into it for you.

    Worked Example: Mockito mock, when, verify
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.InjectMocks;
    import org.mockito.Mock;
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import java.util.Optional;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.Mockito.*;        // mock, when, verify, times, any...
    
    record User(Long id, String name, String email) {}
    
    interface UserRepository {                  // a dependency — talks to a database
        Optional<User> findById(Long id);
        User save(User user);
    }
    
    // ━━━ Class under test: it DEPENDS on UserRepository ━━━
    class UserService {
        private final UserRepository repo;
        UserService(UserRepository repo) { this.repo = repo; }
    
        User getUser(Long id) {
            return repo.findById(id)
                       .orElseThrow(() -> new IllegalStateException("User not found: " + id));
        }
        User createUser(String name, String email) {
            return repo.save(new User(null, name, email));
        }
    }
    
    @ExtendWith(MockitoExtension.class)         // turns @Mock / @InjectMocks on
    class UserServiceTest {
        @Mock UserRepository repo;              // a FAKE repo — no real database
        @InjectMocks UserService service;      // the fake is injected into the service
    
        @Test
        void getUser_returnsUser_whenFound() {
            // Arrange: program the mock — "when findById(1L) is called, return Alice"
            when(repo.findById(1L))
                .thenReturn(Optional.of(new User(1L, "Alice", "alice@test.com")));
            // Act
            User user = service.getUser(1L);
            // Assert the outcome...
            assertEquals("Alice", user.name());
            // ...and verify the interaction actually happened
            verify(repo).findById(1L);
        }
    
        @Test
        void getUser_throws_whenMissing() {
            when(repo.findById(99L)).thenReturn(Optional.empty());
            assertThrows(IllegalStateException.class, () -> service.getUser(99L));
        }
    
        @Test
        void createUser_callsSaveExactlyOnce() {
            when(repo.save(any(User.class)))            // any() = match any User argument
                .thenReturn(new User(42L, "Bob", "bob@test.com"));
            User created = service.createUser("Bob", "bob@test.com");
            assertEquals(42L, created.id());
            verify(repo, times(1)).save(any(User.class));   // called exactly once
        }
    }
    This is real JUnit 5 (and Mockito) code. Add the JUnit/Mockito dependencies and run it with mvn test or gradle test in a project — the test runner compiles and executes each @Test method and reports pass/fail. It will not run in a plain online compiler that lacks the test libraries.

    🎯 Your Turn #1 — Parameterized tests & assertAll

    Fill in the three ___ blanks below to make the suite pass. You'll wire up @ValueSource, @CsvSource, and assertAll. The expected result is in the last comment.

    Your Turn: fill in the blanks
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.params.ParameterizedTest;
    import org.junit.jupiter.params.provider.ValueSource;
    import org.junit.jupiter.params.provider.CsvSource;
    import static org.junit.jupiter.api.Assertions.*;
    
    class Calc {
        int add(int a, int b) { return a + b; }
        boolean isEven(int n) { return n % 2 == 0; }
    }
    
    class CalcTest {
        private final Calc calc = new Calc();
    
        // 🎯 YOUR TURN — fill in the blanks marked with ___
    
        // 1) Run isEven() against MANY numbers without copy-pasting the test.
        @ParameterizedTest
        @ValueSource(ints = {2, 4, 100, -8})   // 👉 each value is fed in as "n"
        void evenNumbersAreEven(int n) {
            assertTrue(___);                   // 👉 call calc.isEven(n)
        }
    
        // 2) @CsvSource gives each row as comma-separated arguments.
        @ParameterizedTest
        @CsvSource({ "2, 3, 5", "10, 5, 15", "-1, 1, 0" })   // a, b, expected
        void addingWorks(int a, int b, int expected) {
            assertEquals(___, calc.add(a, b));  // 👉 expected goes FIRST
        }
    
        // 3) assertAll evaluates EVERY assertion and reports all failures at once.
        @Test
        void groupedChecks() {
            assertAll(
                () -> assertEquals(7, calc.add(3, 4)),
                () -> ___                       // 👉 assertTrue that calc.isEven(4)
            );
        }
    }
    
    // ✅ Expected: Tests run: 8, Failures: 0  (4 values + 3 rows + 1 grouped test)
    This is real JUnit 5 (and Mockito) code. Add the JUnit/Mockito dependencies and run it with mvn test or gradle test in a project — the test runner compiles and executes each @Test method and reports pass/fail. It will not run in a plain online compiler that lacks the test libraries.

    🎯 Your Turn #2 — Stub and verify a mock

    Finish this Mockito test: stub the fake PriceApi with when().thenReturn(), then verify() it was consulted. Two blanks, expected output in the comment.

    Your Turn: complete the Mockito test
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.InjectMocks;
    import org.mockito.Mock;
    import org.mockito.junit.jupiter.MockitoExtension;
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.Mockito.*;
    
    interface PriceApi { double priceOf(String symbol); }
    
    class Portfolio {
        private final PriceApi api;
        Portfolio(PriceApi api) { this.api = api; }
        double value(String symbol, int shares) { return api.priceOf(symbol) * shares; }
    }
    
    @ExtendWith(MockitoExtension.class)
    class PortfolioTest {
        @Mock PriceApi api;            // fake API — no real network call
        @InjectMocks Portfolio portfolio;
    
        // 🎯 YOUR TURN — finish this Mockito test
    
        @Test
        void value_multipliesPriceByShares() {
            // 1) Stub: when priceOf("AAPL") is asked, return 200.0
            when(api.priceOf("AAPL")).thenReturn(___);   // 👉 use 200.0
    
            // 2) Act: 3 shares of AAPL
            double total = portfolio.value("AAPL", 3);
    
            // 3) Assert the maths, then verify the API was actually consulted
            assertEquals(600.0, total, 0.001);
            verify(___).priceOf("AAPL");                 // 👉 verify the mock 'api'
        }
    }
    
    // ✅ Expected: Tests run: 1, Failures: 0  (600.0 == 200.0 * 3)
    This is real JUnit 5 (and Mockito) code. Add the JUnit/Mockito dependencies and run it with mvn test or gradle test in a project — the test runner compiles and executes each @Test method and reports pass/fail. It will not run in a plain online compiler that lacks the test libraries.

    🧩 Mini-Challenge — Test a BankAccount (no scaffold)

    This time only the plan is given — you write the test methods. Use a @BeforeEach, an assertEquals, an assertThrows, and a @CsvSource case. Follow the numbered comments.

    Mini-Challenge: write the tests yourself
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.params.ParameterizedTest;
    import org.junit.jupiter.params.provider.CsvSource;
    import static org.junit.jupiter.api.Assertions.*;
    
    class BankAccount {
        private double balance;
        void deposit(double amount) {
            if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
            balance += amount;
        }
        void withdraw(double amount) {
            if (amount > balance) throw new IllegalStateException("Insufficient funds");
            balance -= amount;
        }
        double getBalance() { return balance; }
    }
    
    class BankAccountTest {
        // 🎯 MINI-CHALLENGE: test BankAccount yourself (no code given — only the plan)
        //
        // 1. Add a @BeforeEach that creates a FRESH BankAccount field before each test.
        // 2. @Test: deposit 100, then assertEquals the balance is 100 (delta 0.001).
        // 3. @Test: withdrawing more than the balance should assertThrows
        //           IllegalStateException.class.
        // 4. @ParameterizedTest + @CsvSource with rows "100, 30, 70" and "50, 50, 0"
        //    (deposit, withdraw, expected) — deposit, withdraw, then assertEquals.
        //
        // ✅ Expected when complete: all tests green, e.g. "Tests run: 4, Failures: 0".
    
        // your test methods here
    }
    This is real JUnit 5 (and Mockito) code. Add the JUnit/Mockito dependencies and run it with mvn test or gradle test in a project — the test runner compiles and executes each @Test method and reports pass/fail. It will not run in a plain online compiler that lacks the test libraries.

    Common Errors & Fixes

    • Testing implementation, not behaviour: asserting that a private helper was called ties the test to how the code works, so every harmless refactor breaks it. Fix: assert the observable outcome (the return value, the thrown exception), not the internal steps.
    • No isolation between tests: reusing one shared object across tests lets test A leave data that makes test B pass or fail at random. Fix: rebuild state in @BeforeEach so every test starts clean.
    • Over-mocking: mocking your own logic or value objects means the test only checks the mock setup, not real correctness — it passes even when the code is wrong. Fix: mock only external dependencies (DB, API, clock); use real objects for plain logic.
    • assertEquals arguments backwards: assertEquals(actual, expected) still passes/fails correctly but prints a misleading "expected: <x> but was: <y>". Fix: always put the known-correct value first: assertEquals(expected, actual).
    • Comparing doubles without a delta: assertEquals(0.3, a + b) can fail on rounding. Fix: add a tolerance: assertEquals(0.3, a + b, 0.001).

    Pro Tips

    • 💡 @DisplayName turns cryptic method names into readable report lines: @DisplayName("rejects a blank email").
    • 💡 @ParameterizedTest covers twenty edge cases in one method — and reports exactly which input failed.
    • 💡 assertAll shows every broken expectation in one run, saving fix-rerun-fix cycles.
    • 💡 Red → Green → Refactor: write a failing test, write the minimum code to pass, then clean up while the test stays green.

    📋 Quick Reference

    Annotation / MethodPurposeExample
    @TestMark a test method@Test void works() {...}
    @BeforeEachSetup before each testcart = new Cart();
    @DisplayNameReadable test name@DisplayName("starts empty")
    @ParameterizedTestRun with many inputswith @ValueSource / @CsvSource
    assertEqualsCheck equalityassertEquals(expected, actual)
    assertThrowsCheck it throwsassertThrows(Ex.class, () -> ...)
    assertAllGroup assertionsassertAll(() -> ..., () -> ...)
    @Mock / whenFake + stub a dependencywhen(m.x()).thenReturn(v)
    verifyCheck mock was calledverify(m).x()

    Frequently Asked Questions

    What is the difference between JUnit and Mockito?

    JUnit is the test framework: it finds methods marked @Test, runs them, and reports pass or fail through assertions like assertEquals and assertThrows. Mockito is a separate library that creates fake versions (mocks) of a class's dependencies — such as a database repository or web API — so you can test one class in isolation without those real, slow, or unavailable services. You almost always use them together: JUnit runs the test, Mockito fakes the collaborators.

    Why does assertEquals(0, cart.getTotal()) put 0 first?

    JUnit's assertEquals signature is assertEquals(expected, actual). The expected value goes first, the value your code produced goes second. Getting the order wrong does not change whether the test passes or fails, but it flips the failure message — JUnit will say 'expected: <5> but was: <0>' with the wrong labels, which sends you debugging in the wrong direction. Always put the known correct value first.

    When should I use a mock instead of a real object?

    Mock only the things that are slow, external, non-deterministic, or have side effects: databases, HTTP APIs, the file system, the clock, email senders. Use the real object for plain logic and value objects (your own calculations, records, lists). Over-mocking — replacing real logic with mocks — means your test only checks that you wired the mock the way you said, not that the code is correct.

    What is @BeforeEach for, and how is it different from @BeforeAll?

    @BeforeEach runs before every single @Test method, giving each test a clean, freshly-built starting state so one test cannot leak data into the next. @BeforeAll runs only once, before any test in the class, and must be static — use it for expensive one-time setup (starting an in-memory server, loading a big file). For most unit tests you want @BeforeEach so tests stay independent.

    Why use @ParameterizedTest instead of writing many @Test methods?

    When the same logic should hold for many inputs, @ParameterizedTest lets you write the test once and feed it a list of cases with @ValueSource (single values) or @CsvSource (rows of comma-separated arguments). You avoid copy-pasting, each case is reported separately so you see exactly which input failed, and adding a new case is one line. It is the standard way to cover boundary and edge values cheaply.

    How do I test that a method throws an exception?

    Wrap the call in assertThrows(SomeException.class, () -> code()). The lambda holds the code expected to throw; the test passes if that exception type (or a subclass) is thrown and fails if nothing is thrown. assertThrows also returns the caught exception, so you can chain assertEquals on its getMessage() to check the error text.

    🎉 Lesson Complete!

    You can now write professional unit tests: @Test methods with assertEquals, assertThrows, and assertAll; clean state via @BeforeEach/@AfterEach/@BeforeAll; broad coverage with @ParameterizedTest; clear names with @DisplayName; and isolation with Mockito's mock/when/verify — all structured as arrange–act–assert.

    Next up: Maven & Gradle — the build tools that pull in JUnit and Mockito and run your whole test suite with one command.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service