DEV Community

Mayur Bodhare
Mayur Bodhare

Posted on

JUnit 5 Explained: Unit Testing, TDD and Annotations

The first time a bug reached production because of a change I made, I was told the same thing three times: "Did you test it?" I had. I had run the program, clicked around and it looked fine. But "it looked fine when I tried it" is not a test. It's a hope.

Unit tests turn that hope into a safety net. They're small programs that check your code for you, in milliseconds, every time you change something. In Java, the most popular tool for writing them is JUnit.

In this post, we'll look at how testing used to work, what unit testing and TDD are, how JUnit 5 differs from JUnit 4, how JUnit 5 is built, how to set it up with Maven and Surefire, and then the main assertions and annotations you'll use every day.

A quick version note: JUnit's newest major version is JUnit 6 (released in September 2025). It needs Java 17, and the Jupiter API you learn here carries over to it. I'm using JUnit 5 because it still runs on older Java versions and is everywhere in existing projects. To try JUnit 6, you mostly change the version number.

Table of contents

Testing in the traditional SDLC

SDLC stands for Software Development Life Cycle: the steps a team follows to build software. In the traditional style, often called the waterfall model, each step happens after the previous one is finished:

   Requirements --> Design --> Coding --> TESTING --> Release --> Maintenance
                                            ^
                         a separate testing (QA) team starts here,
                         only after ALL the code is "done"
Enter fullscreen mode Exit fullscreen mode

Developers write all the code. Then they hand it over to a separate testing team, who run it, find problems and send them back. It's sometimes called "throwing the code over the wall."

This has real problems:

  • Feedback comes late. A bug written in week 2 might be found in week 12. By then, the developer has forgotten the details of that code.
  • Fixes are expensive. The later a bug is found, the more code is built on top of it, and the more places need to change.
  • Handoffs cause friction. Developers wait for test results. Testers wait for builds. Each side may blame the other.
  • Repeat work is manual. After every fix, someone must re-test the old features by hand to make sure nothing broke. This is called regression testing, and by hand it's slow and boring.

Today, most teams work differently. Developers test their own code as they write it, with automated tests that run on every change. A dedicated QA role still matters (for exploring the product, checking the whole system and thinking like a user), but it no longer carries the whole burden, and it doesn't wait until the end.

   MODERN:   write code + write tests --> run tests automatically on every commit
                  |                                  |
                  +---- fast feedback, within minutes ----+
Enter fullscreen mode Exit fullscreen mode

Unit testing

A unit test checks one small piece of code, usually a single method or class, in isolation. It's written by the developer, in code, and it runs automatically. A unit test answers one question: "Given this input, does this method give the output I expect?"

Why developers write them

  • Bugs are caught early, while the code is still fresh in your mind.
  • Refactoring becomes safe. You can improve the code and instantly know if you broke something.
  • They work as living documentation. A test shows exactly how a method is supposed to behave.
  • Regression testing becomes free. The whole suite runs in seconds.
  • They give confidence. You can change code without fear.

What makes a good unit test?

A handy memory trick is FIRST:

  • Fast: a test runs in milliseconds, so you can run thousands.
  • Independent: no test depends on another test or on the order.
  • Repeatable: it gives the same result every time, on any machine.
  • Self-validating: it passes or fails by itself, with no human checking the output.
  • Timely: written at the right time, ideally right alongside the code.

The Arrange-Act-Assert pattern

Most tests follow three steps:

@Test
void addsTwoNumbers() {
    Calculator calc = new Calculator();      // Arrange: set things up

    int result = calc.add(2, 3);             // Act: do the thing

    assertEquals(5, result);                 // Assert: check the result
}
Enter fullscreen mode Exit fullscreen mode

The test pyramid

Unit tests aren't the only kind. A healthy project has many cheap tests at the bottom and few expensive ones at the top:

             /\
            /  \         END-TO-END tests: few, slow, test the whole app
           /----\
          /      \       INTEGRATION tests: some, test parts working together
         /--------\
        /          \     UNIT tests: many, very fast, test small pieces alone
       /____________\
Enter fullscreen mode Exit fullscreen mode

This post is about the bottom layer: unit tests.

Test-driven development (TDD)

