As your test suite grows, flat test classes become hard to navigate. JUnit 6 Nested Tests β using the @Nested annotation β let you organise related tests into inner class groups that produce a hierarchical, readable test plan. This guide covers every aspect of nested tests with real-world examples, output, and patterns used in production codebases.
What Are Nested Tests?
A @Nested test class is a non-static inner class inside a JUnit 6 test class. JUnit treats it as a group of tests that share a common context. The result is a tree-shaped test report that reads like a specification:
ShoppingCartTest
ββ WhenCartIsEmpty
β ββ β checkout throws EmptyCartException
β ββ β total is zero
ββ WhenCartHasOneItem
β ββ β total equals item price
β ββ β removing the item empties the cart
ββ WhenDiscountIsApplied
ββ β valid coupon reduces total by coupon percentage
ββ β expired coupon throws CouponExpiredException
Basic @Nested Example
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("ShoppingCart β item management and checkout")
class ShoppingCartTest {
// Shared object under test β each test in any nested class gets a fresh instance
private ShoppingCart cart;
@BeforeEach
void createEmptyCart() {
cart = new ShoppingCart();
}
// ----------------------------------------------------------------
// Nested class: groups tests for the "empty cart" scenario
// ----------------------------------------------------------------
@Nested
@DisplayName("When the cart is empty")
class WhenCartIsEmpty {
// Note: outer @BeforeEach runs first, creating an empty cart
// No additional setup needed here β the cart is already empty
@Test
@DisplayName("total is zero")
void totalIsZero() {
assertEquals(0.0, cart.getTotal(), 0.001);
}
@Test
@DisplayName("checkout throws EmptyCartException")
void checkoutThrowsEmptyCartException() {
assertThrows(EmptyCartException.class, () -> cart.checkout(),
"Checking out an empty cart must throw EmptyCartException");
}
@Test
@DisplayName("item count is zero")
void itemCountIsZero() {
assertEquals(0, cart.getItemCount());
}
}
// ----------------------------------------------------------------
// Nested class: groups tests for the "cart has items" scenario
// ----------------------------------------------------------------
@Nested
@DisplayName("When the cart has items")
class WhenCartHasItems {
@BeforeEach
void addItemsToCart() {
// Outer @BeforeEach already ran (cart is empty)
// This inner @BeforeEach adds items for this scenario
cart.addItem(new CartItem("Java Book", 49.99));
cart.addItem(new CartItem("JUnit Guide", 29.99));
}
@Test
@DisplayName("total equals sum of item prices")
void totalEqualsSumOfItemPrices() {
assertEquals(79.98, cart.getTotal(), 0.001);
}
@Test
@DisplayName("item count reflects number of added items")
void itemCountReflectsAddedItems() {
assertEquals(2, cart.getItemCount());
}
@Test
@DisplayName("removing an item decreases total correctly")
void removingItemDecreasesTotal() {
cart.removeItem("Java Book");
assertEquals(29.99, cart.getTotal(), 0.001);
}
// ----------------------------------------------------------------
// Deeply nested class: sub-scenario within "cart has items"
// ----------------------------------------------------------------
@Nested
@DisplayName("And a valid discount coupon is applied")
class AndValidDiscountCouponApplied {
@BeforeEach
void applyValidCoupon() {
// Outer @BeforeEach (empty cart) ran first
// Inner @BeforeEach (add items) ran second
// This @BeforeEach applies a coupon on top
cart.applyCoupon(new Coupon("SAVE10", 10)); // 10% off
}
@Test
@DisplayName("total is reduced by the coupon percentage")
void totalIsReducedByCouponPercentage() {
// 79.98 - 10% = 71.982
assertEquals(71.98, cart.getTotal(), 0.01);
}
@Test
@DisplayName("coupon is marked as used after checkout")
void couponIsMarkedAsUsedAfterCheckout() {
cart.checkout();
assertTrue(cart.getAppliedCoupon().isUsed());
}
}
}
}
Lifecycle Execution Order in Nested Classes
Lifecycle methods compose from outer to inner β the outer classβs hooks always run before the inner classβs hooks:
For test: "WhenCartHasItems > And a valid discount coupon is applied > total is reduced"
1. [Outer @BeforeEach] createEmptyCart() β outer class
2. [Inner @BeforeEach] addItemsToCart() β WhenCartHasItems
3. [Deepest @BeforeEach] applyValidCoupon() β AndValidDiscountCouponApplied
4. [Test Method] totalIsReducedByCouponPercentage()
5. [Deepest @AfterEach] (if any)
6. [Inner @AfterEach] (if any)
7. [Outer @AfterEach] (if any)
Real-World Pattern: Nested Tests for a REST Controller
@DisplayName("UserController API β registration and login")
class UserControllerTest {
private UserController controller;
private UserService userService;
@BeforeEach
void setUp() {
userService = new InMemoryUserService();
controller = new UserController(userService);
}
@Nested
@DisplayName("POST /api/users/register")
class RegisterEndpoint {
@Test
@DisplayName("returns 201 CREATED with valid registration data")
void returns201WithValidData() {
RegisterRequest request = new RegisterRequest("[email protected]", "SecurePass1!");
ResponseEntity response = controller.register(request);
assertEquals(201, response.getStatusCodeValue());
assertNotNull(response.getBody().getId());
}
@Test
@DisplayName("returns 400 BAD REQUEST when email is already taken")
void returns400WhenEmailAlreadyExists() {
userService.createUser("[email protected]", "pass");
RegisterRequest duplicate = new RegisterRequest("[email protected]", "AnotherPass1!");
ResponseEntity response = controller.register(duplicate);
assertEquals(400, response.getStatusCodeValue());
}
@Test
@DisplayName("returns 400 BAD REQUEST when password is too weak")
void returns400WhenPasswordIsTooWeak() {
RegisterRequest request = new RegisterRequest("[email protected]", "weak");
ResponseEntity response = controller.register(request);
assertEquals(400, response.getStatusCodeValue());
}
}
@Nested
@DisplayName("POST /api/users/login")
class LoginEndpoint {
@BeforeEach
void registerUserFirst() {
userService.createUser("[email protected]", "SecurePass1!");
}
@Test
@DisplayName("returns 200 OK with valid credentials")
void returns200WithValidCredentials() {
LoginRequest request = new LoginRequest("[email protected]", "SecurePass1!");
ResponseEntity response = controller.login(request);
assertEquals(200, response.getStatusCodeValue());
assertNotNull(response.getBody().getToken());
}
@Test
@DisplayName("returns 401 UNAUTHORIZED with wrong password")
void returns401WithWrongPassword() {
LoginRequest request = new LoginRequest("[email protected]", "WrongPassword!");
ResponseEntity response = controller.login(request);
assertEquals(401, response.getStatusCodeValue());
}
}
}
Expected Test Report Output
UserController API β registration and login
POST /api/users/register
β returns 201 CREATED with valid registration data
β returns 400 BAD REQUEST when email is already taken
β returns 400 BAD REQUEST when password is too weak
POST /api/users/login
β returns 200 OK with valid credentials
β returns 401 UNAUTHORIZED with wrong password
Tests run: 5, Failures: 0
Rules and Constraints for @Nested
- Nested test classes must be non-static inner classes (they are implicitly non-static)
- They can have their own
@BeforeEach,@AfterEach,@BeforeAll, and@AfterAll @BeforeAlland@AfterAllin nested classes require@TestInstance(PER_CLASS)unless declared static- Nesting depth is unlimited, but more than 3 levels deep usually signals that the class needs splitting
- Outer class lifecycle methods always execute before inner class lifecycle methods
Frequently Asked Questions (FAQs)
Q1: Can @Nested classes have their own @BeforeAll method?
Yes, but with a caveat. In the default PER_METHOD lifecycle, @BeforeAll in a @Nested class must be static. If you annotate the @Nested class with @TestInstance(TestInstance.Lifecycle.PER_CLASS), then @BeforeAll can be non-static. This is useful for expensive one-time setup specific to that nested group, like starting an embedded server for that scenario only.
Q2: Does the outer class’s @BeforeEach run for nested class tests?
Yes, always. Lifecycle methods cascade from outer to inner. For any test inside a @Nested class, JUnit runs the outer classβs @BeforeEach first, then the nested classβs own @BeforeEach, then the test. This composition is what makes nested tests powerful β you layer setup progressively.
Q3: How many levels of nesting should I use?
Two to three levels is the practical sweet spot. One level groups by scenario. Two levels adds sub-scenario grouping. Beyond three levels, the cognitive overhead of tracking cascading lifecycle methods typically outweighs the organisational benefit. If you find yourself needing four or more levels, consider whether some of those scenarios should be separate test classes.
Q4: Can I use @Tag on a @Nested class?
Yes. @Tag on a @Nested class applies to all tests within it. This lets you tag an entire scenario group (e.g., @Tag("slow") on a DatabaseIntegrationScenario nested class) and filter it in or out at build time without tagging individual methods. See Tags and Test Suites in JUnit 6 for details.
Q5: Can @Nested classes be used with Spring Boot tests?
Yes. @Nested classes work seamlessly inside @SpringBootTest or @WebMvcTest classes. The Spring context is managed at the outer class level and shared with nested classes. This is an excellent pattern for grouping controller tests by endpoint (as shown in the UserController example above). See JUnit 6 with Spring Boot for complete examples.
See Also
- JUnit 6 Tutorial: Complete Series Index
- JUnit 6 Test Lifecycle Explained
- JUnit 6 Test Naming Conventions
- Tags and Test Suites in JUnit 6
- JUnit 6 with Spring Boot: Unit, Slice, and Integration Testing
Conclusion
@Nested classes transform a flat list of test methods into a structured, scenario-driven specification. Used correctly, they make your test suite as readable as a user story: βShoppingCart β When the cart has items β And a valid discount coupon is applied β total is reduced by the coupon percentage.β That level of clarity is worth every minute spent structuring your tests well.
Next: Parameterized Tests in JUnit 6 β run the same test logic against dozens of inputs without duplicating a single line of code.