Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 25 of 41

Lesson 25 · Tools and Craft

Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits

Learn to write reliable unit tests in Java with JUnit: structure, assertions, exception and parameterised tests, test lifecycle, then test doubles and Mockito, and the honest limits of mocking, all run in a real Maven project.

On this page

Key takeaways

  • A good unit test is small, fast, deterministic, independent and checks one behaviour. Name it for the behaviour, arrange the inputs, act once, and assert the outcome.
  • JUnit gives you @Test, @BeforeEach, assertions such as assertEquals and assertThrows, and @ParameterizedTest for many inputs. By default a new test class instance is created for each test, so tests do not share state.
  • Test the boundaries: zero, one, empty, maximum, just inside and just outside the valid range, and every error path.
  • A mock replaces a collaborator (a database, a payment service) so you can test one class alone. Use when/thenReturn to script it, verify to check an important interaction, and ArgumentCaptor to inspect what was passed.
  • Mocks are a tool, not the goal. Over-mocking couples tests to implementation, and mocks prove nothing about the real integration, so a test suite also needs tests with real components.

"It works on my machine" is not evidence. A test is a small program that runs your code with known inputs and checks the result, so you find out that something broke in seconds instead of in production. Automated tests are also what lets you change code with confidence: you refactor, run the tests, and know whether you still have the behaviour you had.

This article covers unit tests with JUnit, the standard testing framework for Java, and Mockito, the most widely used library for replacing collaborators. Every test shown here was run in a real Maven project.

What you will learn

#
  • What makes a unit test good, and how to structure one.
  • JUnit essentials: assertions, exception tests, parameterised tests and the lifecycle.
  • Test doubles, and how to use Mockito.
  • What to test, what not to test, and the limits of mocking.

What a unit test is

#

A unit test checks a small piece of behaviour (usually one class) in isolation, runs in milliseconds, needs no database, network or file system, and gives the same result every time. Other levels of tests exist, and a healthy project has several:

Level

Checks

Speed

You will meet it

Unit

One class or method, collaborators replaced

Milliseconds

This article

Integration

Several real components together, such as the code and a database

Seconds

Articles 29 and 37

End-to-end

The whole system through its real interface

Slow

Mentioned in article 37

The usual advice is a pyramid: many fast unit tests at the base, fewer integration tests, very few end-to-end tests at the top.

A good unit test is:

  • Fast, so you run them all the time.
  • Deterministic: no dependence on the current time, random numbers, ordering or network. (Inject a Clock, as in article 8, instead of calling LocalDate.now().)
  • Independent: it passes or fails regardless of which other tests ran before.
  • Focused: it checks one behaviour, so when it fails you know why.
  • Readable: a failing test should explain what is broken.

The project and the code under test

#

The tests below use a small shop domain, deliberately separate from the projects later in the course. The project layout and pom.xml follow article 23. The only parts of the POM that differ from that article are these two test dependencies (JUnit's aggregate artifact, and Mockito's JUnit integration, which brings in Mockito itself):

XML
<dependency>  <groupId>org.junit.jupiter</groupId>  <artifactId>junit-jupiter</artifactId>  <version>${junit.version}</version>  <scope>test</scope></dependency><dependency>  <groupId>org.mockito</groupId>  <artifactId>mockito-junit-jupiter</artifactId>  <version>${mockito.version}</version>  <scope>test</scope></dependency>

Here is the first class to test:

Java
// File: src/main/java/com/example/shop/PriceCalculator.javapackage com.example.shop;public class PriceCalculator {    /** Price in cents after a percentage discount, rounded down to a whole cent. */    public int priceAfterDiscount(int priceCents, int discountPercent) {        if (priceCents < 0) {            throw new IllegalArgumentException("price must not be negative: " + priceCents);        }        if (discountPercent < 0 || discountPercent > 100) {            throw new IllegalArgumentException("discount must be between 0 and 100: " + discountPercent);        }        long discounted = (long) priceCents * (100 - discountPercent) / 100;        return (int) discounted;    }}

Money is held in whole cents as an int because floating-point numbers cannot represent most decimal fractions exactly (article 2). The multiplication is done in long first so that large prices cannot overflow.

Your first tests with JUnit

#
Java
// File: src/test/java/com/example/shop/PriceCalculatorTest.javapackage com.example.shop;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;import org.junit.jupiter.api.BeforeEach;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;class PriceCalculatorTest {    private PriceCalculator calculator;    @BeforeEach    void setUp() {        calculator = new PriceCalculator();      // a fresh object for every test    }    @Test    @DisplayName("a 10 percent discount takes a tenth off")    void appliesPercentageDiscount() {        assertEquals(900, calculator.priceAfterDiscount(1000, 10));    }    @ParameterizedTest(name = "{0} cents at {1}% off costs {2} cents")    @CsvSource({            "1000, 0,   1000",            "1000, 100, 0",            "999,  10,  899",            "1,    50,  0",            "0,    25,  0"    })    void boundaryAndRoundingCases(int price, int percent, int expected) {        assertEquals(expected, calculator.priceAfterDiscount(price, percent));    }    @Test    void rejectsDiscountAbove100() {        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,                () -> calculator.priceAfterDiscount(1000, 101));        assertEquals("discount must be between 0 and 100: 101", e.getMessage());    }    @Test    void rejectsNegativePrice() {        assertThrows(IllegalArgumentException.class, () -> calculator.priceAfterDiscount(-1, 10));    }}

Running mvn test found and ran all eight test executions in this class: one plain test, five parameterised cases, and two exception tests, and reported Tests run: 8, Failures: 0, Errors: 0, Skipped: 0. What each part does:

  • @Test marks a method as a test. Test methods are void, take no arguments (except parameterised tests), and in JUnit Jupiter they and the class can be package-private, so no public is needed.
  • Assertions such as assertEquals(expected, actual) fail the test when the check is false. The order is expected first, actual second, which matters because the failure message uses it ("expected: 900 but was: 901"). Others you will use: assertTrue, assertFalse, assertNull, assertNotNull, assertSame, assertArrayEquals, assertAll (several checks, all reported), and assertThrows.
  • assertThrows(Type.class, () -> code) checks that the code throws that exception type, and returns the exception so you can check its message. This replaces the old pattern of try/catch with fail().
  • @BeforeEach runs before every test. @AfterEach runs after (closing resources). @BeforeAll/@AfterAll run once per class, on static methods by default.
  • @DisplayName gives a readable name in reports. Plain, descriptive method names (rejectsDiscountAbove100) also work well.
  • @ParameterizedTest runs the same test with different data, here from @CsvSource (each row is one case). Other sources include @ValueSource, @EnumSource and @MethodSource.

Older style. Older projects use JUnit 4: the annotations come from org.junit (org.junit.Test, @Before, @After), test classes and methods must be public, and exceptions are tested with @Test(expected = ...) or a rule. JUnit 5 and later (the package org.junit.jupiter) replaced these. Existing Java code bases often still contain JUnit 4 tests. The ideas are the same, but the imports and annotations differ, and you must not mix the two within one test class.

Why each test gets a fresh object

#

By default, JUnit creates a new instance of the test class for every test method, so the field calculator in one test cannot leak state into another. This is the property that makes tests independent. The @BeforeEach method then prepares each instance. (JUnit can be configured to use one instance per class, but only do that when you understand what shared state you are introducing.)

What a failing test tells you

#

A test that never fails is worthless until you have seen it fail. These are real failures, produced by deliberately breaking priceAfterDiscount (an off-by-one that rounds up) and running the tests. The build stops, and each failing case is reported with its numbers:

Code
[ERROR] Tests run: 8, Failures: 6, Errors: 0, Skipped: 0 <<< FAILURE! -- in com.example.shop.PriceCalculatorTest[ERROR]   PriceCalculatorTest.appliesPercentageDiscount:23 expected: <900> but was: <901>[ERROR]   PriceCalculatorTest.boundaryAndRoundingCases:35 expected: <1000> but was: <1001>[ERROR]   PriceCalculatorTest.boundaryAndRoundingCases:35 expected: <0> but was: <1>[ERROR]   PriceCalculatorTest.boundaryAndRoundingCases:35 expected: <899> but was: <900>

The report names the test, the line, and the expected and actual values. Because the parameterised test lists each case, you can see which input failed. Another deliberate break, changing the validation to reject a discount of exactly 100, produced a different kind of report:

Code
[ERROR]   PriceCalculatorTest.boundaryAndRoundingCases:35 IllegalArgument discount must be between 0 and 100: 100

JUnit separates a failure (an assertion was false) from an error (the code threw an unexpected exception). Both mean the test did not pass, but they point at different problems. This second case caught a boundary mistake because the table includes the edge value 100.

Deciding what to test

#

For each unit, test:

  • The normal case (a typical input and expected result).
  • Boundaries: zero, one, the maximum, the first and last valid values, and the values just outside (-1, 101). Many bugs live on a boundary.
  • Empty and absent: empty strings and lists, null where it is possible.
  • Error paths: that the right exception is thrown with a useful message.
  • Rounding and arithmetic, such as 999 cents at 10% off becoming 899, not 900.
  • State changes: that a method that should change the object really did.

Do not test:

  • The language or the library (that ArrayList.add works).
  • Trivial getters and setters, with no logic.
  • Private methods directly: test them through the public behaviour that uses them. If a private method seems to need its own test, it may be a separate class waiting to be extracted.
  • Implementation details that could change while behaviour stays the same. Tests that break on every refactor are a cost, not a benefit.

A test should read as Arrange, Act, Assert: set up the inputs, perform one action, check the outcome. If you need several unrelated actions, you probably need several tests.

Test-driven development, briefly

#

TDD is a way of working: write a failing test for the next small behaviour, write just enough code to pass it, then clean up (refactor) with the tests as a safety net. You do not have to adopt it fully, but writing the test first for a bug fix is always a good habit: the test reproduces the bug (it fails), the fix makes it pass, and it stays in the suite to stop the bug returning (article 22).

Test doubles and Mockito

#

Real classes have collaborators: a payment service that charges a real card, a repository that writes to a database. A unit test must not do that. You replace the collaborator with a test double. The common kinds:

Kind

What it is

Example

Dummy

Passed in but never used

A null or empty object to fill a parameter

Stub

Returns fixed answers

A gateway that always returns "pay-77"

Fake

A simplified working implementation

An in-memory repository backed by a HashMap

Mock

A double that also records the calls it receives, so a test can check them

"was save called once with this order?"

Spy

A wrapper around a real object that records calls

Partial replacement of a real object

Mockito creates stubs, mocks and spies for you at run time, so you do not have to write the classes by hand.

Doubles work because of the design principle from article 6: program against an interface, and receive collaborators through the constructor, so a test can supply a double. Here is the code under test:

Java
// File: src/main/java/com/example/shop/PaymentGateway.javapackage com.example.shop;public interface PaymentGateway {    /** Charges the customer and returns a payment reference. */    String charge(String customerId, int amountCents);}
Java
// File: src/main/java/com/example/shop/OrderRepository.javapackage com.example.shop;public interface OrderRepository {    void save(Order order);}
Java
// File: src/main/java/com/example/shop/Order.javapackage com.example.shop;public record Order(String customerId, int amountCents, String paymentReference) {}
Java
// File: src/main/java/com/example/shop/PaymentFailedException.javapackage com.example.shop;public class PaymentFailedException extends RuntimeException {    public PaymentFailedException(String message) {        super(message);    }}
Java
// File: src/main/java/com/example/shop/OrderService.javapackage com.example.shop;public class OrderService {    private final PaymentGateway gateway;    private final OrderRepository repository;    public OrderService(PaymentGateway gateway, OrderRepository repository) {        this.gateway = gateway;        this.repository = repository;    }    public Order placeOrder(String customerId, int amountCents) {        if (amountCents <= 0) {            throw new IllegalArgumentException("amount must be positive: " + amountCents);        }        String reference = gateway.charge(customerId, amountCents);   // fails by throwing        Order order = new Order(customerId, amountCents, reference);        repository.save(order);                                        // only after a successful charge        return order;    }}

The behaviour worth testing is the ordering rule: an order must be saved only after a successful charge, and never when the payment fails or the amount is invalid. Without doubles, testing "payment fails" would need a real gateway that you can make fail on demand. With Mockito:

Java
// File: src/test/java/com/example/shop/OrderServiceTest.javapackage com.example.shop;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;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.ArgumentCaptor;import org.mockito.InjectMocks;import org.mockito.Mock;import org.mockito.junit.jupiter.MockitoExtension;@ExtendWith(MockitoExtension.class)class OrderServiceTest {    @Mock    PaymentGateway gateway;    @Mock    OrderRepository repository;    @InjectMocks    OrderService service;    @Test    void savesTheOrderAfterASuccessfulCharge() {        when(gateway.charge("c-1", 2500)).thenReturn("pay-77");        Order order = service.placeOrder("c-1", 2500);        assertEquals("pay-77", order.paymentReference());        ArgumentCaptor<Order> saved = ArgumentCaptor.forClass(Order.class);        verify(repository).save(saved.capture());        assertEquals(new Order("c-1", 2500, "pay-77"), saved.getValue());    }    @Test    void doesNotSaveWhenThePaymentFails() {        when(gateway.charge(anyString(), anyInt())).thenThrow(new PaymentFailedException("card declined"));        assertThrows(PaymentFailedException.class, () -> service.placeOrder("c-1", 2500));        verify(repository, never()).save(org.mockito.ArgumentMatchers.any());    }    @Test    void rejectsANonPositiveAmountWithoutCharging() {        assertThrows(IllegalArgumentException.class, () -> service.placeOrder("c-1", 0));        org.mockito.Mockito.verifyNoInteractions(gateway, repository);    }}

All three passed (Tests run: 3, Failures: 0). The Mockito pieces:

  • @ExtendWith(MockitoExtension.class) connects Mockito to JUnit's lifecycle, so the annotated fields are created before each test.
  • @Mock creates a mock of the interface. @InjectMocks creates the object under test and passes the mocks to its constructor.
  • when(call).thenReturn(value) is stubbing: when gateway.charge("c-1", 2500) is called, return "pay-77". thenThrow(...) makes it throw. A mock that is not stubbed returns a default (null, 0, false, or an empty collection).
  • Argument matchers such as anyString() and anyInt() match any value. A subtle rule: if you use a matcher for one argument you must use matchers for all arguments of that call.
  • verify(mock).method(...) checks the method was called exactly once with those arguments. verify(mock, never()) checks it was not called. verifyNoInteractions checks that nothing at all was called.
  • ArgumentCaptor captures an argument that was passed, so you can assert on its contents, which is how the first test confirms that the saved order has the right values.

The extension also applies strict stubs: if a test sets up a when(...) that is never used, the test fails with an "unnecessary stubbing" error. That keeps tests clean, because a stub nobody uses is leftover noise.

A warning you will see on newer JDKs

#

When these tests ran on JDK 21, the output contained this real warning:

Code
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3WARNING: A Java agent has been loaded dynamically (...byte-buddy-agent-1.17.7.jar)WARNING: Dynamic loading of agents will be disallowed by default in a future release

Mockito's default way of mocking classes loads a Java agent while the tests run, and recent JDKs warn about dynamically loaded agents, because the JDK is moving towards making that opt-in. The tests still pass. The warning itself points to Mockito's documentation, which describes how to register Mockito as an agent in your build. Treat it as a note to configure the build properly, and do not ignore it forever: a future JDK may refuse the dynamic attach.

The limits of mocking

#

Mocks are easy to overuse. Know their costs.

  1. A mock only does what you told it. If you stub gateway.charge to return "pay-77", you have proved your service handles that answer. You have not proved the real gateway returns it, that the real SQL is right, or that the two components fit together. Mocks cannot find integration problems. That is the job of integration tests.
  2. Over-mocking couples the test to the implementation. If a test verifies exactly which internal methods were called in which order, then a harmless refactor breaks the test even though behaviour is unchanged. Verify outcomes and important side effects (the order was saved, the card was not charged twice), not every call.
  3. Do not mock what you do not own and do not mock simple values. Do not mock String, List, records or your own plain data classes. Create the real thing. When the collaborator is a library (a database driver, an HTTP client), wrap it in your own interface and mock that, and test the wrapper against the real library separately.
  4. A fake can be better than a mock. An in-memory repository (a HashMap that implements OrderRepository) lets a test read back what was stored, instead of verifying calls. It is more code, but it survives refactoring better.
  5. Mocking your own class under test (a spy that stubs half the class) is a smell. It usually means the class does too much and should be split.
  6. Many mocks in one test means the class has too many dependencies. Treat that as design feedback.

Test quality habits

#
  • Name the behaviour, not the method: doesNotSaveWhenThePaymentFails tells a reader (and a failure report) far more than testPlaceOrder2.
  • Keep tests independent of order and of each other.
  • No logic in tests: loops and branching inside a test make it a program that needs its own tests. Prefer a table of cases.
  • Do not use real time, randomness, the network or the file system in unit tests. If you need files, JUnit provides a temporary directory (@TempDir) that is created and removed for you.
  • Run the whole suite before you push (mvn clean verify, article 23) and make the CI server run it on every pull request (article 24).
  • Treat coverage numbers with caution. A coverage tool tells you which lines ran, not whether the assertions are meaningful. A test with no assertion gives coverage and no protection. Use it to find untested code, not as a target to maximise.
  • Fix or delete flaky tests. A test that sometimes fails for no reason teaches the team to ignore failures.
  • A failing test is information. Do not edit a test to make it pass unless the requirement changed.

Common mistakes

#
  • Writing tests that cannot fail (no assertion, or asserting what the mock was told to return).
  • Swapping the arguments of assertEquals, so the failure message is misleading.
  • Sharing mutable state between tests through static fields.
  • Testing several behaviours in one test, so a failure does not point to a cause.
  • Depending on the current time or random data.
  • Mocking everything, including value objects, and verifying every call.
  • Catching an exception in a test and doing nothing, so the test passes even when the code is wrong. Use assertThrows.
  • Using JUnit 4 annotations with JUnit 5 and later, so tests silently do not run. If "Tests run: 0" appears, check the imports.
  • Skipping tests (-DskipTests) to get a build through.

Exercises

#

Exercise 1

Choose the cases

#

You are testing static boolean isLeapYear(int year). Which inputs would you test? (Rule: divisible by 4, except centuries, except centuries divisible by 400.)

Exercise 2: Write a parameterised test

#

Write a @ParameterizedTest with @CsvSource for isLeapYear, using the cases from your answer to Exercise 1.

Exercise 3: Test the error path

#

new OrderService(...).placeOrder("c-1", -5) should throw IllegalArgumentException with a message containing the amount. Write the test, with a check that the payment gateway was never called.

Exercise 4: Spot the problem

#

A test stubs when(repo.findById(1)).thenReturn(Optional.of(user)) and then asserts assertEquals(user, service.find(1)). The service just returns repo.findById(1).orElseThrow(). What does this test prove, and what does it not prove?

Exercise 5: Mock or fake?

#

When would you write an in-memory fake OrderRepository instead of using a Mockito mock?

Show solutionHide solution

Test each branch of the rule: an ordinary leap year (2024), an ordinary non-leap year (2023), a century that is not a leap year (1900), a century that is (2000), and the year that most people forget, such as 2100. Boundary-style cases such as 4 and 0 are also worth including if your function accepts them.

Exercise 2

Show solutionHide solution
Java
@ParameterizedTest(name = "{0} leap year? {1}")@CsvSource({        "2024, true",        "2023, false",        "1900, false",        "2000, true",        "2100, false"})void leapYears(int year, boolean expected) {    assertEquals(expected, isLeapYear(year));}

The table makes the cases visible, and each row is reported separately.

Exercise 3

Show solutionHide solution
Java
@Testvoid rejectsANegativeAmountWithoutCharging() {    IllegalArgumentException e = assertThrows(IllegalArgumentException.class,            () -> service.placeOrder("c-1", -5));    assertEquals("amount must be positive: -5", e.getMessage());    verifyNoInteractions(gateway);}

The test sits in OrderServiceTest with its @Mock fields and uses a static import for verifyNoInteractions. It checks the exception type, the message that contains the amount, and the absence of a charge.

Exercise 4

Show solutionHide solution

It proves only that the service passes the repository's answer through. It does not prove that the real repository finds users, that id 1 exists, or that the query is correct. Because the stub supplies exactly the answer the assertion expects, the test would pass for almost any implementation that calls findById. For this logic, it is worth little; the valuable tests are the ones where the service makes a decision, such as what it does when the user is absent (Optional.empty()), plus an integration test with the real repository.

Exercise 5

Show solutionHide solution

When tests need to read back what was stored, such as "save two orders, then find them by customer", or when many tests share the same repository behaviour. A fake HashMap-based repository behaves like a real one, so the tests check outcomes and not the exact sequence of calls, which makes them more resistant to refactoring. Use a mock when the question is "was this interaction made?", for example "was nothing saved when payment failed?".

Where to go next

#

Sources & further reading

  1. docs.junit.org
  2. javadoc.io
  3. maven.apache.org
  • Guide · Technology

    Heaps and Priority Queues

  • Guide · Technology

    Dynamic Programming Practice: From 1D to 2D

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java