JUnit Basics

Write and run JUnit Jupiter tests: test methods, the per-test lifecycle, setup and teardown, nesting, tags and temporary directories.

Syntaximport org.junit.jupiter.api.*; class CartTest { @BeforeEach void setUp() { ... } @Test void addsAnItem() { Assertions.assertEquals(1, cart.size()); } }

JUnit is the standard test framework for Java. The current line is JUnit 6, released in September 2025; it needs Java 17 or newer and keeps the Jupiter programming model from JUnit 5, so annotations still live in org.junit.jupiter.api and most JUnit 5 tests run unchanged. Maven Surefire and Gradle start the tests through the JUnit Platform, and IDEs do the same.

A test class

A test is a method annotated with @Test. It returns void, is not private and not static, and neither the class nor the method needs to be public. Test code goes in src/test/java, usually in the same package as the class under test.

JUnit creates a new instance of the test class for every test method. A field set in one test is not seen by the next, which keeps tests independent. This is why ordinary fields and constructors work fine for setup.

Lifecycle annotations

  • @BeforeEach / @AfterEach: run around every test. Use them for fresh fixtures.
  • @BeforeAll / @AfterAll: run once per class and must be static, because no instance exists yet. Use them for expensive shared resources, such as a container or an HTTP server.
  • @Disabled("reason") skips a test; always give the reason.
  • @DisplayName gives a readable name in reports; @Tag("slow") lets the build include or exclude groups.
  • @Nested on a non-static inner class groups tests that share a setup.

Do not rely on the order tests run in. It is deterministic but deliberately not obvious, and a test that only passes after another has run will break when someone runs it alone.

Mistakes people make

  • Importing org.junit.Test (JUnit 4) instead of org.junit.jupiter.api.Test. When some library pulls JUnit 4 onto the class path, the import compiles, but without the Vintage engine those tests never run and the build stays green.
  • Writing a test with no assertion. It passes as long as nothing throws, which proves very little.
  • Using System.out.println to inspect results by eye. Put the expectation in an assertion so the build checks it every time.
  • Writing files to fixed paths. @TempDir gives each test a fresh directory and deletes it afterwards.

In a Spring Boot project, spring-boot-starter-test already brings JUnit Jupiter, AssertJ and Mockito with matching versions, so you add that one dependency instead of each library.

Example

Example · java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

@DisplayName("Shopping cart")
class CartTest {

    private Cart cart;

    @BeforeEach
    void setUp() {
        // Runs before every test, on a fresh instance of CartTest
        cart = new Cart();
    }

    @Test
    void startsEmpty() {
        assertTrue(cart.items().isEmpty());
    }

    @Test
    @DisplayName("adding the same SKU twice increases its quantity")
    void addingTheSameSkuTwiceIncreasesQuantity() {
        cart.add("SKU-1", 1);
        cart.add("SKU-1", 2);

        assertEquals(List.of(new CartItem("SKU-1", 3)), cart.items());
    }
}

// The class under test
record CartItem(String sku, int quantity) {}

class Cart {
    private final Map<String, Integer> quantities = new LinkedHashMap<>();

    void add(String sku, int quantity) {
        quantities.merge(sku, quantity, Integer::sum);
    }

    List<CartItem> items() {
        return quantities.entrySet().stream()
                .map(e -> new CartItem(e.getKey(), e.getValue()))
                .toList();
    }
}

When to use it

  • Add a first test class to an existing Maven service so the CI pipeline fails when a pricing rule breaks.
  • Tag slow tests that start an embedded server so the fast suite stays under a few seconds on every save.
  • Test a CSV export endpoint's writer with @TempDir instead of files left behind in the project directory.

More examples

Maven dependencies and Surefire

The test scope keeps JUnit out of the packaged service. Pinning the Surefire version avoids an old default plugin that may not find JUnit Platform tests.

Example · xml
<dependencyManagement>
  <dependencies>
    <!-- One BOM keeps every JUnit artifact on the same version -->
    <dependency>
      <groupId>org.junit</groupId>
      <artifactId>junit-bom</artifactId>
      <version>6.0.3</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <!-- API, parameterized tests and the engine in one artifact -->
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.5.6</version>
    </plugin>
  </plugins>
</build>

The same setup with Gradle (Kotlin DSL)

Without useJUnitPlatform(), Gradle looks for JUnit 4 tests and reports that nothing ran. Recent Gradle versions also expect the platform launcher on the test runtime class path, which is why it is declared explicitly.

Example · kotlin
dependencies {
    testImplementation(platform("org.junit:junit-bom:6.0.3"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
    useJUnitPlatform {
        // ./gradlew test runs everything except tests tagged "slow"
        excludeTags("slow")
    }
}

@Nested groups and @TempDir

Each @Nested class reads as a scenario in the test report, and outer fields such as the @TempDir path are visible inside it. A nested class must not be static, because JUnit creates it from an instance of the outer class.

Example · java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

class ReportExporterTest {

    @TempDir
    Path dir; // a fresh directory per test, deleted afterwards

    @Nested
    class WhenTheReportHasRows {
        private ReportExporter exporter;

        @BeforeEach
        void setUp() {
            exporter = new ReportExporter(dir);
        }

        @Test
        void writesOneCsvLinePerRowPlusAHeader() throws Exception {
            Path file = exporter.export(List.of(new Row("ord-1", 1999)));
            assertEquals(2, Files.readAllLines(file).size());
        }
    }

    @Nested
    class WhenTheReportIsEmpty {
        @Test
        void refusesToWriteAFile() {
            var exporter = new ReportExporter(dir);
            assertThrows(IllegalStateException.class, () -> exporter.export(List.of()));
        }
    }
}

// The class under test
record Row(String orderId, long totalCents) {}

class ReportExporter {
    private final Path dir;

    ReportExporter(Path dir) {
        this.dir = dir;
    }

    Path export(List<Row> rows) throws IOException {
        if (rows.isEmpty()) {
            throw new IllegalStateException("nothing to export");
        }
        var lines = new ArrayList<String>();
        lines.add("order_id,total_cents");
        for (Row row : rows) {
            lines.add(row.orderId() + "," + row.totalCents());
        }
        return Files.write(dir.resolve("report.csv"), lines);
    }
}

Discussion

  • Be the first to comment on this lesson.