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
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
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.
For
Learners who can build a Maven project and want a repeatable way to prove their code works and keep it working.
Level
Before you start
Interfaces (article 6), exceptions (article 10), and a Maven project (article 23). Verified with JUnit Jupiter 6.1.3 and Mockito 5.23.0 on JDK 21.
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 | |
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 callingLocalDate.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):
<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:
// 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
#// 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:
@Testmarks a method as a test. Test methods arevoid, take no arguments (except parameterised tests), and in JUnit Jupiter they and the class can be package-private, so nopublicis 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), andassertThrows. 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 withfail().@BeforeEachruns before every test.@AfterEachruns after (closing resources).@BeforeAll/@AfterAllrun once per class, onstaticmethods by default.@DisplayNamegives a readable name in reports. Plain, descriptive method names (rejectsDiscountAbove100) also work well.@ParameterizedTestruns the same test with different data, here from@CsvSource(each row is one case). Other sources include@ValueSource,@EnumSourceand@MethodSource.
Older style. Older projects use JUnit 4: the annotations come from
org.junit(org.junit.Test,@Before,@After), test classes and methods must bepublic, and exceptions are tested with@Test(expected = ...)or a rule. JUnit 5 and later (the packageorg.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:
[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:
[ERROR] PriceCalculatorTest.boundaryAndRoundingCases:35 IllegalArgument discount must be between 0 and 100: 100JUnit 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,
nullwhere it is possible. - Error paths: that the right exception is thrown with a useful message.
- Rounding and arithmetic, such as
999 cents at 10% offbecoming 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.addworks). - 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 |
Stub | Returns fixed answers | A gateway that always returns |
Fake | A simplified working implementation | An in-memory repository backed by a |
Mock | A double that also records the calls it receives, so a test can check them | "was |
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:
// 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);}// File: src/main/java/com/example/shop/OrderRepository.javapackage com.example.shop;public interface OrderRepository { void save(Order order);}// File: src/main/java/com/example/shop/Order.javapackage com.example.shop;public record Order(String customerId, int amountCents, String paymentReference) {}// File: src/main/java/com/example/shop/PaymentFailedException.javapackage com.example.shop;public class PaymentFailedException extends RuntimeException { public PaymentFailedException(String message) { super(message); }}// 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:
// 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.@Mockcreates a mock of the interface.@InjectMockscreates the object under test and passes the mocks to its constructor.when(call).thenReturn(value)is stubbing: whengateway.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()andanyInt()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.verifyNoInteractionschecks that nothing at all was called.ArgumentCaptorcaptures 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:
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 releaseMockito'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.
- A mock only does what you told it. If you stub
gateway.chargeto 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. - 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.
- 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. - A fake can be better than a mock. An in-memory repository (a
HashMapthat implementsOrderRepository) lets a test read back what was stored, instead of verifying calls. It is more code, but it survives refactoring better. - 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.
- 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:
doesNotSaveWhenThePaymentFailstells a reader (and a failure report) far more thantestPlaceOrder2. - 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
staticfields. - 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
@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
@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
#- Next: Project 1: Expense Tracker CLI is where you apply Maven, Git and tests to a complete application.
- Later, for integration tests with a database: Project 2: Expense Tracker on PostgreSQL.
- For Spring slice tests and MockMvc: Testing Spring Boot Applications.
Sources & further reading
- Technology
- Software Engineering
- #Java
- #Java backend
- #Java interview
- #JPA
- #JUnit
- #learn Java
- #Mockito
- #Spring Boot
- #SQL
- #unit testing
Connected lessons
Linked from 4 guides
Other pages in the library that point here.
Keep reading
More in Technology →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

