Skip to content
C Codeloom
Java

JUnit 5 Tutorial: Writing Clean, Modern Java Tests

A complete introduction to JUnit 5. Learn the new architecture, lifecycle, parameterized tests, assertions, and patterns that keep your test suite fast and readable.

·4 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • The JUnit 5 architecture: Platform, Jupiter, Vintage
  • Lifecycle annotations and test instance behavior
  • Parameterized and dynamic tests
  • Assertions and assumptions
  • Patterns for fast, focused test suites

Prerequisites

  • Basic Java
  • Familiarity with build tools

What and Why

JUnit 5, also called Jupiter, is the modern rewrite of the Java testing standard. It replaces JUnit 4’s monolithic design with a modular architecture, adds first-class support for parameterized and dynamic tests, and embraces Java 8 features like lambdas.

The why is straightforward: tests are code you read more than you write. JUnit 5 produces clearer, more expressive tests with less boilerplate. It also integrates with build tools, IDEs, and CI systems out of the box.

Mental Model

JUnit 5 is three projects in one. The Platform is the foundation that test runners and IDEs talk to. Jupiter is the new programming model with @Test, @BeforeEach, and friends. Vintage is a shim that lets old JUnit 4 tests run on the same engine, so migration can be incremental.

When you run a test, the build tool asks the Platform to discover tests. Engines (Jupiter, Vintage, or third-party like Spock) report what they found. The Platform then asks engines to execute selected tests and reports results back.

Hands-on Example

A small calculator and its tests:

public class Calculator {
    public int add(int a, int b) { return a + b; }
    public int divide(int a, int b) {
        if (b == 0) throw new ArithmeticException("divide by zero");
        return a / b;
    }
}

class CalculatorTest {
    private Calculator calc;

    @BeforeEach
    void setUp() { calc = new Calculator(); }

    @Test
    @DisplayName("adds two positive numbers")
    void addsPositive() {
        assertEquals(5, calc.add(2, 3));
    }

    @Test
    void throwsOnDivideByZero() {
        assertThrows(ArithmeticException.class, () -> calc.divide(1, 0));
    }

    @ParameterizedTest
    @CsvSource({ "1, 1, 2", "2, 3, 5", "-1, 1, 0" })
    void addsVariousInputs(int a, int b, int expected) {
        assertEquals(expected, calc.add(a, b));
    }
}
+------------------+
|   Build / IDE    |
+--------+---------+
       |
       v
+------------------+
| JUnit Platform   |
+--------+---------+
       |
 +-----+-----+-------------+
 v           v             v
Jupiter     Vintage      Third-party
Engine      Engine          Engine
 |           |             |
 v           v             v
@Test       JUnit 4      Spock, etc.
methods     tests
JUnit 5 architecture and discovery flow

The diagram explains why you can mix engines. Each engine reports tests up to the Platform using a shared protocol.

Common Pitfalls

Mixing JUnit 4 and JUnit 5 imports in the same class produces confusing failures. The annotations look identical but come from different packages. Stick with org.junit.jupiter.api for new tests.

@BeforeAll methods must be static unless you change the test instance lifecycle to PER_CLASS. The default lifecycle creates a new instance per test, so static is required to share setup.

Tests that share mutable state through static fields create order-dependent failures. JUnit 5 does not guarantee test method order. Reset state in @BeforeEach instead.

Using assertEquals(expected, actual) with the arguments swapped produces correct pass/fail behavior but inverts the diagnostic message. Always put the expected value first.

Practical Tips

Use @DisplayName to write tests that read like specifications. "rejects negative balance withdrawal" is far clearer than testWithdraw3.

Group related tests with @Nested classes. The IDE renders them as a tree, and reading a test file feels like reading documentation.

For data-driven tests, prefer @ParameterizedTest over loops inside a single test. Failures point at the exact input, not a generic message.

Use assertAll to report multiple failures from a single test instead of stopping at the first:

assertAll(
    () -> assertEquals(2, calc.add(1, 1)),
    () -> assertEquals(0, calc.add(-1, 1))
);

Keep tests fast. A slow suite gets skipped. Push integration tests into a separate Maven or Gradle source set so unit tests run in seconds.

Wrap-up

JUnit 5 is a worthy upgrade from JUnit 4. The modular architecture, parameterized tests, and lambda-friendly assertions make tests easier to write and easier to read. Migration is gradual thanks to the Vintage engine.

Adopt JUnit 5 for new tests today, then convert legacy suites when you touch them. Your future self, debugging a flaky test at 2 AM, will thank you.