JUnit Basics
Write and run JUnit Jupiter tests: test methods, the per-test lifecycle, setup and teardown, nesting, tags and temporary directories.
import 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 bestatic, 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.@DisplayNamegives a readable name in reports;@Tag("slow")lets the build include or exclude groups.@Nestedon 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 oforg.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.printlnto inspect results by eye. Put the expectation in an assertion so the build checks it every time. - Writing files to fixed paths.
@TempDirgives 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
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;
("Shopping cart")
class CartTest {
private Cart cart;
void setUp() {
// Runs before every test, on a fresh instance of CartTest
cart = new Cart();
}
void startsEmpty() {
assertTrue(cart.items().isEmpty());
}
("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.
<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.
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.
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