skip to content
everything.tooling.testingWithJunit

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.

Prices.java
class Prices {
    static long withTax(long cents) { return cents + cents * 18 / 100; }
}
PricesTest.java
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));
    }
}
java -jar junit-platform-console-standalone-6.1.3.jar execute ... --details=tree
├─ 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.

LifecycleTest.java next to run
  1. 1import org.junit.jupiter.api.*;
  2. 2
  3. 3class LifecycleTest {
  4. 4 static int instances = 0;
  5. 5
  6. 6 LifecycleTest() {
  7. 7 instances++;
  8. 8 log(" new instance #" + instances);
  9. 9 }
  10. 10
  11. 11 @BeforeAll
  12. 12 static void beforeAll() {
  13. 13 log("@BeforeAll");
  14. 14 }
  15. 15
  16. 16 @BeforeEach
  17. 17 void beforeEach() {
  18. 18 log(" @BeforeEach");
  19. 19 }
  20. 20
  21. 21 @Test
  22. 22 void first() {
  23. 23 log(" @Test first");
  24. 24 }
  25. 25
  26. 26 @Test
  27. 27 void second() {
  28. 28 log(" @Test second");
  29. 29 }
  30. 30
  31. 31 @AfterEach
  32. 32 void afterEach() {
  33. 33 log(" @AfterEach");
  34. 34 }
  35. 35
  36. 36 @AfterAll
  37. 37 static void afterAll() {
  38. 38 log("@AfterAll");
  39. 39 }
  40. 40
  41. 41 static void log(String s) { System.out.println(s); }
  42. 42}
stdout
@BeforeAll
stack
beforeAll()
no locals yet
junit engine
no locals yet
heap
LifecycleTest.classstatics
instances0

no instance exists yet. that's why @BeforeAll has to be static: there's no object to call it on.

step 1/10
step through it. a new instance for each test method, and the old one becomes garbage. statics live on the class, so they're shared by every test.
predict the output
java
@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:

don'tassertEquals on a list
java
assertEquals(List.of("apple", "fig", "kiwi"), got);
text
expected: <[apple, fig, kiwi]> but was: <[pear, kiwi, fig]>
doAssertJ
java
assertThat(got).containsExactly("apple", "fig", "kiwi");
text
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.

mock(List.class)all fake
  1. add("a")·
  2. size()·
  3. get(0)·
  4. isEmpty()·
  5. when(mock.get(0)).thenReturn("stubbed")·
  6. when(mock.get(5)).thenReturn("x")·
  7. verify(mock).add("a")·
spy(new ArrayList<>())real, wrapped
  1. add("a")·
  2. size()·
  3. get(0)·
  4. isEmpty()·
  5. doReturn("stubbed").when(spy).get(0)·
  6. when(spy.get(5)).thenReturn("x")·
  7. 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.

step 1/8
step through the same calls on a mock and a spy. every result is real output from mockito 5.24. the red one is the classic spy trap.
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):

UserRepositoryTest.java
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.

hover a stage. a static @Container starts once for the whole class; an instance field one would start per test (slow).

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.

one level deeper: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.

keylines
  • @Test methods, 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.