Unit Testing
Reviewed & published by Brayan K
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.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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:
| Hook | When it runs | Typical use |
|---|---|---|
| @BeforeEach | Before every @Test | Build fresh objects |
| @AfterEach | After every @Test | Clean up, reset state |
| @BeforeAll | Once, before all (static) | Expensive one-time setup |
| @AfterAll | Once, 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.
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));
}
}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.
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", "[email protected]")));
// 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", "[email protected]"));
User created = service.createUser("Bob", "[email protected]");
assertEquals(42L, created.id());
verify(repo, times(1)).save(any(User.class)); // called exactly once
}
}🎯 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.
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)🎯 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.
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)🧩 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.
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
}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 / Method | Purpose | Example |
|---|---|---|
| @Test | Mark a test method | @Test void works() {...} |
| @BeforeEach | Setup before each test | cart = new Cart(); |
| @DisplayName | Readable test name | @DisplayName("starts empty") |
| @ParameterizedTest | Run with many inputs | with @ValueSource / @CsvSource |
| assertEquals | Check equality | assertEquals(expected, actual) |
| assertThrows | Check it throws | assertThrows(Ex.class, () -> ...) |
| assertAll | Group assertions | assertAll(() -> ..., () -> ...) |
| @Mock / when | Fake + stub a dependency | when(m.x()).thenReturn(v) |
| verify | Check mock was called | verify(m).x() |
🎉 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.
Practice quiz
What is the difference between JUnit and Mockito?
- They are two names for the same library
- Mockito runs the tests; JUnit makes mocks
- JUnit is the test framework that runs tests; Mockito creates fake versions of dependencies
- JUnit only works with Mockito
Answer: JUnit is the test framework that runs tests; Mockito creates fake versions of dependencies. JUnit finds @Test methods, runs them, and reports pass/fail. Mockito fakes a class's collaborators (DB, API) so you can test in isolation. They are used together.
What is the correct argument order for assertEquals?
- assertEquals(expected, actual)
- assertEquals(actual, expected)
- The order does not matter at all
- assertEquals(message, expected)
Answer: assertEquals(expected, actual). The expected (known-correct) value goes first, the value your code produced goes second. Reversing it flips the failure message labels and misleads debugging.
How do you test that a method throws an exception in JUnit 5?
- try { code(); } catch (Exception e) { pass(); }
- assertEquals(Exception.class, code())
- @Test(expected = Exception.class) only
- assertThrows(SomeException.class, () -> code())
Answer: assertThrows(SomeException.class, () -> code()). assertThrows(SomeException.class, () -> code()) passes when that exception type (or a subclass) is thrown, and returns the caught exception for further checks.
What does @BeforeEach do?
- Runs once before all tests in the class
- Runs before every single @Test method, giving each a clean starting state
- Runs after each test
- Marks a test method
Answer: Runs before every single @Test method, giving each a clean starting state. @BeforeEach runs before every @Test so each test gets fresh state and cannot leak data into the next. @BeforeAll runs only once and must be static.
Why use @ParameterizedTest instead of many separate @Test methods?
- It writes the test once and feeds many inputs, reporting each case separately
- It runs tests faster on the GPU
- It hides failing cases
- It is required for every test
Answer: It writes the test once and feeds many inputs, reporting each case separately. @ParameterizedTest with @ValueSource or @CsvSource avoids copy-pasting, reports exactly which input failed, and makes adding a case one line.
What does Mockito's when(mock.call()).thenReturn(value) do?
- Verifies the mock was called
- Creates a real object
- Stubs the fake so it returns the given value for that call
- Throws an exception
Answer: Stubs the fake so it returns the given value for that call. when(...).thenReturn(...) programs (stubs) what the fake returns. verify(mock).call() is the separate step that asserts the call actually happened.
When should you use a mock instead of a real object?
- For your own plain logic and value objects
- For slow, external, or non-deterministic dependencies like databases and HTTP APIs
- For everything, always
- Never use mocks
Answer: For slow, external, or non-deterministic dependencies like databases and HTTP APIs. Mock only slow/external/non-deterministic collaborators. Over-mocking your own logic means the test only checks the mock wiring, not real correctness.
Why does comparing doubles in assertEquals often need a delta, e.g. assertEquals(1059.97, total, 0.001)?
- Deltas make tests run faster
- It is required for all assertEquals calls
- The delta is the expected value
- Doubles are inexact, so an exact comparison can fail on rounding
Answer: Doubles are inexact, so an exact comparison can fail on rounding. Floating-point arithmetic is inexact, so a + b might be 0.30000000000000004. A small tolerance (delta) makes the comparison robust.
What does assertAll do?
- Stops at the first failing assertion
- Evaluates every grouped assertion and reports all failures at once
- Runs all test classes
- Asserts a mock was called
Answer: Evaluates every grouped assertion and reports all failures at once. assertAll runs each grouped lambda assertion and reports every failure in one run, saving fix-rerun-fix cycles.
What does @InjectMocks do in a Mockito test?
- Creates a fake of an interface
- Runs the test in parallel
- Injects the created @Mock dependencies into the class under test
- Verifies all mocks were used
Answer: Injects the created @Mock dependencies into the class under test. @InjectMocks marks the real class under test and wires the @Mock fakes into it automatically, so you test that class against controlled dependencies.
Continue this course
- Previous: JSON & XML Parsing with Jackson & JAXB
- Next: Maven & Gradle: Dependency Management at Scale — Manage dependencies, build lifecycle, and plugins with Maven and Gradle
- Quick reference: Java cheat sheet
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.