TDD flips the usual order. Instead of writing code and then tests, you write the test first, watch it fail, and only then write the code to make it pass. It follows a short loop called Red, Green, Refactor:

        +------------------------------------+
        |                                    |
        v                                    |
  1. RED      write a small test that FAILS (the code doesn't exist yet)
        |
        v
  2. GREEN    write the SIMPLEST code that makes the test pass
        |
        v
  3. REFACTOR clean up the code, tests stay green
        |
        +------------------------------------> repeat with the next small test
Enter fullscreen mode Exit fullscreen mode

Let's try it. The rule we want: orders of 1000 or more get 10% off, smaller orders get no discount, and negative amounts are invalid.

Step 1 (red). Write the first test before the class exists:

class DiscountCalculatorTest {

    @Test
    void noDiscountBelow1000() {
        assertEquals(500, new DiscountCalculator().applyDiscount(500));
    }
}
Enter fullscreen mode Exit fullscreen mode

It doesn't even compile yet (cannot find symbol: class DiscountCalculator). That counts as red. Now write the simplest code that passes:

public class DiscountCalculator {
    public int applyDiscount(int amount) {
        return amount;
    }
}
Enter fullscreen mode Exit fullscreen mode

Green.

Step 2 (red). Add the next test:

@Test
void tenPercentOffAt1000() {
    assertEquals(900, new DiscountCalculator().applyDiscount(1000));
}
Enter fullscreen mode Exit fullscreen mode
[ERROR] DiscountCalculatorTest.tenPercentOffAt1000 expected: <900> but was: <1000>
Enter fullscreen mode Exit fullscreen mode

Red. Now make it pass:

public int applyDiscount(int amount) {
    if (amount >= 1000) {
        return amount - amount / 10;
    }
    return amount;
}
Enter fullscreen mode Exit fullscreen mode

Green.

Step 3 (red). Negative amounts should fail:

@Test
void rejectsNegativeAmounts() {
    assertThrows(IllegalArgumentException.class,
        () -> new DiscountCalculator().applyDiscount(-1));
}
Enter fullscreen mode Exit fullscreen mode
[ERROR] Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
Enter fullscreen mode Exit fullscreen mode

Make it pass, then refactor the magic numbers into constants. After the cleanup, all three tests still pass, which proves the refactoring didn't break anything:

public class DiscountCalculator {
    private static final int THRESHOLD = 1000;
    private static final int PERCENT = 10;

    public int applyDiscount(int amount) {
        if (amount < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
        if (amount >= THRESHOLD) {
            return amount - amount * PERCENT / 100;
        }
        return amount;
    }
}
Enter fullscreen mode Exit fullscreen mode
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
Enter fullscreen mode Exit fullscreen mode

TDD's benefits: you only write the code that's needed, every feature has a test from the start, and the code tends to be easier to test (and often simpler) because you thought about how it would be used first.

JUnit 4 vs JUnit 5

JUnit 4 served Java for over a decade. JUnit 5 (also called Jupiter, after the name of its new programming model) was a complete rewrite. The main reasons:

  • JUnit 4 was one big jar, hard to extend, and it couldn't use modern Java features.
  • Its extension tools (@RunWith and @Rule) were awkward, and you couldn't combine several runners.
  • Tools like IDEs had to depend on its internal classes.

No backward compatibility

JUnit 5 is not backward compatible with JUnit 4. It uses new packages and new annotations:

  • JUnit 4: org.junit.Test
  • JUnit 5: org.junit.jupiter.api.Test

A test class written with JUnit 4 annotations won't run on the Jupiter engine, and the other way around. They're simply different APIs.

The saving grace is the Vintage engine (we'll meet it in the architecture section). It lets the new platform run your old JUnit 4 tests, so you can migrate gradually, with old and new tests living side by side.

The same test, written both ways

JUnit 4:

import static org.junit.Assert.assertEquals;
import org.junit.Before;
import org.junit.Test;

public class CalculatorTest {
    private Calculator calc;

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

    @Test(expected = ArithmeticException.class)
    public void divideByZero() {
        calc.divide(1, 0);
    }
}
Enter fullscreen mode Exit fullscreen mode

JUnit 5:

import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class CalculatorTest {
    private Calculator calc;

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

    @Test
    void divideByZero() {
        assertThrows(ArithmeticException.class, () -> calc.divide(1, 0));
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice that in JUnit 5, the class and methods don't need to be public. Package-private is fine.

The renamed pieces

  JUNIT 4                         JUNIT 5
  org.junit.Test                  org.junit.jupiter.api.Test
  @Before                         @BeforeEach
  @After                          @AfterEach
  @BeforeClass                    @BeforeAll
  @AfterClass                     @AfterAll
  @Ignore                         @Disabled
  @Category                       @Tag
  @RunWith / @Rule                @ExtendWith  (the extension model)
  @Test(expected = X.class)       assertThrows(X.class, ...)
  @Test(timeout = 500)            @Timeout  or  assertTimeout(...)
  Assert  (the class)             Assertions  (the class)
  assertEquals(msg, exp, act)     assertEquals(exp, act, msg)   <- message moved to the END
Enter fullscreen mode Exit fullscreen mode

That last row is a classic trap. In JUnit 4, the optional failure message came first. In JUnit 5, it comes last. If you migrate carelessly, your code may still compile (when everything is a String), but behave confusingly.

What's new in JUnit 5

  • @DisplayName: readable names with spaces and symbols.
  • assertAll: check several things and see all failures at once.
  • assertThrows: with lambdas, replaces @Test(expected=...).
  • @Nested: group tests into inner classes.
  • @ParameterizedTest: one test, many inputs.
  • @RepeatedTest, @Tag, @Timeout, @TempDir.
  • Dynamic tests (@TestFactory): create tests at run time.
  • Test classes and methods can be package-private.
  • Dependency injection for test constructors and methods, through extensions.
  • A real extension model that replaces runners and rules.
  • It needs only Java 8 (JUnit 6 needs Java 17).

What changed in JUnit 6

Going from JUnit 5 to 6 is much gentler than going from 4 to 5. The org.junit.jupiter.api package and the annotations stay, so most JUnit 5 tests compile and run unchanged. The main changes:

  • Java 17 is the minimum (JUnit 5 needed only Java 8). Kotlin projects need Kotlin 2.2 or newer.
  • One version number for everything. In JUnit 5, the versions didn't match: Jupiter and Vintage were 5.x, but the Platform was 1.x (for example, Platform 1.14 went with Jupiter 5.14). In JUnit 6, the Platform, Jupiter and Vintage all share the same version, like 6.1.0. That makes dependency management much simpler.
  • Kotlin coroutine support. Kotlin tests can use suspend functions directly.
  • A cancellation API in the Platform, so a running test run can be stopped early.
  • Clean-up of old modules. junit-platform-runner and junit-platform-jfr were removed, and junit-jupiter-migrationsupport (a helper that let some JUnit 4 rules work inside Jupiter tests) is deprecated.
  • Standard Java rules are now followed for which fields and methods are visible or overridden.
  • If you use @CsvSource with complex or unusual CSV data, double-check those tests after upgrading.

To upgrade from JUnit 5, the usual steps are:

  1. Make sure your project builds on Java 17 or newer.
  2. Change the JUnit version to a 6.x version (a BOM makes this a one-line change).
  3. Read the official Migration Guide on the JUnit site.
  4. Move off anything that was removed or deprecated.
  5. Run your tests.

JUnit 5 architecture

JUnit 5 isn't one jar. It's a set of three parts, each with its own job:

  JUnit 5  =  JUnit PLATFORM  +  JUnit JUPITER  +  JUnit VINTAGE
Enter fullscreen mode Exit fullscreen mode

1. JUnit Platform

The foundation. It does two things:

  • It defines how test frameworks plug in, through the TestEngine API.
  • It provides the Launcher, which finds tests and starts them. Build tools and IDEs call this Launcher.

2. JUnit Jupiter

The new programming model (the annotations and assertions you write) and the engine that runs them. When you write @Test from org.junit.jupiter.api, you're using Jupiter. It also contains the extension model for adding custom behavior.

3. JUnit Vintage

An engine that runs old JUnit 3 and JUnit 4 tests on the new platform. It's what makes gradual migration possible.

Here's the whole picture:

    IDEs (IntelliJ IDEA, Eclipse, VS Code)       Build tools (Maven Surefire, Gradle)
                        \                               /
                         v                             v
  +-------------------------------------------------------------------------+
  |                          JUNIT PLATFORM                                  |
  |         Launcher API (finds and starts tests)  +  TestEngine API         |
  +-------------------------------------------------------------------------+
             |                       |                        |
             v                       v                        v
      JUnit Jupiter            JUnit Vintage           Third-party engines
   (JUnit 5 tests and        (runs JUnit 3/4         (Spock, Cucumber, TestNG...)
    extension model)          tests)
Enter fullscreen mode Exit fullscreen mode

Why this design is smart

The IDEs and build tools talk only to the Platform's Launcher, not to any particular test framework. When IntelliJ IDEA shows green and red bars, or Maven runs your tests, they ask the Launcher to discover and run tests, and the Launcher asks each available engine to do its part. Because of this:

  • Adding a new test framework doesn't require changing every IDE and build tool. It just needs a new engine.
  • You can run JUnit 5 and JUnit 4 tests in the same project.

What happens when a test runs (behind the scenes)

  Surefire or IDE
       |
       v
  Launcher --> Jupiter engine: scans for classes with @Test methods (using reflection)
                     |
                     |  for EACH test method:
                     |     1. create a NEW instance of the test class
                     |     2. run @BeforeEach methods
                     |     3. run the @Test method
                     |     4. run @AfterEach methods
                     |
                     v
             report PASS / FAIL / SKIPPED back up to the Launcher --> shown in the IDE or Maven
Enter fullscreen mode Exit fullscreen mode

The extension model

In JUnit 4, you extended behavior with @RunWith (only one allowed per class) and @Rule. In Jupiter, there's one concept: the extension. You add it with @ExtendWith, and you can add many. Libraries like Mockito and Spring plug in this way. We'll write a tiny one later.

JUnit 5 in a Maven project

You need two things in your pom.xml: the JUnit dependency and a recent Surefire plugin.

<properties>
    <maven.compiler.release>17</maven.compiler.release>
    <junit.version>5.14.3</junit.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>${junit.version}</version>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.5.0</version>
        </plugin>
    </plugins>
</build>
Enter fullscreen mode Exit fullscreen mode

What each part does:

  • junit-jupiter is a convenient "aggregate" dependency. It brings in three libraries at once: junit-jupiter-api (what you write tests with), junit-jupiter-params (parameterized tests) and junit-jupiter-engine (what runs them).
  • <scope>test</scope> means the library is used only for testing, and it's not packaged into your final JAR.
  • Surefire is the Maven plugin that runs your unit tests during the test phase. JUnit 5 needs Surefire version 2.22.0 or newer, and I pin a recent one so builds are repeatable.
  • To use JUnit 6 instead, change junit.version to a 6.x version and make sure you use Java 17 or newer.
  • To keep running old JUnit 4 tests, add junit-vintage-engine as another test dependency.

How Surefire finds your tests

  • Test code goes in src/test/java.
  • By default, Surefire runs classes named *Test, Test*, *Tests or *TestCase.
  • When Surefire sees the Jupiter engine on the test classpath, it switches to its JUnit Platform provider, which uses the Launcher from the architecture section.

Run your tests:

mvn test
Enter fullscreen mode Exit fullscreen mode

You can run a single class or method:

mvn test -Dtest=CalculatorTest
mvn test -Dtest=CalculatorTest#addsTwoNumbers
Enter fullscreen mode Exit fullscreen mode

A trimmed result looks like this (numbers and times will differ):

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.example.CalculatorTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Enter fullscreen mode Exit fullscreen mode

Detailed reports are saved in target/surefire-reports. Note the difference between two counts: a Failure means an assertion didn't hold, and an Error means an unexpected exception happened.

(Surefire runs unit tests. For slower integration tests, Maven teams usually use the Failsafe plugin, with files named *IT.)

A quick note for Gradle users

In Gradle, add the same dependency under testImplementation, and turn on JUnit Platform in the test task:

tasks.test {
    useJUnitPlatform()
}
Enter fullscreen mode Exit fullscreen mode

Your first JUnit 5 test

Here's the class we'll test throughout the rest of the post:

package com.example;

public class Calculator {

    public int add(int a, int b) {
        return a + b;
    }

    public int divide(int a, int b) {
        return a / b;
    }

    public boolean isEven(int n) {
        return n % 2 == 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

And its test, in src/test/java/com/example/CalculatorTest.java:

package com.example;

import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.Test;

class CalculatorTest {

    private final Calculator calc = new Calculator();

    @Test
    void addsTwoNumbers() {
        assertEquals(5, calc.add(2, 3));
    }
}
Enter fullscreen mode Exit fullscreen mode

Three things to notice:

  • @Test marks a method as a test.
  • The import static ...Assertions.* line lets us write assertEquals(...) without the class name.
  • The class and method are package-private. No public needed.

When a test fails, the report tells you exactly what happened. If we wrote assertEquals(6, calc.add(2, 3)):

[ERROR] Failures:
[ERROR]   CalculatorTest.addsTwoNumbers:15 expected: <6> but was: <5>
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
Enter fullscreen mode Exit fullscreen mode

Assertions

An assertion is a check inside a test. If it holds, the test continues. If it doesn't, the test fails right there.

In JUnit 5, all the assertions are static methods of one class: org.junit.jupiter.api.Assertions. (Careful: in JUnit 4, the class was called Assert, singular. In JUnit 5 it's Assertions, plural.)

assertEquals and assertNotEquals

assertEquals(expected, actual) passes when the two values are equal. Always put the expected value first, because the failure message uses that order.

@Test
void equalityChecks() {
    assertEquals(5, calc.add(2, 3));
    assertNotEquals(4, calc.add(2, 3));
    assertEquals("Java", "Ja" + "va");
}
Enter fullscreen mode Exit fullscreen mode

For double and float, you must give a tolerance (called a delta), because of rounding errors:

@Test
void doublesNeedADelta() {
    assertEquals(0.3, 0.1 + 0.2, 0.0001);   // passes
    assertEquals(0.3, 0.1 + 0.2);           // FAILS
}
Enter fullscreen mode Exit fullscreen mode
expected: <0.3> but was: <0.30000000000000004>
Enter fullscreen mode Exit fullscreen mode

0.1 + 0.2 isn't exactly 0.3 in floating-point math. The delta says "close enough is fine."

For objects, assertEquals uses the object's .equals() method.

assertTrue and assertFalse

@Test
void booleanChecks() {
    assertTrue(calc.isEven(4));
    assertFalse(calc.isEven(7));
}
Enter fullscreen mode Exit fullscreen mode

Use these when the thing you're checking is naturally a yes/no. If you're comparing two values, prefer assertEquals, because its failure message shows both values.

assertNull, assertNotNull, assertSame, assertNotSame

@Test
void nullAndSameChecks() {
    Map<String, String> map = new HashMap<>();
    assertNull(map.get("missing"));

    map.put("k", "v");
    assertNotNull(map.get("k"));

    String a = new String("hi");
    String b = new String("hi");
    assertEquals(a, b);        // same text
    assertNotSame(a, b);       // but two different objects

    String c = a;
    assertSame(a, c);          // exactly the same object
}
Enter fullscreen mode Exit fullscreen mode

assertEquals asks "are they equal?" (like .equals()). assertSame asks "are they the very same object?" (like ==).

assertArrayEquals and assertIterableEquals

Arrays don't override equals, so assertEquals compares their references, not their contents. Use assertArrayEquals:

@Test
void arrayChecks() {
    int[] expected = {1, 2, 3};
    int[] actual = {1, 2, 3};

    assertArrayEquals(expected, actual);     // passes: compares the contents
    assertEquals(expected, actual);          // FAILS: two different array objects
}
Enter fullscreen mode Exit fullscreen mode
expected: <[I@6d06d69c> but was: <[I@7852e922>
Enter fullscreen mode Exit fullscreen mode

For collections, assertIterableEquals compares the items one by one, in order, even if the two lists are different types:

assertIterableEquals(List.of(1, 2, 3), new ArrayList<>(List.of(1, 2, 3)));
Enter fullscreen mode Exit fullscreen mode

There's also assertLinesMatch for comparing lists of text lines.

assertThrows and assertDoesNotThrow

assertThrows checks that some code throws a specific exception. It takes the exception class and a lambda (the code to run). It also returns the exception, so you can inspect it:

@Test
void divisionByZeroThrows() {
    ArithmeticException ex = assertThrows(
        ArithmeticException.class,
        () -> calc.divide(10, 0));

    assertEquals("/ by zero", ex.getMessage());
}
Enter fullscreen mode Exit fullscreen mode

This is the replacement for JUnit 4's @Test(expected = ...), and it's better, because it checks that exactly this line throws, not just any line in the method.

Related methods:

  • assertThrowsExactly(X.class, ...) requires the exact type. (assertThrows also accepts a subclass.)
  • assertDoesNotThrow(() -> calc.divide(10, 2)) checks that nothing is thrown.

assertAll

Normally, the first failed assertion stops the test, so you only see one problem at a time. assertAll runs a group of checks and reports all the failures together:

@Test
void groupedChecks() {
    String name = "Asha Rao";

    assertAll("name checks",
        () -> assertTrue(name.startsWith("Ravi")),
        () -> assertTrue(name.endsWith("Sharma")),
        () -> assertEquals(8, name.length())
    );
}
Enter fullscreen mode Exit fullscreen mode
org.opentest4j.MultipleFailuresError: name checks (2 failures)
   org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
   org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
Enter fullscreen mode Exit fullscreen mode

Two checks failed, and both were reported in a single run. It's useful when checking several fields of one object.

assertTimeout

Checks that code finishes within a time limit:

@Test
void finishesQuickly() {
    assertTimeout(Duration.ofMillis(500), () -> Thread.sleep(100));
}
Enter fullscreen mode Exit fullscreen mode
  • assertTimeout waits for the code to finish, then fails if it took too long.
  • assertTimeoutPreemptively runs the code in another thread and stops it as soon as the time is up. Use it with care, because things tied to the original thread (like some transactions) may not work.

You can also put @Timeout on a test method (see the annotations part).

fail()

fail() makes a test fail on purpose. It's handy for:

  • a placeholder for a test you haven't written yet,
  • a line that should never be reached.
@Test
void notWrittenYet() {
    fail("TODO: write this test");
}
Enter fullscreen mode Exit fullscreen mode
org.opentest4j.AssertionFailedError: TODO: write this test
Enter fullscreen mode Exit fullscreen mode

Failure messages

Every assertion accepts an optional message as the last argument:

assertEquals(5, calc.add(2, 2), "adding 2 and 2");
Enter fullscreen mode Exit fullscreen mode
adding 2 and 2 ==> expected: <5> but was: <4>
Enter fullscreen mode Exit fullscreen mode

If building the message is expensive, pass a lambda. It's only evaluated when the assertion fails:

assertTrue(calc.isEven(3), () -> "Expected an even number but got " + 3);
Enter fullscreen mode Exit fullscreen mode

Assumptions

Assumptions are different from assertions. If an assumption is false, the test is aborted (skipped), not failed. Use them when a test only makes sense in certain conditions:

import static org.junit.jupiter.api.Assumptions.assumeTrue;

@Test
void onlyOnCiServer() {
    assumeTrue("CI".equals(System.getenv("ENV")), "Runs only on the CI server");
    // the rest of the test runs only if the assumption holds
}
Enter fullscreen mode Exit fullscreen mode

In the Surefire summary, this shows up as Skipped.

Annotations

Annotations tell JUnit what each method is for. Here are the core ones in one place:

  • @Test: this method is a test.
  • @DisplayName: a readable name for the test.
  • @Disabled: skip this test.
  • @BeforeEach: run before each test.
  • @AfterEach: run after each test.
  • @BeforeAll: run once before all the tests in the class.
  • @AfterAll: run once after all the tests in the class.
  • @TestInstance: control how test class objects are created.

@test, @DisplayName and @Disabled

class CalculatorTest {

    private final Calculator calc = new Calculator();

    @Test
    @DisplayName("2 + 3 = 5")
    void addsTwoNumbers() {
        assertEquals(5, calc.add(2, 3));
    }

    @Test
    @Disabled("Bug #123: not fixed yet")
    void brokenForNow() {
        fail("Should not run");
    }
}
Enter fullscreen mode Exit fullscreen mode
  • @DisplayName shows a friendly name in IDEs and reports, with spaces and symbols if you like.
  • @Disabled skips the test. Always give a reason, so nobody forgets why it's off. It appears as Skipped.

@BeforeEach, @AfterEach, @BeforeAll and @AfterAll

These are lifecycle methods: they run at set moments around your tests. Use them for setup and cleanup. Let's print messages to see the order:

class LifecycleTest {

    @BeforeAll
    static void beforeAll() {
        System.out.println("beforeAll");
    }

    @BeforeEach
    void beforeEach() {
        System.out.println("  beforeEach");
    }

    @Test
    void testA() {
        System.out.println("    testA");
    }

    @Test
    void testB() {
        System.out.println("    testB");
    }

    @AfterEach
    void afterEach() {
        System.out.println("  afterEach");
    }

    @AfterAll
    static void afterAll() {
        System.out.println("afterAll");
    }
}
Enter fullscreen mode Exit fullscreen mode

Output (testA and testB may swap places):

beforeAll
  beforeEach
    testA
  afterEach
  beforeEach
    testB
  afterEach
afterAll
Enter fullscreen mode Exit fullscreen mode

Here is the timeline:

  @BeforeAll                    <- once, at the start
     @BeforeEach  testA  @AfterEach
     @BeforeEach  testB  @AfterEach
  @AfterAll                     <- once, at the end
Enter fullscreen mode Exit fullscreen mode

The rules:

  • @BeforeEach and @AfterEach run around every test. They're the right place to create fresh objects (like a new Calculator) so tests don't affect each other.
  • @BeforeAll and @AfterAll run once per class. They're for expensive setup, such as starting a test database.
  • By default, @BeforeAll and @AfterAll methods must be static. If they aren't, you get:
JUnit Jupiter @BeforeAll method 'beforeAll' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).
Enter fullscreen mode Exit fullscreen mode

That error message points to our next topic.

Test instance lifecycle (@TestInstance)

By default, JUnit creates a brand-new object of your test class for every single test method. This is the default setting, called PER_METHOD. It exists so that tests can't affect each other through fields. Watch:

class PerMethodTest {

    int counter = 0;

    @Test
    void first() {
        counter++;
        System.out.println("counter = " + counter);
    }

    @Test
    void second() {
        counter++;
        System.out.println("counter = " + counter);
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

counter = 1
counter = 1
Enter fullscreen mode Exit fullscreen mode

Both tests print 1, because each test got its own fresh object with counter starting at 0.

You can change this with @TestInstance, which takes one of two options from the TestInstance.Lifecycle enum:

  • PER_METHOD (the default): a new instance for each test method.
  • PER_CLASS: one instance shared by all the test methods in the class.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PerClassTest {

    int counter = 0;

    @Test
    void first() {
        counter++;
        System.out.println("counter = " + counter);
    }

    @Test
    void second() {
        counter++;
        System.out.println("counter = " + counter);
    }
}
Enter fullscreen mode Exit fullscreen mode

Output (the order of the two tests can change, but the counts keep rising):

counter = 1
counter = 2
Enter fullscreen mode Exit fullscreen mode

Now the one object is shared, so counter carries over. Here are the two modes side by side:

  PER_METHOD (default)                       PER_CLASS

  @BeforeAll (static)                        @BeforeAll (can be non-static)
    new instance #1 -> testA                   ONE instance -> testA
    new instance #2 -> testB                                 -> testB
  @AfterAll (static)                         @AfterAll (can be non-static)
Enter fullscreen mode Exit fullscreen mode

When should you use PER_CLASS?

  • When you want @BeforeAll and @AfterAll to be non-static, so they can use instance fields.
  • When you have expensive setup you want to share, and you accept the risk of shared state.
  • When you want to write ordered or dependent steps (though independent tests are usually better).

The cost is that tests can now affect each other through the shared fields, so the independent rule of FIRST is easier to break. If you use it, reset the state in @BeforeEach.

More features worth knowing

Parameterized tests

Instead of copying a test with different inputs, write it once and feed it many values. (It uses the junit-jupiter-params library, which is included in junit-jupiter.)

@ParameterizedTest
@ValueSource(ints = {2, 4, 6, 8})
void evenNumbersAreEven(int n) {
    assertTrue(calc.isEven(n));
}

@ParameterizedTest
@CsvSource({"1, 2, 3", "5, 5, 10", "-1, 1, 0"})
void addsManyPairs(int a, int b, int expected) {
    assertEquals(expected, calc.add(a, b));
}
Enter fullscreen mode Exit fullscreen mode

The first test runs 4 times, and the second 3 times. Each run is reported separately, so you see exactly which input failed. You can also use @MethodSource to feed data from a method.

Nested tests

@Nested lets you group tests into inner classes, which is nice for describing different situations:

class ListTest {

    List<String> list;

    @Nested
    class WhenEmpty {
        @BeforeEach
        void setUp() {
            list = new ArrayList<>();
        }

        @Test
        void isEmpty() {
            assertTrue(list.isEmpty());
        }
    }

    @Nested
    class WhenNotEmpty {
        @BeforeEach
        void setUp() {
            list = new ArrayList<>(List.of("a"));
        }

        @Test
        void hasOneItem() {
            assertEquals(1, list.size());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Repeated tests, tags, timeouts and ordering

  • @RepeatedTest(3) runs the same test three times.
  • @Tag("slow") labels a test. With Maven, you can include or exclude by tag: mvn test -Dgroups=slow or -DexcludedGroups=slow.
  • @Timeout(2) fails the test if it runs longer than 2 seconds (the default unit is seconds).
  • @TempDir gives your test a temporary folder that is deleted afterwards.
  • @TestMethodOrder with @Order sets the order of test methods. Don't rely on order in normal tests. It's meant for special cases.
  • Conditional tests like @EnabledOnOs(OS.LINUX) or @EnabledIfEnvironmentVariable run a test only in some environments.

Extensions

An extension is a class that hooks into the test lifecycle. Here's a tiny one that logs when each test starts and ends:

import org.junit.jupiter.api.extension.*;

class LoggingExtension implements BeforeEachCallback, AfterEachCallback {

    @Override
    public void beforeEach(ExtensionContext context) {
        System.out.println("START " + context.getDisplayName());
    }

    @Override
    public void afterEach(ExtensionContext context) {
        System.out.println("END   " + context.getDisplayName());
    }
}
Enter fullscreen mode Exit fullscreen mode
@ExtendWith(LoggingExtension.class)
class ExtensionDemoTest {

    @Test
    void works() {
        System.out.println("  running");
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

START works()
  running
END   works()
Enter fullscreen mode Exit fullscreen mode

Real libraries use the same mechanism. For example, @ExtendWith(MockitoExtension.class) sets up mock objects for you. And you can add as many extensions as you like.

Which one should I use?

  • JUnit 4 or JUnit 5? For new projects, use JUnit 5 (or JUnit 6 if you're on Java 17 or newer). Keep JUnit 4 only for old code, and run it through the Vintage engine while you migrate.
  • assertThrows or try/catch? Always assertThrows. It's shorter, and it fails if nothing is thrown.
  • assertEquals or assertTrue(a.equals(b))? assertEquals. On failure, it shows both values.
  • @BeforeEach or a field initializer? Either works for simple setup. Use @BeforeEach when setup needs several steps. Use final fields for simple, unchanging objects.
  • PER_METHOD or PER_CLASS? Stay with the default PER_METHOD for independent tests. Use PER_CLASS only when you need non-static @BeforeAll or expensive shared setup.
  • One test with a loop, or @ParameterizedTest? Parameterized. Each input is reported on its own.
  • JUnit's assertions or a fluent library? JUnit's built-in assertions are enough to start. Libraries like AssertJ and Hamcrest read more naturally for complex checks (JUnit 5 has no built-in assertThat).
  • Real dependencies or mocks? A unit test should be isolated. When your class talks to a database or a web service, replace it with a mock (a fake). Mockito is the common tool, and it plugs into JUnit 5 as an extension.

Common mistakes

  1. Mixing JUnit 4 and JUnit 5 imports. If you import org.junit.Test (JUnit 4) but only the Jupiter engine is present, your test is never discovered, and it silently doesn't run. Check that your imports start with org.junit.jupiter.api.
  2. Getting the argument order wrong. It's assertEquals(expected, actual), and the message comes last in JUnit 5. Swapping the first two gives misleading failure messages ("expected 5 but was 4" when it's the other way round).
  3. Comparing arrays or doubles the wrong way. assertEquals on arrays compares references, so use assertArrayEquals. And double values need a delta.
  4. Letting tests depend on each other. Tests that share state, or only work in a certain order, will fail randomly. Keep each test independent, and be careful with PER_CLASS.
  5. Tests that never run. The causes are an old Surefire (older than 2.22.0 can't find JUnit 5 tests), a class name that doesn't match *Test, or tests placed outside src/test/java. If Maven says "No tests to run," check these.
  6. Tests that can't fail. A test with no assertion always passes, which gives false comfort. And a try/catch inside a test can swallow the very exception you meant to catch. Use assertThrows, and make sure every test asserts something.

Quick recap

  • In the traditional SDLC, a separate team tested after coding was done, so bugs were found late and fixes were costly. Now developers write automated tests as they go.
  • A unit test checks one small piece of code in isolation. Good tests are FIRST: fast, independent, repeatable, self-validating and timely. Use the Arrange-Act-Assert pattern.
  • TDD is Red, Green, Refactor: write a failing test, write the simplest code to pass it, then clean up.
  • JUnit 5 is a rewrite and not backward compatible with JUnit 4: new package org.junit.jupiter.api, new annotations.
  • Architecture: Platform (the Launcher and TestEngine API that IDEs and build tools use) + Jupiter (the new tests and extension model) + Vintage (runs JUnit 3 and 4 tests).
  • With Maven: add junit-jupiter in test scope, and use Surefire 2.22.0 or newer. Run mvn test.
  • Assertions live in Assertions: assertEquals, assertNotEquals, assertTrue, assertFalse, assertNull, assertNotNull, assertSame, assertArrayEquals, assertIterableEquals, assertThrows, assertDoesNotThrow, assertAll, assertTimeout and fail(). The message goes last.
  • Annotations: @Test, @DisplayName, @Disabled, @BeforeEach, @AfterEach, @BeforeAll, @AfterAll.
  • @TestInstance: PER_METHOD (default, a new object per test) or PER_CLASS (one shared object, and non-static @BeforeAll and @AfterAll).
  • Also useful: @ParameterizedTest, @Nested, @RepeatedTest, @Tag, @Timeout and extensions through @ExtendWith.

Try it yourself

Here's a small practice idea: build a PasswordValidator class using TDD. The rules are: at least 8 characters, at least one digit and at least one uppercase letter. Write one failing test per rule first, make each pass with the simplest code, then refactor. Use a @ParameterizedTest with @CsvSource to check many valid and invalid passwords, and assertThrows for a null password.

For a bonus experiment, copy the LifecycleTest class from this post and add @TestInstance(TestInstance.Lifecycle.PER_CLASS). Make @BeforeAll non-static, and add a counter field to prove that the instance is shared. Then run a single test with mvn test -Dtest=CalculatorTest#addsTwoNumbers.

If this helped, or if you spotted something I should fix, tell me in the comments. I'm still learning too.

Top comments (0)