Spring Test Data
Introduction
Testing is a critical aspect of software development, and having proper test data is essential for writing effective tests. In Spring applications, setting up test data can be challenging, especially when dealing with complex domain models and database interactions. Spring provides several utilities and approaches to help developers create, manage, and utilize test data efficiently.
In this tutorial, we'll explore various techniques for working with test data in Spring applications, from simple in-memory data to more complex database fixtures. You'll learn how to set up test data for both unit tests and integration tests while keeping your tests clean, maintainable, and reliable.
Why Test Data Matters
Before diving into the implementations, it's important to understand why proper test data management is crucial:
- Reproducibility: Tests should produce the same results each time they run
- Isolation: Each test should run independently without affecting others
- Clarity: Test data should clearly express the scenario being tested
- Maintainability: Test data should be easy to update as your application evolves
Common Approaches to Test Data in Spring
1. In-Memory Test Data
For simple unit tests, you often don't need a real database. You can create in-memory objects to use as test data.
@Test
public void testUserService() {
// Create in-memory test data
User user = new User("john.doe", "John", "Doe", "[email protected]");
// Mock dependencies
UserRepository mockRepository = Mockito.mock(UserRepository.class);
Mockito.when(mockRepository.findByUsername("john.doe")).thenReturn(user);
// Create the service with the mock dependency
UserService userService = new UserServiceImpl(mockRepository);
// Test the service
User found = userService.findByUsername("john.doe");
assertEquals("John", found.getFirstName());
assertEquals("Doe", found.getLastName());
}
This approach is simple but becomes tedious when you need the same test data across multiple tests.
2. Test Fixture Methods
You can centralize your test data creation in fixture methods to reuse across tests:
public class UserTestFixtures {
public static User createTestUser() {
return new User("john.doe", "John", "Doe", "[email protected]");
}
public static List<User> createTestUsers() {
List<User> users = new ArrayList<>();
users.add(createTestUser());
users.add(new User("jane.smith", "Jane", "Smith", "[email protected]"));
return users;
}
}
// Usage in a test
@Test
public void testUserFiltering() {
List<User> testUsers = UserTestFixtures.createTestUsers();
// Use the test data...
}
3. TestNG/JUnit Data Providers
For parameterized testing, Spring works well with JUnit's @ParameterizedTest
for providing test data:
@ParameterizedTest
@MethodSource("provideUsersForValidation")
void testUserValidation(User user, boolean expectedValidity) {
UserValidator validator = new UserValidator();
assertEquals(expectedValidity, validator.isValid(user));
}
private static Stream<Arguments> provideUsersForValidation() {
return Stream.of(
Arguments.of(new User("john.doe", "John", "Doe", "[email protected]"), true),
Arguments.of(new User("jane", "Jane", "Smith", "not-an-email"), false),
Arguments.of(new User("", "Empty", "Username", "[email protected]"), false)
);
}
4. Using @TestConfiguration for Complex Test Data
For more complex scenarios, Spring's @TestConfiguration
can help set up beans specifically for testing:
@TestConfiguration
public class TestDataConfig {
@Bean
public List<Product> testProducts() {
List<Product> products = new ArrayList<>();
products.add(new Product("1", "Test Product 1", 19.99, 50));
products.add(new Product("2", "Test Product 2", 29.99, 25));
products.add(new Product("3", "Test Product 3", 9.99, 100));
return products;
}
@Bean
public ProductRepository testProductRepository() {
InMemoryProductRepository repository = new InMemoryProductRepository();
repository.saveAll(testProducts());
return repository;
}
}
@SpringBootTest
@Import(TestDataConfig.class)
public class ProductServiceIntegrationTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private ProductService productService;
@Test
public void testFindProductsByPriceRange() {
List<Product> products = productService.findByPriceRange(10.0, 30.0);
assertEquals(2, products.size());
}
}
Database Test Data for Integration Tests
For integration tests involving databases, Spring provides several approaches to manage test data effectively:
1. Using SQL Scripts with @Sql Annotation
Spring's @Sql
annotation allows you to run SQL scripts before and after tests:
@SpringBootTest
@Sql({"/schema.sql", "/test-data.sql"})
public class UserRepositoryIntegrationTest {
@Autowired
private UserRepository userRepository;
@Test
public void testFindByEmail() {
User user = userRepository.findByEmail("[email protected]");
assertNotNull(user);
assertEquals("John", user.getFirstName());
}
@Test
@Sql("/additional-users.sql") // Additional data for this specific test
public void testFindPremiumUsers() {
List<User> premiumUsers = userRepository.findByPremiumStatusTrue();
assertEquals(2, premiumUsers.size());
}
}
The test-data.sql
file might contain:
-- test-data.sql
INSERT INTO users (id, username, first_name, last_name, email)
VALUES
(1, 'john.doe', 'John', 'Doe', '[email protected]'),
(2, 'jane.smith', 'Jane', 'Smith', '[email protected]');
2. Using @DataJpaTest with Test Data Builders
For testing JPA repositories, @DataJpaTest
is useful and can be combined with builder patterns for test data:
@DataJpaTest
public class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager entityManager;
@Test
public void testFindRecentOrders() {
// Create and persist test data with a builder pattern
Customer customer = new CustomerBuilder()
.withName("John Doe")
.withEmail("[email protected]")
.build();
entityManager.persist(customer);
Order order1 = new OrderBuilder()
.withCustomer(customer)
.withDate(LocalDate.now().minusDays(1))
.withAmount(100.0)
.build();
Order order2 = new OrderBuilder()
.withCustomer(customer)
.withDate(LocalDate.now().minusDays(10))
.withAmount(200.0)
.build();
entityManager.persist(order1);
entityManager.persist(order2);
entityManager.flush();
// Test the repository method
List<Order> recentOrders = orderRepository.findOrdersFromLastWeek();
assertEquals(1, recentOrders.size());
assertEquals(order1.getId(), recentOrders.get(0).getId());
}
}
3. Using TestContainers for Database Tests
For more realistic database tests, TestContainers provides real database instances in Docker containers:
@SpringBootTest
@Testcontainers
public class UserServiceDatabaseTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void registerPgProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserService userService;
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
void setupDatabase() {
// Set up schema
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS users (" +
"id SERIAL PRIMARY KEY, " +
"username VARCHAR(100), " +
"email VARCHAR(100))");
// Insert test data
jdbcTemplate.update(
"INSERT INTO users (username, email) VALUES (?, ?)",
"testuser", "[email protected]");
}
@Test
public void testUserLookup() {
User user = userService.findByUsername("testuser");
assertNotNull(user);
assertEquals("[email protected]", user.getEmail());
}
}
Test Data Factories and Object Mothers
For more sophisticated test data needs, you can implement test data factories or the Object Mother pattern:
// Object Mother pattern
public class UserMother {
public static User regularUser() {
return new User("regular.user", "Regular", "User", "[email protected]")
.withRole(Role.USER)
.withActive(true)
.withCreatedDate(LocalDateTime.now().minusDays(30));
}
public static User adminUser() {
return new User("admin.user", "Admin", "User", "[email protected]")
.withRole(Role.ADMIN)
.withActive(true)
.withCreatedDate(LocalDateTime.now().minusDays(100));
}
public static User inactiveUser() {
return new User("inactive.user", "Inactive", "User", "[email protected]")
.withRole(Role.USER)
.withActive(false)
.withCreatedDate(LocalDateTime.now().minusDays(15));
}
}
// Usage in tests
@Test
public void testAdminPrivileges() {
User adminUser = UserMother.adminUser();
assertTrue(securityService.hasAdminAccess(adminUser));
User regularUser = UserMother.regularUser();
assertFalse(securityService.hasAdminAccess(regularUser));
}
Real-world Example: E-commerce Order Processing Test
Here's a comprehensive example that demonstrates several techniques for testing an e-commerce order processing system:
@SpringBootTest
public class OrderProcessingIntegrationTest {
@MockBean
private PaymentGateway paymentGateway;
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@Autowired
private ProductRepository productRepository;
@BeforeEach
void setupTestData() {
// Clear previous test data
orderRepository.deleteAll();
// Create test products
Product laptop = new Product("P1", "Laptop", 999.99, 10);
Product phone = new Product("P2", "Smartphone", 499.99, 20);
Product headphones = new Product("P3", "Headphones", 99.99, 50);
productRepository.saveAll(Arrays.asList(laptop, phone, headphones));
// Mock payment gateway responses
when(paymentGateway.processPayment(anyDouble(), any(PaymentDetails.class)))
.thenReturn(new PaymentResult(true, "Payment successful", "TX123456"));
}
@Test
public void testSuccessfulOrderProcessing() {
// Arrange
Customer customer = new Customer("C1", "John Doe", "[email protected]");
OrderItem item1 = new OrderItem("P1", 1, 999.99);
OrderItem item2 = new OrderItem("P3", 2, 99.99);
Order order = new Order()
.withCustomer(customer)
.withItems(Arrays.asList(item1, item2))
.withShippingAddress(new Address("123 Main St", "Anytown", "12345", "USA"));
PaymentDetails paymentDetails = new PaymentDetails("4111111111111111", "John Doe", "12/25", "123");
// Act
OrderResult result = orderService.processOrder(order, paymentDetails);
// Assert
assertTrue(result.isSuccessful());
assertNotNull(result.getOrderId());
// Verify order was saved correctly
Order savedOrder = orderRepository.findById(result.getOrderId()).orElse(null);
assertNotNull(savedOrder);
assertEquals(2, savedOrder.getItems().size());
assertEquals(1199.97, savedOrder.getTotalAmount(), 0.01);
// Verify product inventory was updated
Product laptop = productRepository.findById("P1").orElse(null);
Product headphones = productRepository.findById("P3").orElse(null);
assertEquals(9, laptop.getStockQuantity());
assertEquals(48, headphones.getStockQuantity());
// Verify payment was processed
verify(paymentGateway, times(1)).processPayment(eq(1199.97), eq(paymentDetails));
}
@Test
public void testOrderWithInsufficientInventory() {
// Arrange
Customer customer = new Customer("C2", "Jane Smith", "[email protected]");
OrderItem item = new OrderItem("P1", 20, 999.99); // Only 10 in stock
Order order = new Order()
.withCustomer(customer)
.withItems(Collections.singletonList(item))
.withShippingAddress(new Address("456 Oak St", "Othertown", "67890", "USA"));
PaymentDetails paymentDetails = new PaymentDetails("5555555555554444", "Jane Smith", "10/24", "456");
// Act
OrderResult result = orderService.processOrder(order, paymentDetails);
// Assert
assertFalse(result.isSuccessful());
assertEquals("Insufficient inventory for product: Laptop", result.getErrorMessage());
// Verify payment was not processed
verify(paymentGateway, never()).processPayment(anyDouble(), any(PaymentDetails.class));
// Verify inventory remained unchanged
Product laptop = productRepository.findById("P1").orElse(null);
assertEquals(10, laptop.getStockQuantity());
}
}
Best Practices for Spring Test Data
- Keep test data minimal: Include only what's needed for the specific test scenario
- Make test data intention-revealing: Name variables and methods to express their purpose
- Isolate tests: Ensure each test has its own data to avoid test interdependencies
- Use the right level of abstraction: Use appropriate tooling based on what you're testing (unit vs. integration)
- Clean up after tests: Use
@BeforeEach
and@AfterEach
to maintain a clean state - Prefer in-memory data for unit tests: It's faster and doesn't require external resources
- Consider using builder patterns: They make test data creation more readable and maintainable
- Organize test data factory methods by concept: Group related data creation methods together
Summary
In this tutorial, we explored various approaches to managing test data in Spring applications:
- In-memory test data for simple tests
- Test fixture methods for reusable data
- TestNG/JUnit parameterized tests
- Using
@TestConfiguration
for test-specific beans - SQL scripts with
@Sql
for database initialization @DataJpaTest
with test data builders for JPA testing- TestContainers for realistic database tests
- Object Mother pattern for sophisticated test data needs
Effective test data management is crucial for creating maintainable, reliable tests. By choosing the right approach for your specific testing needs, you can ensure your Spring tests remain valuable as your application grows in complexity.
Additional Resources
- Spring Testing Documentation
- TestContainers for Java
- JUnit 5 User Guide
- Object Mother Pattern
- Test Data Builders Pattern
Exercises
- Create a test data factory for a blog application with
Post
andComment
entities - Write integration tests for a user registration service using SQL scripts to set up test data
- Implement parameterized tests for a price calculation service with different discount scenarios
- Use TestContainers to write a test for a service that interacts with MongoDB
- Create an Object Mother class for an HR system with different types of employees and departments
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)