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.
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 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.
Related articles
- Java Mockito Tutorial: Mocking Dependencies in Java Tests
Learn how to use Mockito to write fast, focused unit tests. Understand mocks, stubs, spies, argument captors, and how to avoid over-mocking.
- Java Java Collections Framework Cheatsheet
A pragmatic tour of Java's collection interfaces and implementations, with guidance on choosing between List, Set, Map, and Queue variants in real applications.
- Java Java Concurrency with CompletableFuture
How CompletableFuture composes asynchronous work in Java: chaining, combining, error handling, executors, and the patterns that keep concurrent code readable.
- Java Java Exception Handling Best Practices
Clear rules for using checked and unchecked exceptions in Java, with patterns for wrapping, logging, and propagating errors in real production code.