Testing with JUnit 5 & Mockito

JUnit 5 Basics

15 min Lesson 2 of 13

JUnit 5 Basics

JUnit 5 is the de-facto standard testing framework on the JVM. It is not a single library but an architecture composed of three modules: JUnit Platform (the launcher and engine API), JUnit Jupiter (the new programming model and annotations you write every day), and JUnit Vintage (backwards-compatible runner for JUnit 3/4 suites). In day-to-day work you interact almost exclusively with Jupiter.

Adding JUnit 5 to Your Project

Modern build tools include JUnit 5 by default when you generate a project, but it is worth knowing the explicit dependency. In Maven add the BOM and a single test-scope dependency:

<!-- pom.xml --> <dependencyManagement> <dependencies> <dependency> <groupId>org.junit</groupId> <artifactId>junit-bom</artifactId> <version>5.10.2</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> </dependencies>

With Gradle (Kotlin DSL):

// build.gradle.kts dependencies { testImplementation(platform("org.junit:junit-bom:5.10.2")) testImplementation("org.junit.jupiter:junit-jupiter") } tasks.test { useJUnitPlatform() // tells Gradle to discover Jupiter tests }
useJUnitPlatform() is mandatory in Gradle. Without it Gradle uses its own built-in JUnit 4 runner and silently skips all Jupiter tests — a notoriously confusing failure where the build is green but no tests ran.

The @Test Annotation

@Test is the fundamental marker that turns an ordinary method into a test case. In Jupiter it lives in the org.junit.jupiter.api package — a different package from the old JUnit 4 org.junit.Test, so if your IDE imports the wrong one, the test runner ignores the method silently.

Rules for a valid @Test method:

  • The class must be non-abstract and have a single accessible constructor (or none, triggering the default no-arg constructor).
  • The method must be non-private and return void. JUnit 5 does not require public — package-private is the preferred style.
  • The method must take no parameters unless you use parameter resolvers (covered later in this tutorial).

A First Test

Below is a self-contained class that tests a small utility:

package com.example.util; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class StringUtilsTest { @Test void reverseOfEmptyStringIsEmpty() { String result = StringUtils.reverse(""); assertEquals("", result); } @Test void reverseOfSingleCharIsItself() { assertEquals("a", StringUtils.reverse("a")); } @Test void reverseOfPalindromeIsPalindrome() { String palindrome = "racecar"; assertEquals(palindrome, StringUtils.reverse(palindrome)); } @Test void reverseOfRegularWordIsCorrect() { assertEquals("olleh", StringUtils.reverse("hello")); } }
Name tests like sentences that describe behaviour, not implementation. reverseOfEmptyStringIsEmpty() tells you immediately what breaks if the test fails. Names like test1() or testReverse() tell you nothing useful. The JUnit Platform uses the method name as the display name in reports, so readability matters in source and in CI output.

How Tests Run

Understanding the execution model prevents a whole category of subtle ordering bugs.

  1. Discovery. The JUnit Platform scans the classpath for classes that contain @Test-annotated methods. By convention these classes live under src/test/java and mirror the package structure of the production code they exercise.
  2. Instantiation. JUnit 5 creates a new instance of the test class for every test method by default. This deliberate design decision prevents shared mutable state from leaking between tests — a key source of flaky tests in JUnit 4, which reused one instance per class.
  3. Method execution. The platform invokes the method. If the method completes without throwing an exception the test passes. If any exception escapes — including AssertionError from a failed assertion — the test is marked failed.
  4. Reporting. Results are collected and surfaced by your IDE test runner, Maven Surefire, or Gradle's HTML test report.
// Demonstrating per-method instantiation class InstantiationDemoTest { // A new ArrayList is created for every test method private final List<String> items = new ArrayList<>(); @Test void firstTest() { items.add("one"); assertEquals(1, items.size()); // passes } @Test void secondTest() { // items is a fresh empty list; firstTest's add() never happened here assertEquals(0, items.size()); // also passes } }

Test Execution Order

By default JUnit 5 does not guarantee the order in which test methods execute within a class. The order is deterministic across runs (based on a hash of the method name), but not alphabetical or declaration order. This is intentional: tests that pass only in a specific order are hiding a hidden dependency and are not true unit tests.

If you need a specific order — for integration or scenario tests — annotate the class with @TestMethodOrder(MethodOrderer.OrderAnnotation.class) and then use @Order(1), @Order(2), etc. on each method. Reserve this for genuine integration scenarios, not as a crutch for poorly isolated unit tests.

Display Names

The @DisplayName annotation lets you replace the method name with a human-readable string including spaces, special characters, or emoji — useful when the test method name cannot fully express the intent:

@Test @DisplayName("reverse(null) should throw NullPointerException") void reverseNullThrows() { assertThrows(NullPointerException.class, () -> StringUtils.reverse(null)); }
Do not use @DisplayName as an excuse for a poor method name. IDEs search test output by method name; if the method is called test42() you lose that navigation. A good method name plus an optional @DisplayName for the report is the right combination.

Disabling a Test

Use @Disabled (with a mandatory reason string) when a test must temporarily be skipped — never delete it and never comment it out:

@Test @Disabled("Temporarily disabled: upstream CSV parser bug PROJ-1234, fix expected in v3.2") void parsesCsvWithQuotedCommas() { // ... }

A disabled test still appears in the report as skipped, keeping visibility that something is intentionally not running. Deleting the test silently removes that visibility.

Summary

JUnit 5 Jupiter is the annotation layer you interact with daily. The @Test annotation marks a non-private, void, no-parameter method as a test case. A fresh class instance is created per method, eliminating inter-test pollution. Tests pass if no exception escapes and fail on any thrown exception including assertion failures. Good method names and the optional @DisplayName keep test reports readable. In the next lesson we will go deep on JUnit 5's assertion API, including grouped assertions, exception assertions, and timeout constraints.