Nested and Repeated Tests in JUnit 5

3 minute read

As your test classes grow, flat lists of test methods become hard to navigate. @Nested provides structure, and @RepeatedTest helps find flaky tests.


@DisplayName("UserService")
class UserServiceTest {

    private UserService userService;

    @BeforeEach
    void setUp() {
        userService = new UserService(new InMemoryUserRepository());
    }

    @Nested
    @DisplayName("createUser")
    class CreateUser {

        @Test
        @DisplayName("should create user with valid input")
        void shouldCreateUser() {
            User user = userService.createUser("Alice", "alice@example.com");
            assertNotNull(user.getId());
            assertEquals("Alice", user.getName());
        }

        @Test
        @DisplayName("should throw when name is blank")
        void shouldThrowWhenNameBlank() {
            assertThrows(IllegalArgumentException.class,
                () -> userService.createUser("", "alice@example.com"));
        }

        @Test
        @DisplayName("should throw when email is duplicate")
        void shouldThrowWhenEmailDuplicate() {
            userService.createUser("Alice", "alice@example.com");
            assertThrows(DuplicateEmailException.class,
                () -> userService.createUser("Bob", "alice@example.com"));
        }
    }

    @Nested
    @DisplayName("findUser")
    class FindUser {

        @Test
        @DisplayName("should find existing user by ID")
        void shouldFindById() {
            User created = userService.createUser("Alice", "alice@example.com");
            User found = userService.findById(created.getId());
            assertEquals("Alice", found.getName());
        }

        @Test
        @DisplayName("should return null for non-existent ID")
        void shouldReturnNullForMissing() {
            assertNull(userService.findById(999L));
        }
    }

    @Nested
    @DisplayName("deleteUser")
    class DeleteUser {

        private User existingUser;

        @BeforeEach
        void createTestUser() {
            existingUser = userService.createUser("Alice", "alice@example.com");
        }

        @Test
        @DisplayName("should delete existing user")
        void shouldDeleteUser() {
            userService.deleteById(existingUser.getId());
            assertNull(userService.findById(existingUser.getId()));
        }

        @Test
        @DisplayName("should throw when deleting non-existent user")
        void shouldThrowForMissing() {
            assertThrows(UserNotFoundException.class,
                () -> userService.deleteById(999L));
        }
    }
}

IDE Output

UserService
  ├── createUser
  │   ├── ✓ should create user with valid input
  │   ├── ✓ should throw when name is blank
  │   └── ✓ should throw when email is duplicate
  ├── findUser
  │   ├── ✓ should find existing user by ID
  │   └── ✓ should return null for non-existent ID
  └── deleteUser
      ├── ✓ should delete existing user
      └── ✓ should throw when deleting non-existent user

Nested Lifecycle Rules

  • Outer @BeforeEach runs before inner @BeforeEach.
  • Inner classes inherit outer lifecycle methods.
  • Inner classes cannot have @BeforeAll/@AfterAll (unless using PER_CLASS).
class OuterTest {

    @BeforeEach
    void outerSetup() {
        System.out.println("1. Outer @BeforeEach");
    }

    @Nested
    class InnerTest {

        @BeforeEach
        void innerSetup() {
            System.out.println("2. Inner @BeforeEach");
        }

        @Test
        void test() {
            System.out.println("3. Test runs");
        }
    }
}
// Output: 1 → 2 → 3

@RepeatedTest — Run Multiple Times

Useful for detecting flaky tests — tests that pass sometimes and fail other times.

@RepeatedTest(5)
void shouldAlwaysReturnPositive() {
    int result = randomService.generatePositiveNumber();
    assertTrue(result > 0);
}

With Custom Display Name

@RepeatedTest(value = 3, name = "Attempt {currentRepetition} of {totalRepetitions}")
void shouldBeConsistent() {
    assertEquals(42, calculator.compute());
}

Output:

Attempt 1 of 3  ✓
Attempt 2 of 3  ✓
Attempt 3 of 3  ✓

RepetitionInfo — Access Repetition Details

@RepeatedTest(5)
void testWithRepetitionInfo(RepetitionInfo info) {
    int current = info.getCurrentRepetition();   // 1, 2, 3, 4, 5
    int total = info.getTotalRepetitions();      // 5

    System.out.println("Running repetition " + current + " of " + total);
    // Use different data per repetition
}

When to Use What

Tool Use Case
@Nested Organize tests by method/scenario
@RepeatedTest Detect flaky tests, test randomness
@ParameterizedTest Same logic, different inputs
@Tag Filter by category (fast, slow)

Real-World Pattern: BDD-Style Nesting

@DisplayName("ShoppingCart")
class ShoppingCartTest {

    private ShoppingCart cart;

    @BeforeEach
    void setUp() {
        cart = new ShoppingCart();
    }

    @Nested
    @DisplayName("when empty")
    class WhenEmpty {

        @Test
        @DisplayName("should have zero items")
        void shouldHaveZeroItems() {
            assertEquals(0, cart.getItemCount());
        }

        @Test
        @DisplayName("should have zero total")
        void shouldHaveZeroTotal() {
            assertEquals(BigDecimal.ZERO, cart.getTotal());
        }
    }

    @Nested
    @DisplayName("after adding one item")
    class AfterAddingOne {

        @BeforeEach
        void addItem() {
            cart.add(new Product("Laptop", new BigDecimal("999.99")));
        }

        @Test
        @DisplayName("should have one item")
        void shouldHaveOneItem() {
            assertEquals(1, cart.getItemCount());
        }

        @Test
        @DisplayName("should show correct total")
        void shouldShowTotal() {
            assertEquals(new BigDecimal("999.99"), cart.getTotal());
        }

        @Nested
        @DisplayName("then removing it")
        class ThenRemoving {

            @BeforeEach
            void removeItem() {
                cart.removeAll();
            }

            @Test
            @DisplayName("should be empty again")
            void shouldBeEmpty() {
                assertEquals(0, cart.getItemCount());
            }
        }
    }
}

Key Takeaways

  1. @Nested turns flat test classes into organized hierarchies.
  2. Inner classes inherit outer @BeforeEach/@AfterEach.
  3. @RepeatedTest runs the same test N times — great for flaky test detection.
  4. BDD-style nesting (when..., then...) improves readability.
  5. Don’t nest more than 2-3 levels deep — it gets hard to follow.

What’s Next?

Next: Mockito Basics — mock dependencies so you can test classes in isolation.

Updated: