Article
In modern Java testing, understanding the test lifecycle is crucial for writing effective, isolated, and maintainable tests. JUnit 5's @BeforeEach annotation plays a pivotal role in test setup and lifecycle management. This guide explores how to leverage @BeforeEach and other lifecycle annotations to create robust test suites.
JUnit 5 Test Lifecycle Overview
JUnit 5 introduces a clear and predictable test lifecycle with several key annotations:
@BeforeAll: Runs once before all tests in the class@BeforeEach: Runs before each test method@Test: The actual test method@AfterEach: Runs after each test method@AfterAll: Runs once after all tests in the class
@BeforeEach in Depth
Basic @BeforeEach Usage
import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; class BasicBeforeEachTest { private List<String> list; private String testData; @BeforeEach void setUp() { // This method runs before EVERY @Test method list = new ArrayList<>(); list.add("apple"); list.add("banana"); list.add("cherry"); testData = "initial-data"; System.out.println("Set up completed for test"); } @Test void testListSize() { assertEquals(3, list.size()); // Each test gets a fresh list with 3 items } @Test void testListContents() { assertTrue(list.contains("banana")); assertFalse(list.contains("orange")); } @Test void testDataModification() { testData = "modified-data"; assertEquals("modified-data", testData); // Modification doesn't affect other tests } @Test void testDataIsReset() { // This test still sees the original testData value assertEquals("initial-data", testData); } } Real-World Example: Database Test Setup
import org.junit.jupiter.api.*; import java.sql.*; import java.util.Optional; class UserRepositoryTest { private UserRepository userRepository; private Connection connection; @BeforeEach void setUpDatabase() throws SQLException { // Set up in-memory H2 database for each test connection = DriverManager.getConnection( "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", "sa", "" ); // Create schema try (Statement stmt = connection.createStatement()) { stmt.execute(""" CREATE TABLE users ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL, email VARCHAR(100) UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """); } userRepository = new UserRepository(connection); // Insert test data userRepository.save(new User("john_doe", "[email protected]")); userRepository.save(new User("jane_smith", "[email protected]")); } @AfterEach void tearDown() throws SQLException { // Clean up after each test try (Statement stmt = connection.createStatement()) { stmt.execute("DROP TABLE users"); } connection.close(); } @Test void findUserByUsername() { Optional<User> user = userRepository.findByUsername("john_doe"); assertTrue(user.isPresent()); assertEquals("[email protected]", user.get().getEmail()); } @Test void saveNewUser() { User newUser = new User("bob_wilson", "[email protected]"); User saved = userRepository.save(newUser); assertNotNull(saved.getId()); assertEquals("bob_wilson", saved.getUsername()); } } // Supporting classes class User { private Long id; private String username; private String email; public User(String username, String email) { this.username = username; this.email = email; } // Getters and setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } } class UserRepository { private final Connection connection; public UserRepository(Connection connection) { this.connection = connection; } public User save(User user) throws SQLException { String sql = "INSERT INTO users (username, email) VALUES (?, ?)"; try (PreparedStatement stmt = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { stmt.setString(1, user.getUsername()); stmt.setString(2, user.getEmail()); stmt.executeUpdate(); try (ResultSet rs = stmt.getGeneratedKeys()) { if (rs.next()) { user.setId(rs.getLong(1)); } } } return user; } public Optional<User> findByUsername(String username) throws SQLException { String sql = "SELECT * FROM users WHERE username = ?"; try (PreparedStatement stmt = connection.prepareStatement(sql)) { stmt.setString(1, username); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { User user = new User(rs.getString("username"), rs.getString("email")); user.setId(rs.getLong("id")); return Optional.of(user); } } } return Optional.empty(); } } Advanced @BeforeEach Patterns
1. Conditional Setup with TestInfo
import org.junit.jupiter.api.*; import org.junit.jupiter.api.condition.EnabledOnOs; import org.junit.jupiter.api.condition.OS; class ConditionalSetupTest { private FileProcessor fileProcessor; private Path tempDirectory; @BeforeEach void setUp(TestInfo testInfo) throws IOException { // Use TestInfo to get information about the current test System.out.println("Setting up for test: " + testInfo.getDisplayName()); System.out.println("Tags: " + testInfo.getTags()); // Conditional setup based on test characteristics if (testInfo.getTags().contains("file-operation")) { tempDirectory = Files.createTempDirectory("test-files"); fileProcessor = new FileProcessor(tempDirectory); } else { fileProcessor = new FileProcessor(); } // Different setup for specific test methods if (testInfo.getDisplayName().contains("LargeFile")) { fileProcessor.setBufferSize(8192); } } @AfterEach void tearDown() throws IOException { if (tempDirectory != null) { // Clean up temporary files Files.walk(tempDirectory) .sorted(Comparator.reverseOrder()) .map(Path::toFile) .forEach(File::delete); } } @Test @Tag("file-operation") void testFileProcessing() { assertNotNull(tempDirectory); // Test file operations... } @Test void testInMemoryProcessing() { // Test without file operations... } @Test @DisplayName("processLargeFile") void testLargeFileProcessing() { assertEquals(8192, fileProcessor.getBufferSize()); } } class FileProcessor { private Path workspace; private int bufferSize = 1024; public FileProcessor() { this.workspace = null; } public FileProcessor(Path workspace) { this.workspace = workspace; } public void setBufferSize(int bufferSize) { this.bufferSize = bufferSize; } public int getBufferSize() { return bufferSize; } } 2. Parameter Injection with @BeforeEach
import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.RegisterExtension; import java.time.LocalDateTime; import java.util.logging.*; class ParameterInjectionTest { private TestLogger logger; private PaymentProcessor paymentProcessor; @RegisterExtension TestLoggerExtension loggerExtension = new TestLoggerExtension(); @BeforeEach void setUp(TestReporter testReporter, TestInfo testInfo) { // Inject TestReporter to publish additional test information testReporter.publishEntry("startTime", LocalDateTime.now().toString()); testReporter.publishEntry("testMethod", testInfo.getTestMethod().get().getName()); logger = loggerExtension.getLogger(); paymentProcessor = new PaymentProcessor(logger); logger.info("Setting up test: " + testInfo.getDisplayName()); } @Test void processPayment() { paymentProcessor.process(100.0, "USD"); // Verify payment processing... } @Test void processRefund() { paymentProcessor.refund(50.0, "USD"); // Verify refund processing... } } // Custom extension for logging class TestLoggerExtension implements BeforeEachCallback, AfterEachCallback { private Logger logger; public Logger getLogger() { return logger; } @Override public void beforeEach(ExtensionContext context) { logger = Logger.getLogger(context.getRequiredTestClass().getName()); logger.info("Starting test: " + context.getDisplayName()); } @Override public void afterEach(ExtensionContext context) { logger.info("Completed test: " + context.getDisplayName()); } } class PaymentProcessor { private final Logger logger; public PaymentProcessor(Logger logger) { this.logger = logger; } public void process(double amount, String currency) { logger.info("Processing payment: " + amount + " " + currency); // Payment processing logic } public void refund(double amount, String currency) { logger.info("Processing refund: " + amount + " " + currency); // Refund processing logic } } 3. Nested Test Classes with @BeforeEach
import org.junit.jupiter.api.*; import java.util.*; class NestedBeforeEachTest { private ShoppingCart cart; @BeforeEach void setUp() { cart = new ShoppingCart(); System.out.println("Outer @BeforeEach - Base cart created"); } @Test void emptyCart() { assertTrue(cart.isEmpty()); assertEquals(0, cart.getTotal()); } @Nested @DisplayName("When cart has items") class CartWithItems { @BeforeEach void addItems() { // This runs after the outer @BeforeEach cart.addItem("Laptop", 999.99); cart.addItem("Mouse", 29.99); System.out.println("Inner @BeforeEach - Items added to cart"); } @Test void cartTotal() { assertEquals(1029.98, cart.getTotal(), 0.01); } @Test void cartItemCount() { assertEquals(2, cart.getItemCount()); } @Nested @DisplayName("When applying discount") class WithDiscount { @BeforeEach void applyDiscount() { // This runs after both outer @BeforeEach methods cart.applyDiscount(0.1); // 10% discount System.out.println("Inner inner @BeforeEach - Discount applied"); } @Test void discountedTotal() { assertEquals(926.98, cart.getTotal(), 0.01); } } } @Nested @DisplayName("When cart has digital items") class DigitalItems { @BeforeEach void addDigitalItems() { cart.addItem("E-book", 9.99); cart.addItem("Software", 49.99); } @Test void digitalItemsTotal() { assertEquals(59.98, cart.getTotal(), 0.01); } } } class ShoppingCart { private List<CartItem> items = new ArrayList<>(); private double discount = 0.0; public void addItem(String name, double price) { items.add(new CartItem(name, price)); } public boolean isEmpty() { return items.isEmpty(); } public int getItemCount() { return items.size(); } public double getTotal() { double total = items.stream().mapToDouble(CartItem::getPrice).sum(); return total * (1 - discount); } public void applyDiscount(double discount) { this.discount = discount; } } class CartItem { private String name; private double price; public CartItem(String name, double price) { this.name = name; this.price = price; } public String getName() { return name; } public double getPrice() { return price; } } Common Patterns and Best Practices
1. Reset Pattern for Mutable State
class ResetPatternTest { private mutableState; private originalState; @BeforeEach void setUp() { // Capture original state originalState = System.getProperty("test.property"); // Set up test state mutableState = new ComplexObject(); mutableState.initialize(); System.setProperty("test.property", "test-value"); } @AfterEach void tearDown() { // Reset to original state if (originalState == null) { System.clearProperty("test.property"); } else { System.setProperty("test.property", originalState); } // Clean up resources mutableState.cleanup(); } @Test void testWithModifiedState() { // Test that depends on modified state assertEquals("test-value", System.getProperty("test.property")); } } 2. Template Method Pattern for @BeforeEach
abstract class BaseTestTemplate { protected TestDatabase database; protected HttpClient httpClient; @BeforeEach void baseSetUp() { // Common setup for all tests database = TestDatabase.createInMemoryDatabase(); httpClient = HttpClient.newBuilder().build(); // Call template method for specific setup specificSetUp(); } protected abstract void specificSetUp(); @AfterEach void baseTearDown() { database.close(); } } class UserServiceTest extends BaseTestTemplate { private UserService userService; @Override protected void specificSetUp() { // Specific setup for UserService tests userService = new UserService(database, httpClient); userService.initialize(); } @Test void createUser() { User user = userService.createUser("[email protected]"); assertNotNull(user.getId()); } } class ProductServiceTest extends BaseTestTemplate { private ProductService productService; @Override protected void specificSetUp() { // Specific setup for ProductService tests productService = new ProductService(database); productService.loadCatalog(); } @Test void findProduct() { Product product = productService.findById(1L); assertNotNull(product); } } Common Pitfalls and Solutions
1. Avoiding Expensive Operations in @BeforeEach
class EfficientBeforeEachTest { private static ExpensiveResource sharedResource; private TestData testData; @BeforeAll static void setUpExpensiveResources() { // One-time setup for expensive operations sharedResource = ExpensiveResource.create(); } @BeforeEach void setUpPerTest() { // Fast setup for each test testData = new TestData(); testData.initialize(); } @AfterAll static void tearDownExpensiveResources() { sharedResource.close(); } @Test void testWithSharedResource() { sharedResource.process(testData); // Test assertions... } } 2. Handling Exceptions in @BeforeEach
class ExceptionHandlingTest { private DatabaseConnection db; @BeforeEach void setUp() { try { db = DatabaseConnection.create(); db.connect(); } catch (DatabaseException e) { // Mark test as failed if setup fails throw new RuntimeException("Test setup failed", e); } } @Test void testDatabaseOperation() { // This test won't run if @BeforeEach fails assertTrue(db.isConnected()); } @AfterEach void tearDown() { if (db != null && db.isConnected()) { db.disconnect(); } } } Best Practices Summary
- Keep @BeforeEach Methods Focused: Each should handle a specific aspect of setup
- Use Descriptive Method Names:
setUpDatabase()vs justsetUp() - Avoid Test Logic in @BeforeEach: Setup should prepare state, not contain assertions
- Clean Up in @AfterEach: Always pair setup with proper cleanup
- Use @BeforeAll for Expensive Resources: Share resources between tests when safe
- Leverage Dependency Injection: Use TestInfo, TestReporter for context-aware setup
- Consider Test Isolation: Ensure tests don't depend on each other's state
Conclusion
The @BeforeEach annotation is a powerful tool in JUnit 5 for managing test lifecycle and ensuring proper test isolation. By understanding its execution order, combining it with other lifecycle methods, and following best practices, you can create maintainable, reliable test suites. Remember that good test setup is about finding the right balance between code reuse and test independence, ensuring each test has exactly what it needs to run successfully while remaining isolated from other tests.