🛠️ Testing, Tools & Ecosystem · Advanced

Unit testing with JUnit 5 in Java

@Test, assertions, assertThrows, lifecycle annotations, parameterized tests.

🧩 The mysteryYou fix one bug and quietly create two more somewhere else. A few hundred tiny methods that run in seconds can catch that before your users do.

A test is a method that can fail

JUnit runs every method marked **@Test and reports which ones fail. Inside, assertions check results. The most common: assertEquals(expected, actual)**.

@Test
void addsTwoNumbers() {
    var calc = new Calc();
    assertEquals(5, calc.add(2, 3));
}

Expected comes first

assertEquals takes the expected value first and the actual value second. The test passes either way, but on failure JUnit prints *expected: <5> but was: <4>*. Swapped arguments make that message lie to whoever reads it.

Testing exceptions

**assertThrows(Type.class, lambda) runs the lambda, checks that it throws that type, and returns the exception** so you can inspect it further.

var ex = assertThrows(
    ArithmeticException.class,
    () -> calc.divide(1, 0));
assertEquals("/ by zero", ex.getMessage());
⚠️ The trap

Throwing outside the lambda

Here withdraw(-5) runs before assertThrows, so its exception escapes and the test errors. The call that should throw belongs inside the lambda: () -> acc.withdraw(-5).

@Test
void rejectsNegativeAmount() {
    Account acc = new Account();
    acc.withdraw(-5);       // throws here!
    assertThrows(IllegalArgumentException.class,
        () -> acc.balance());
}

The lifecycle

By default JUnit 5 creates a new instance of the test class for each test method, so fields can't leak between tests. **@BeforeEach/@AfterEach run around every test. @BeforeAll/@AfterAll run once per class, so they're static by default: there's no instance yet. @Disabled("reason")** skips a test.

🔮 Predict it

Shared counter?

A test class has a field int count = 0;. Two tests each do count++ and then assertEquals(1, count). What happens?

  1. Both pass
  2. The second one fails
  3. It depends on the order
Show the answer

Both pass. Each test method gets a fresh instance of the class, so each starts with count = 0. That independence is the point.

Many inputs, one test

✗ Copy-paste
@Test void two()  { assertEquals(0, 2 % 2); }
@Test void four() { assertEquals(0, 4 % 2); }

Every new case means another copied method.

✓ Parameterized
@ParameterizedTest
@ValueSource(ints = {2, 4, 6})
void isEven(int n) {
    assertEquals(0, n % 2);
}

One test, run once per value. @CsvSource takes strings like "1, 2, 3" for several arguments.

💼 In the real world

Tests in the pipeline

Every push to CI runs the whole suite. Fast, independent tests are what make that bearable: a failure points at one method, not at "something in the build". A shared static field that leaks state between tests is the classic source of flaky, order-dependent failures.

Key takeaways

  1. assertEquals(expected, actual): order matters for messages
  2. assertThrows runs a lambda and returns the exception
  3. A new test class instance is created per test method
  4. @ParameterizedTest + @ValueSource / @CsvSource
🤯 Did you know?

JUnit 5 is really three parts: the JUnit Platform (launches tests), JUnit Jupiter (the new programming model) and JUnit Vintage (runs old JUnit 3 and 4 tests).

Practice questions

In which order does assertEquals take its arguments?

  1. assertEquals(actual, expected)
  2. assertEquals(expected, actual)
  3. Order doesn't exist; it takes a single boolean
  4. assertEquals(message, actual)
Check your answer

assertEquals(expected, actual). JUnit expects the expected value first and the actual value second.

Complete the annotation so the test runs once for each number.

@ParameterizedTest
@___(ints = {2, 4, 6})
void isEven(int n) {
    assertEquals(0, n % 2);
}
  1. ValueSource
  2. BeforeEach
  3. Test
  4. CsvSource
Check your answer

ValueSource. @ValueSource supplies a single literal value per invocation, here through ints = {...}. @CsvSource uses strings of comma-separated values, not an ints attribute.

Real classes have dependencies: databases, mailers, payment APIs. Next: replace them with stunt doubles using Mockito.