Spring Boot
JUnit 5
JUnit 5 is the default test framework in Spring Boot, included via spring-boot-starter-test. It comprises three modules: JUnit Platform (launcher), JUnit Jupiter (API and engine), and JUnit Vintage (JUnit 3/4 compatibility). This entry covers the full JUnit 5 API — lifecycle annotations, assertions, assumptions, dynamic tests, extensions, conditional execution, and test ordering.
Lifecycle Annotations
JUnit 5 lifecycle annotations control setup and teardown at the method and class level. @BeforeEach and @AfterEach run before and after every test. @BeforeAll and @AfterAll run once per class — their methods must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).
Java
class OrderServiceLifecycleTest {
private OrderRepository orderRepo;
private InventoryService inventoryService;
private OrderService orderService;
// ── @BeforeAll — once before any test in the class ────────────────
@BeforeAll
static void initSharedResources() {
// One-time setup: start containers, load fixtures, etc.
System.out.println("Setting up shared resources");
}
// ── @BeforeEach — before every @Test method ───────────────────────
@BeforeEach
void setUp() {
orderRepo = mock(OrderRepository.class);
inventoryService = mock(InventoryService.class);
orderService = new OrderService(orderRepo,
inventoryService);
}
@Test
void create_savesOrder() {
when(orderRepo.save(any())).thenReturn(buildOrder(1L));
OrderResponse response = orderService.create(
buildRequest(), 1L);
assertThat(response).isNotNull();
}
// ── @AfterEach — after every @Test method ─────────────────────────
@AfterEach
void tearDown() {
// Clean up per-test state
verifyNoMoreInteractions(orderRepo);
}
// ── @AfterAll — once after all tests in the class ─────────────────
@AfterAll
static void cleanUpSharedResources() {
System.out.println("Tearing down shared resources");
}
}
// ── @TestInstance — avoid static for @BeforeAll ───────────────────────
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class OrderServicePerClassTest {
// Non-static shared state — safe with PER_CLASS lifecycle
private final List<String> log = new ArrayList<>();
@BeforeAll
void initAll() {
log.add("init"); // non-static — works with PER_CLASS
}
@AfterAll
void cleanAll() {
log.clear();
}
@Test
void firstTest() {
assertThat(log).contains("init");
}
}Assertions and Assumptions
JUnit 5 provides assertAll() for grouped assertions, assertThrows() for exception testing, and assertTimeout() for performance assertions. Assumptions abort a test (not fail it) when a precondition is not met — useful for environment-dependent tests.
Java
class JUnit5AssertionsTest {
// ── assertAll — all assertions evaluated regardless of failures ────
@Test
void userResponse_hasAllFields() {
UserResponse response = buildUserResponse();
assertAll("user response fields",
() -> assertThat(response.id())
.isEqualTo(1L),
() -> assertThat(response.email())
.isEqualTo("alice@example.com"),
() -> assertThat(response.name())
.isEqualTo("Alice"),
() -> assertThat(response.roles())
.containsExactly("USER"),
() -> assertThat(response.createdAt())
.isNotNull()
);
// Reports ALL failures, not just the first
}
// ── assertThrows — capture and inspect the exception ─────────────
@Test
void findById_throws_orderNotFoundException() {
when(orderRepo.findById(99L))
.thenReturn(Optional.empty());
OrderNotFoundException ex = assertThrows(
OrderNotFoundException.class,
() -> orderService.findById(99L, 1L));
assertThat(ex.getMessage())
.contains("99");
assertThat(ex.getOrderId())
.isEqualTo(99L);
}
// ── assertDoesNotThrow ────────────────────────────────────────────
@Test
void findById_doesNotThrow_whenFound() {
when(orderRepo.findByIdAndUserId(1L, 1L))
.thenReturn(Optional.of(buildOrder(1L)));
assertDoesNotThrow(() ->
orderService.findById(1L, 1L));
}
// ── assertTimeout — verify performance ───────────────────────────
@Test
void findAll_completesWithinDeadline() {
when(orderRepo.findAll(any(Pageable.class)))
.thenReturn(Page.empty());
assertTimeout(Duration.ofMillis(100), () ->
orderService.findAll(PageRequest.of(0, 20)));
}
// ── Assumptions — skip test when precondition not met ─────────────
@Test
void externalApiCall_skipped_inCiEnvironment() {
// Skip if running in CI where external calls are blocked
assumeTrue(System.getenv("CI") == null,
"Skipping external API test in CI");
// This only runs locally
ExternalApiResponse response =
externalApiService.call();
assertThat(response).isNotNull();
}
@Test
void postgresSpecific_skipped_onH2() {
assumingThat(
databaseUrl.contains("postgresql"),
() -> {
// Only runs on PostgreSQL
assertThat(jsonbQueryResult).isNotEmpty();
}
);
}
}JUnit 5 Extensions
Extensions replace JUnit 4 runners and rules. A single extension can implement multiple extension points: BeforeEachCallback, AfterEachCallback, ParameterResolver, and more. @ExtendWith registers an extension; @RegisterExtension registers a programmatic instance with full access to constructor parameters.
Java
// ── Custom extension ──────────────────────────────────────────────────
public class TimingExtension
implements BeforeEachCallback, AfterEachCallback {
private final Map<String, Long> startTimes =
new ConcurrentHashMap<>();
@Override
public void beforeEach(ExtensionContext context) {
startTimes.put(context.getUniqueId(),
System.currentTimeMillis());
}
@Override
public void afterEach(ExtensionContext context) {
long start = startTimes.remove(context.getUniqueId());
long elapsed = System.currentTimeMillis() - start;
System.out.printf("Test '%s' took %dms%n",
context.getDisplayName(), elapsed);
}
}
// ── Parameter resolver extension ──────────────────────────────────────
public class RandomUserExtension
implements ParameterResolver {
@Override
public boolean supportsParameter(
ParameterContext paramCtx,
ExtensionContext extCtx) {
return paramCtx.getParameter().getType()
.equals(User.class);
}
@Override
public Object resolveParameter(
ParameterContext paramCtx,
ExtensionContext extCtx) {
return TestFixtures.buildUser(
(long) (Math.random() * 1000),
"user" + System.nanoTime() + "@test.com");
}
}
// ── Use extensions via annotation ─────────────────────────────────────
@ExtendWith({MockitoExtension.class, TimingExtension.class})
class OrderServiceTest {
@Mock private OrderRepository orderRepo;
@InjectMocks private OrderService orderService;
@Test
void create_isPerformant(User injectedUser) {
// injectedUser provided by RandomUserExtension if added
when(orderRepo.save(any())).thenReturn(buildOrder(1L));
orderService.create(buildRequest(), 1L);
}
}
// ── @RegisterExtension — programmatic with state ─────────────────────
class WireMockExtensionTest {
@RegisterExtension
static WireMockExtension wireMock =
WireMockExtension.newInstance()
.options(wireMockConfig().port(9090))
.build();
@Test
void callsExternalService() {
wireMock.stubFor(get("/api/stock/1")
.willReturn(okJson("{"available": true}")));
StockResponse response = client.checkStock(1L);
assertThat(response.available()).isTrue();
}
}Conditional Tests and Tagging
Conditional annotations skip tests based on the operating system, JVM version, environment variable, system property, or a custom condition. @Tag groups tests for selective execution — run only fast tests in CI, skip slow tests in development, or filter by feature area.
Java
// ── @Tag — group tests for selective execution ───────────────────────
@Tag("fast")
@Tag("unit")
class UserServiceFastTest {
@Test void create_validates_email() { ... }
}
@Tag("slow")
@Tag("integration")
@Tag("database")
class UserRepositoryIntegrationTest {
@Test void findByEmail_queriesDatabase() { ... }
}
// ── Run only tagged tests ─────────────────────────────────────────────
// Maven: mvn test -Dgroups="fast"
// Maven: mvn test -DexcludedGroups="slow"
// Gradle: test { useJUnitPlatform { includeTags "fast" } }
// ── @EnabledOnOs — OS-specific tests ─────────────────────────────────
@Test
@EnabledOnOs(OS.LINUX)
void linuxSpecificFilePermissions() { ... }
@Test
@DisabledOnOs({OS.WINDOWS})
void posixPermissionTest() { ... }
// ── @EnabledIfEnvironmentVariable ────────────────────────────────────
@Test
@EnabledIfEnvironmentVariable(named = "RUN_EXPENSIVE_TESTS",
matches = "true")
void expensiveIntegrationTest() { ... }
// ── @EnabledIfSystemProperty ──────────────────────────────────────────
@Test
@EnabledIfSystemProperty(named = "test.database",
matches = "postgres")
void postgresOnlyTest() { ... }
// ── @EnabledForJreRange ───────────────────────────────────────────────
@Test
@EnabledForJreRange(min = JRE.JAVA_17,
max = JRE.JAVA_21)
void java17To21Feature() { ... }
// ── Custom @Condition annotation ──────────────────────────────────────
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@EnabledIf("isDockerAvailable")
public @interface EnabledIfDockerAvailable {}
public class DockerCondition {
static boolean isDockerAvailable() {
try {
Process p = Runtime.getRuntime()
.exec("docker info");
return p.waitFor() == 0;
} catch (Exception e) {
return false;
}
}
}
// ── @TestMethodOrder — deterministic ordering ─────────────────────────
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class OrderedIntegrationTest {
@Test @Order(1)
void createUser() { ... }
@Test @Order(2)
void loginUser() { ... }
@Test @Order(3)
void placeOrder() { ... }
@Test @Order(4)
void cancelOrder() { ... }
}Dynamic Tests and Test Templates
@TestFactory generates test cases at runtime from a data source — a database, file, or computed set. Each generated test appears individually in the test report. @TestTemplate defines a test that runs multiple times based on a TestTemplateInvocationContextProvider extension.
Java
class DynamicTestsExample {
private final PricingService pricingService =
new PricingService();
// ── @TestFactory — generate tests from a list ─────────────────────
@TestFactory
Stream<DynamicTest> pricing_calculatesCorrectDiscounts() {
record Scenario(String tier, BigDecimal price,
BigDecimal expected) {}
List<Scenario> scenarios = List.of(
new Scenario("STANDARD", bd("100"), bd("0")),
new Scenario("SILVER", bd("100"), bd("5")),
new Scenario("GOLD", bd("100"), bd("10")),
new Scenario("PLATINUM", bd("100"), bd("15")),
new Scenario("GOLD", bd("50"), bd("5")),
new Scenario("PLATINUM", bd("200"), bd("30"))
);
return scenarios.stream().map(s ->
DynamicTest.dynamicTest(
"Tier=" + s.tier() + " price=" + s.price(),
() -> {
BigDecimal discount =
pricingService.discount(s.price(), s.tier());
assertThat(discount)
.isEqualByComparingTo(s.expected());
}));
}
// ── @TestFactory from external data source ─────────────────────────
@TestFactory
Stream<DynamicTest> validationRules_fromCsvFile()
throws Exception {
List<String[]> rows = readCsvFile("/test-data/validation.csv");
return rows.stream().map(row ->
DynamicTest.dynamicTest(row[0], () -> {
String input = row[1];
boolean expected = Boolean.parseBoolean(row[2]);
assertThat(validator.isValid(input))
.isEqualTo(expected);
}));
}
// ── Nested dynamic container ───────────────────────────────────────
@TestFactory
Stream<DynamicNode> orderStatusTransitions() {
return Stream.of(
dynamicContainer("PENDING transitions",
Stream.of(
dynamicTest("→ CONFIRMED is allowed",
() -> assertThat(
OrderStatus.PENDING.canTransitionTo(
OrderStatus.CONFIRMED)).isTrue()),
dynamicTest("→ SHIPPED is not allowed",
() -> assertThat(
OrderStatus.PENDING.canTransitionTo(
OrderStatus.SHIPPED)).isFalse())
)),
dynamicContainer("CONFIRMED transitions",
Stream.of(
dynamicTest("→ SHIPPED is allowed",
() -> assertThat(
OrderStatus.CONFIRMED.canTransitionTo(
OrderStatus.SHIPPED)).isTrue()),
dynamicTest("→ PENDING is not allowed",
() -> assertThat(
OrderStatus.CONFIRMED.canTransitionTo(
OrderStatus.PENDING)).isFalse())
))
);
}
private BigDecimal bd(String value) {
return new BigDecimal(value);
}
}