Mastering Test Lifecycle with @BeforeEach in Java

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

  1. Keep @BeforeEach Methods Focused: Each should handle a specific aspect of setup
  2. Use Descriptive Method Names: setUpDatabase() vs just setUp()
  3. Avoid Test Logic in @BeforeEach: Setup should prepare state, not contain assertions
  4. Clean Up in @AfterEach: Always pair setup with proper cleanup
  5. Use @BeforeAll for Expensive Resources: Share resources between tests when safe
  6. Leverage Dependency Injection: Use TestInfo, TestReporter for context-aware setup
  7. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper