Unit testing with JUnit 5
@Test, assertions, assertThrows, lifecycle hooks, parameterised tests and test-driven habits.
How do you know your code works? Running it and eyeballing the output works once. But after the next change, and the one after that, you would have to re-check everything by hand. Automated tests check your code for you, in seconds, every time you build. JUnit is the standard testing framework for Java; this lesson teaches JUnit 5 (Jupiter), the API you'll find in almost every modern codebase and in Spring Boot.
JUnit 6 (released in 2025, requires Java 17+) is the newest major version. It keeps the same Jupiter programming model, so everything in this lesson works unchanged: just bump the BOM version from
5.14.4to6.x.
Why write unit tests?#
- Catch bugs early, while the code is fresh in your mind.
- Refactor fearlessly: if the tests still pass, you didn't break anything.
- Document behaviour: a test named
rejectsZeroQuantityexplains the rules better than a comment. - Better design: code that is easy to test tends to be small, focused and loosely coupled.
- CI safety net: the build fails before broken code reaches production.
A unit test checks one small unit (usually one class or method) in isolation, runs in milliseconds, and needs no database or network.
Setting up#
Use the Maven or Gradle project from the previous lesson. The test dependencies are:
(Gradle: testImplementation(platform("org.junit:junit-bom:5.14.4")), testImplementation("org.junit.jupiter:junit-jupiter"), testRuntimeOnly("org.junit.platform:junit-platform-launcher") and tasks.test { useJUnitPlatform() }.)
Tests go in src/test/java, in the same package as the class under test, so they can also access package-private members. Run them with mvn test or ./gradlew test, or click the green arrow in your IDE.
The class under test#
Prices are stored in paise (integers) to avoid floating-point rounding errors.
Your first tests#
The essentials:
- A test is a method annotated with
@Test. It needs nopublicmodifier, and returnsvoid. @BeforeEachruns before every test, so each test starts from a freshShoppingCart. Tests must never depend on each other or on execution order.- Assertions (statically imported from
org.junit.jupiter.api.Assertions) check the results. The first failing assertion stops the test. - Structure each test as Arrange, Act, Assert (also called Given, When, Then).
@DisplayNamegives readable names in reports; descriptive method names work just as well.@Nestedclasses group related tests that share extra setup. The outer@BeforeEachruns first, then the inner one.
When a test fails
Suppose we wrote the wrong expected value, assertEquals(12000, cart.totalPaise(), "4 pens at Rs 25"):
The message tells you the test, the expectation and the actual value. Note the argument order: assertEquals(expected, actual). Swapping them produces confusing messages.
The assertion toolbox#
Every assertion accepts an optional final message argument, shown on failure.
Many teams add AssertJ for fluent, very readable assertions:
assertThat(cart.totalPaise()).isEqualTo(16000);andassertThat(names).containsExactly("Asha", "Ben");.
Parameterised tests: one test, many inputs#
Testing the same logic with many inputs? Don't copy-paste. Use @ParameterizedTest (in junit-jupiter-params, included in junit-jupiter):
The PasswordPolicyTest class alone produces 13 test runs, each reported separately.
Lifecycle, temporary files, timeouts and disabling#
Testing with dependencies: mocks#
Real classes depend on things you don't want in a unit test: payment gateways, databases, email servers, the clock. Design the class to receive its dependencies (dependency injection) through an interface:
In the test, Mockito provides a fake PaymentGateway. Add org.mockito:mockito-junit-jupiter:5.24.0 with test scope:
@Mockcreates a fake whose methods return defaults (false,0,null) until you stub them withwhen(...).thenReturn(...).verify(mock).method(args)checks that an interaction happened;never()checks that it didn't.- Argument matchers such as
anyString()andanyInt()match any value.
On recent JDKs Mockito attaches an agent at runtime and prints a warning ("A Java agent has been loaded dynamically"). The Mockito documentation shows how to configure it as a
-javaagentin Surefire or Gradle to silence the warning; the tests work either way.
Mock roles you own (your interfaces), not value objects like String or records. If a test needs many mocks, the class under test probably does too much.
The full run#
24 test executions across four classes, all in about a second. That speed is what lets you run them after every change.
What makes a good unit test (F.I.R.S.T.)#
- Fast: milliseconds, so you run them constantly.
- Independent: no shared mutable state, no order dependence.
- Repeatable: same result every time, everywhere. Avoid real clocks, random numbers and networks; inject a
Clockor a seededRandom. - Self-validating: pass or fail automatically, with no reading of logs.
- Timely: written alongside the code, or before it.
Other habits that pay off:
- One behaviour per test, with a name that states it:
rejectsDiscountAboveFifty. - Test edge cases: empty input, zero, negative numbers,
null, maximum values, duplicates. - Test behaviour through the public API, not private implementation details.
- Keep test code as clean as production code.
Test-driven development (TDD) in one paragraph#
TDD flips the order: Red (write a failing test for the next small behaviour), Green (write the simplest code that passes), Refactor (clean up while the tests stay green), and repeat. Even if you don't practise strict TDD, writing the test first for bug fixes, reproducing the bug before fixing it, is a great habit.
Beyond unit tests#
- Code coverage with JaCoCo shows which lines your tests execute. Useful for spotting gaps, but 100% coverage doesn't mean bug-free.
- Integration tests check several parts together, e.g. your DAO against a real MySQL started by Testcontainers in Docker.
- Spring Boot adds
@SpringBootTest,@WebMvcTestand@DataJpaTeston top of JUnit 5.
Common mistakes#
- Swapping
expectedandactualinassertEquals. - Tests that depend on each other or on shared static state.
- Comparing doubles without a tolerance.
- Catching exceptions in tests instead of using
assertThrows. - Tests that pass even when the code is broken (no assertions, or asserting the wrong thing). Make a test fail once to see it work.
- Mixing JUnit 4 (
org.junit.Test,@Before) with JUnit 5 (org.junit.jupiter.api.Test,@BeforeEach) imports. - Gradle without
useJUnitPlatform(): zero tests run and the build still "passes".
What's next#
You can now build and test like a professional. Next, we survey the most important language features of modern Java 17-21: sealed classes, pattern matching, record patterns, text blocks, virtual threads and more.
Check your understanding
Quick quiz
1.Which annotation runs a method before EACH test method in a JUnit 5 test class?
2.How do you check that
cart.add("Pen", 2500, 0)throws an IllegalArgumentException?3.What is the main purpose of a mock object, e.g. created with Mockito?
Finished reading?
Mark this lesson complete to track your progress.