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
  • Unit testing
  • Test-driven development (TDD)
  • JUnit 4 vs JUnit 5
  • JUnit 5 architecture
  • JUnit 5 in a Maven project
  • Your first JUnit 5 test
  • Assertions
    • assertEquals and assertNotEquals
    • assertTrue and assertFalse
    • assertNull, assertNotNull, assertSame, assertNotSame
    • assertArrayEquals and assertIterableEquals
    • assertThrows and assertDoesNotThrow
    • assertAll
    • assertTimeout
    • fail()
    • Failure messages
    • Assumptions
  • Annotations
    • @Test, @DisplayName and @Disabled
    • @BeforeEach, @AfterEach, @BeforeAll and @AfterAll
  • Test instance lifecycle (@TestInstance)
  • More features worth knowing
  • Which one should I use?
  • Common mistakes
  • Quick recap
  • Try it yourself

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" 

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 ----+ 

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 } 

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 /____________\ 

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 

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)); } } 

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; } } 

Green.

Step 2 (red). Add the next test:

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

Red. Now make it pass:

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

Green.

Step 3 (red). Negative amounts should fail:

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

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; } } 
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 

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); } } 

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)); } } 

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 

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 

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) 

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 

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> 

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 

You can run a single class or method:

mvn test -Dtest=CalculatorTest mvn test -Dtest=CalculatorTest#addsTwoNumbers 

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 

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() } 

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; } } 

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)); } } 

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 

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"); } 

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 } 
expected: <0.3> but was: <0.30000000000000004> 

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)); } 

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 } 

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 } 
expected: <[I@6d06d69c> but was: <[I@7852e922> 

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))); 

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()); } 

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()) ); } 
org.opentest4j.MultipleFailuresError: name checks (2 failures) org.opentest4j.AssertionFailedError: expected: <true> but was: <false> org.opentest4j.AssertionFailedError: expected: <true> but was: <false> 

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)); } 
  • assertTimeout waits for the code to finish, then...