Display Names and Nested Tests

8 min read

A test report is a communication tool. When a CI build fails and a developer reads the report at midnight, the difference between createUserValid and User Registration → When given valid data → should return 201 and the new user ID is the difference between "I need to open the code to understand this" and "I understand the failure immediately." This lesson covers the two JUnit 5 features that transform test reports from lists of method names into readable documentation: @DisplayName and @Nested.

@DisplayName — human-readable names

By default, JUnit uses the method name as the test label. shouldRejectEmptyEmailAddress is reasonable. test1, checkUser, or verifyThatTheEndpointReturnsTheCorrectStatusCodeForAnAuthenticatedUser are not. @DisplayName overrides the label with anything you want — spaces, punctuation, emoji:

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
 
@DisplayName("User Registration")
class RegistrationTest {
 
    @Test
    @DisplayName("should create a user with valid data")
    void createUserValid() {
        User user = userService.register("Alice", "alice@test.com", "admin");
        assertNotNull(user.getId());
        assertEquals("Alice", user.getName());
    }
 
    @Test
    @DisplayName("should reject an empty email ❌")
    void rejectEmptyEmail() {
        assertThrows(IllegalArgumentException.class, () ->
            userService.register("Alice", "", "admin")
        );
    }
 
    @Test
    @DisplayName("should reject a duplicate email")
    void rejectDuplicateEmail() {
        userService.register("Alice", "alice@test.com", "admin");
        assertThrows(IllegalStateException.class, () ->
            userService.register("Bob", "alice@test.com", "user")
        );
    }
}

The IntelliJ test runner shows: User Registration > should create a user with valid data, User Registration > should reject an empty email ❌. The class-level @DisplayName becomes the parent label.

@DisplayNameGeneration — automatic names from method names

If you prefer a convention over individual annotations, @DisplayNameGeneration generates display names automatically from method names:

@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class User_Registration_Test {
 
    @Test void should_create_user_with_valid_data() { ... }
    // Displays as: "should create user with valid data"
 
    @Test void should_reject_empty_email() { ... }
    // Displays as: "should reject empty email"
}

ReplaceUnderscores replaces underscores with spaces — a lightweight BDD-style naming convention without a full Cucumber setup. Other generators include IndicativeSentences (reads class + method name as a sentence) and Simple (strips trailing parentheses). You can also implement your own DisplayNameGenerator.

@Nested turns a non-static inner class into a test context. Tests inside the inner class share the outer class's lifecycle setup. The report shows a tree: outer class → inner class → individual tests.

@DisplayName("User API")
class UserApiTest {
 
    @Nested
    @DisplayName("When creating a user")
    class WhenCreating {
 
        @Test
        @DisplayName("should return 201 for valid data")
        void validData() {
            Response response = given()
                .body("""{"name":"Alice","email":"alice@test.com"}""")
                .post("/api/users");
            assertEquals(201, response.getStatusCode());
        }
 
        @Test
        @DisplayName("should return 400 when email is missing")
        void missingEmail() {
            Response response = given()
                .body("""{"name":"Alice"}""")
                .post("/api/users");
            assertEquals(400, response.getStatusCode());
        }
    }
 
    @Nested
    @DisplayName("When deleting a user")
    class WhenDeleting {
 
        @Test
        @DisplayName("should return 204 for an existing user")
        void existingUser() {
            delete("/api/users/42")
                .then()
                .statusCode(204);
        }
 
        @Test
        @DisplayName("should return 404 for a non-existent user")
        void nonExistentUser() {
            delete("/api/users/9999")
                .then()
                .statusCode(404);
        }
    }
}

The report tree for this class:

User API
  When creating a user
    ✅ should return 201 for valid data
    ✅ should return 400 when email is missing
  When deleting a user
    ✅ should return 204 for an existing user
    ❌ should return 404 for a non-existent user

That is readable documentation. When should return 404 for a non-existent user fails, every developer reading the report knows exactly which scenario broke.

@Nested lifecycle inheritance

The outer class's @BeforeEach runs before every test in every inner class. Inner classes can add their own @BeforeEach that also runs — outer first, then inner:

class CheckoutTest {
 
    User loggedInUser;
 
    @BeforeEach
    void loginUser() {
        loggedInUser = userService.login("alice@test.com", "secret");
    }
 
    @Nested
    @DisplayName("With items in cart")
    class WithItemsInCart {
 
        Cart cart;
 
        @BeforeEach
        void addItems() {
            // Outer @BeforeEach ran first — loggedInUser is ready
            cart = cartService.create(loggedInUser.getId());
            cart.add("Widget", 2);
        }
 
        @Test
        @DisplayName("should calculate subtotal correctly")
        void subtotal() {
            assertEquals(19.98, cart.getSubtotal(), 0.01);
        }
 
        @Test
        @DisplayName("should apply discount code")
        void discountCode() {
            cart.applyCode("SAVE10");
            assertEquals(17.98, cart.getSubtotal(), 0.01);
        }
    }
}

TestNG has no equivalent to @Nested. The closest approximation is separate test classes that extend a base class, but you lose the hierarchical report structure. @Nested is one of Jupiter's genuinely unique features.

Nested test structure as a flow

⚠️ Common mistakes

  • Making @Nested classes static. JUnit 5 requires @Nested classes to be non-static inner classes because they need to hold a reference to the outer class instance (to inherit lifecycle setup). static class WhenCreating compiles but JUnit will not recognise it as a @Nested context — it will just be ignored.
  • Skipping @DisplayName entirely. Method names like test1, checkEndpoint, or auto-generated camelCase are readable to the engineer who wrote the test. Three months later, or in a CI failure read by someone else, they communicate nothing. Write the display name at the same time as the method — it takes ten seconds and pays dividends indefinitely.
  • @Nested without a @BeforeEach to differentiate the setup. If every inner class has identical setup and no class-specific fixture, @Nested adds structure without benefit. Use it when the inner class genuinely represents a distinct scenario context (logged in vs logged out, empty cart vs full cart) and can have its own setup.

🎯 Practice task

Rewrite a flat test class using @Nested and @DisplayName. 20–25 minutes.

  1. Take the test class you wrote in the previous lesson (or create a new ProductServiceTest). It should have at least five test methods covering different scenarios.
  2. Group those tests into at least two @Nested classes. Example: WhenProductExists and WhenProductDoesNotExist, or WithValidInput and WithInvalidInput.
  3. Add @DisplayName to the outer class, each inner class, and each test method. Use plain English — the test name should read as a requirement statement.
  4. Run mvn test. Find the output in IntelliJ's test tree or in the Surefire report. Confirm the hierarchy is visible.
  5. Add inner @BeforeEach. In WhenProductExists, add a @BeforeEach that creates a test product. Confirm that the outer @BeforeEach still runs first by adding System.out.println to both. The outer prints first.
  6. Stretch — @DisplayNameGeneration. Try @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) on one test class with underscore-separated method names. Compare the report with and without it — decide which naming convention you prefer.

Next lesson: @Disabled, @Tag, and @TestMethodOrder — controlling which tests run and in what order.

// tip to track lessons you complete and pick up where you left off across devices.