testing with junit
junit 6, assertions, parameterized tests, mocks and testcontainers.
- chapter
- chapter 12.07
- reading time
- 8 min read
a test is a method with @Test on it. JUnit finds those methods, runs each one on a fresh instance of the test class, and reports what passed. JUnit 6 (september 2025) is the current major version and needs java 17+.
around it sits a small standard toolkit: AssertJ for assertions that explain themselves, Mockito for fake collaborators, and Testcontainers for a real database in docker instead of a fake one.
the picture
a test is a tiny experiment. set up a clean lab bench, do one thing, check the result, throw the bench away. the "throw it away" part is the important one: if experiments share a bench, the second one can pass (or fail) because of crumbs the first one left. junit enforces the clean bench for you, by building a new object for every single test method.
a first test
the code under test, and its test. it's not runnable on this page (junit isn't part of the JDK), so here's what really happened when i ran it with the junit 6.1.3 console launcher on jdk 25.
class Prices {
static long withTax(long cents) { return cents + cents * 18 / 100; }
}import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class PricesTest {
@ParameterizedTest(name = "{0} cents → {1}")
@CsvSource({
"100, 118",
"0, 0",
"1000, 1180",
"99, 117",
})
void addsTax(long cents, long expected) {
assertEquals(expected, Prices.withTax(cents));
}
}├─ JUnit Jupiter ✔
│ └─ PricesTest ✔
│ └─ addsTax(long, long) ✔
│ ├─ "100" cents → "118" ✔
│ ├─ "0" cents → "0" ✔
│ ├─ "1000" cents → "1180" ✔
│ └─ "99" cents → "117" ✘ expected: <117> but was: <116>one method, four tests, and the fourth found a real bug: 99 * 18 / 100 is 17.82, and integer division throws away the .82. a single hand-picked example (100 cents) would have passed forever. that's the case for parameterized tests: the cases are data, so adding the nasty ones costs one line each. @CsvSource, @ValueSource, @EnumSource and @MethodSource (any method returning a stream of arguments) cover almost everything.
the lifecycle
here's the order junit calls things in. the output is real, and it has a surprise in it.
- 1import org.junit.jupiter.api.*;
- 2
- 3class LifecycleTest {
- 4 static int instances = 0;
- 5
- 6 LifecycleTest() {
- 7 instances++;
- 8 log(" new instance #" + instances);
- 9 }
- 10
- 11 @BeforeAll
- 12 static void beforeAll() {
- 13 log("@BeforeAll");
- 14 }
- 15
- 16 @BeforeEach
- 17 void beforeEach() {
- 18 log(" @BeforeEach");
- 19 }
- 20
- 21 @Test
- 22 void first() {
- 23 log(" @Test first");
- 24 }
- 25
- 26 @Test
- 27 void second() {
- 28 log(" @Test second");
- 29 }
- 30
- 31 @AfterEach
- 32 void afterEach() {
- 33 log(" @AfterEach");
- 34 }
- 35
- 36 @AfterAll
- 37 static void afterAll() {
- 38 log("@AfterAll");
- 39 }
- 40
- 41 static void log(String s) { System.out.println(s); }
- 42}
@BeforeAll
no instance exists yet. that's why @BeforeAll has to be static: there's no object to call it on.
@Test void first() { System.out.println("first"); }
@Test void second() { System.out.println("second"); }assertions that explain themselves
when a test fails, the message is the whole bug report. junit's own assertEquals tells you the two values. AssertJ's fluent assertions (assertThat(x).something()) tell you how they differ. here a method meant to return the first three items alphabetically accidentally sorts in reverse. real output from both:
assertEquals(List.of("apple", "fig", "kiwi"), got);expected: <[apple, fig, kiwi]> but was: <[pear, kiwi, fig]>assertThat(got).containsExactly("apple", "fig", "kiwi");Expecting actual:
["pear", "kiwi", "fig"]
to contain exactly (and in same order):
["apple", "fig", "kiwi"]
but some elements were not found:
["apple"]
and others were not expected:
["pear"]AssertJ has assertions for almost everything (containsEntry, isCloseTo, hasMessageContaining, extracting(...)), and your IDE autocompletes them after assertThat(x)., which is most of why people like it.
mocks and spies
some code talks to things you don't want in a unit test: a payment API, a clock, an email server. a mock is a fake stand-in you create in one line, tell what to return, and check afterwards. Mockito is the standard one.
- add("a")·
- size()·
- get(0)·
- isEmpty()·
- when(mock.get(0)).thenReturn("stubbed")·
- when(mock.get(5)).thenReturn("x")·
- verify(mock).add("a")·
- add("a")·
- size()·
- get(0)·
- isEmpty()·
- doReturn("stubbed").when(spy).get(0)·
- when(spy.get(5)).thenReturn("x")·
- verify(spy, times(1)).add("a")·
a mock is a hollow fake made from the interface: no real code at all. a spy wraps a real object and lets calls through.
- mock
- a fake with no real behaviour. unstubbed methods return defaults. use it for collaborators: the thing your code calls.
- spy
- a real object, wrapped. real methods run unless you stub them. use it rarely: usually a smell that a class does too much.
- stub
when(x.call()).thenReturn(v): "if asked this, answer that".- verify
verify(x).call(): "check my code actually asked this".
real dependencies: testcontainers
mocking a database is a trap: your fake happily accepts SQL the real postgres would reject. Testcontainers starts a real postgres (or kafka, or redis) in docker for the duration of a test, hands you its address, and deletes it afterwards. this is the 2.0 API (artifact testcontainers-postgresql, class in org.testcontainers.postgresql):
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer db = new PostgreSQLContainer("postgres:17-alpine");
@Test
void savesAndLoads() throws Exception {
try (var conn = java.sql.DriverManager.getConnection(
db.getJdbcUrl(), db.getUsername(), db.getPassword())) {
// run real migrations, real queries, real constraints
}
}
}hover a stage.
the trade: these tests take seconds instead of milliseconds and need docker on the build machine. that's why most suites are mostly fast unit tests with mocks, plus a smaller layer of these.
what changed in junit 6+
junit 6.0.0 shipped on september 30, 2025. the big moves: java 17 is the minimum (kotlin 2.2 for kotlin users), the platform, jupiter and vintage artifacts now share one version number (no more "platform 1.13 + jupiter 5.13"), and every module is annotated with JSpecify nullness annotations (more on those in code quality). the vintage engine that runs junit 4 tests is deprecated, so a junit 4 suite should migrate rather than ride along. test code written for junit 5 mostly compiles unchanged: same org.junit.jupiter.api package, same annotations.
@Testmethods, each on a fresh instance. statics are the only thing shared.- parameterized tests make the nasty cases cheap. the 99 cents case found the bug.
- test order is fixed but not source order. tests shouldn't care.
- AssertJ messages explain the diff. mocks fake collaborators, spies wrap real objects.
- for databases, test against a real one in docker with Testcontainers.