Skip to main content

Integration Testing Guide

Overview

This comprehensive guide covers integration testing in the Ink platform using Spring Boot Test, TestContainers, and embedded services. Learn how to write effective integration tests that verify component interactions, database operations, and API endpoints.

Target Audience: All developers
Prerequisites: Understanding of Unit Testing Guide
Estimated Time: 50-60 minutes

Prerequisites

  • Completed Unit Testing Guide
  • Spring Boot Test knowledge
  • Docker installed and running
  • Understanding of database operations
  • REST API testing concepts

Integration Testing Architecture

Installation Steps

1. Integration Testing Dependencies

<!-- filepath: /Users/jetstart/dev/jetrev/ink/pom.xml -->
<!-- ...existing code... -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<!-- TestContainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>kafka</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>

<!-- REST Assured for API Testing -->
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>

<!-- Test Database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- ...existing code... -->

2. Base Integration Test Class

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/base/BaseIntegrationTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
public abstract class BaseIntegrationTest {

@LocalServerPort
protected int port;

@Autowired
protected TestRestTemplate restTemplate;

@Autowired
protected ObjectMapper objectMapper;

protected String baseUrl() {
return "http://localhost:" + port;
}

protected <T> ResponseEntity<T> get(String path, Class<T> responseType) {
return restTemplate.getForEntity(baseUrl() + path, responseType);
}

protected <T> ResponseEntity<T> post(String path, Object request, Class<T> responseType) {
return restTemplate.postForEntity(baseUrl() + path, request, responseType);
}

protected <T> ResponseEntity<T> put(String path, Object request, Class<T> responseType) {
return restTemplate.exchange(
baseUrl() + path,
HttpMethod.PUT,
new HttpEntity<>(request),
responseType
);
}

protected ResponseEntity<Void> delete(String path) {
return restTemplate.exchange(
baseUrl() + path,
HttpMethod.DELETE,
null,
Void.class
);
}
}

3. TestContainers Configuration

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/config/TestContainersConfiguration.java
@TestConfiguration
public class TestContainersConfiguration {

@Container
static PostgreSQLContainer<?> postgresContainer = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("ink_test")
.withUsername("test_user")
.withPassword("test_password")
.withReuse(true);

@Container
static KafkaContainer kafkaContainer = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0"))
.withReuse(true);

@Container
static GenericContainer<?> redisContainer = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379)
.withReuse(true);

static {
postgresContainer.start();
kafkaContainer.start();
redisContainer.start();
}

@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// Database properties
registry.add("spring.datasource.url", postgresContainer::getJdbcUrl);
registry.add("spring.datasource.username", postgresContainer::getUsername);
registry.add("spring.datasource.password", postgresContainer::getPassword);

// Kafka properties
registry.add("spring.kafka.bootstrap-servers", kafkaContainer::getBootstrapServers);

// Redis properties
registry.add("spring.data.redis.host", redisContainer::getHost);
registry.add("spring.data.redis.port", () ->
redisContainer.getMappedPort(6379).toString());
}
}

Configuration

Repository Integration Tests

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/repository/UserRepositoryIntegrationTest.java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserRepositoryIntegrationTest {

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("test")
.withUsername("test")
.withPassword("test");

@DynamicPropertySource
static void configureProperties(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 UserRepository userRepository;

@Autowired
private TestEntityManager entityManager;

private User testUser;

@BeforeEach
void setUp() {
testUser = User.builder()
.username("testuser")
.email("test@example.com")
.password("encodedPassword")
.roles(Set.of("USER"))
.active(true)
.createdAt(Instant.now())
.build();
}

@Test
@DisplayName("Should save and retrieve user from database")
void shouldSaveAndRetrieveUser() {
// When
User saved = userRepository.save(testUser);
entityManager.flush();
entityManager.clear();

// Then
Optional<User> found = userRepository.findById(saved.getId());

assertThat(found).isPresent();
assertThat(found.get().getUsername()).isEqualTo("testuser");
assertThat(found.get().getEmail()).isEqualTo("test@example.com");
assertThat(found.get().isActive()).isTrue();
}

@Test
@DisplayName("Should find user by username")
void shouldFindUserByUsername() {
// Given
userRepository.save(testUser);
entityManager.flush();

// When
Optional<User> found = userRepository.findByUsername("testuser");

// Then
assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("test@example.com");
}

@Test
@DisplayName("Should find users by role")
void shouldFindUsersByRole() {
// Given
User admin = User.builder()
.username("admin")
.email("admin@example.com")
.password("password")
.roles(Set.of("ADMIN"))
.build();

userRepository.save(testUser);
userRepository.save(admin);
entityManager.flush();

// When
List<User> users = userRepository.findByRolesContaining("USER");
List<User> admins = userRepository.findByRolesContaining("ADMIN");

// Then
assertThat(users).hasSize(1);
assertThat(users.get(0).getUsername()).isEqualTo("testuser");
assertThat(admins).hasSize(1);
assertThat(admins.get(0).getUsername()).isEqualTo("admin");
}

@Test
@DisplayName("Should update user and maintain referential integrity")
void shouldUpdateUser() {
// Given
User saved = userRepository.save(testUser);
entityManager.flush();
entityManager.clear();

// When
saved.setEmail("newemail@example.com");
saved.setUpdatedAt(Instant.now());
userRepository.save(saved);
entityManager.flush();
entityManager.clear();

// Then
User updated = userRepository.findById(saved.getId()).orElseThrow();
assertThat(updated.getEmail()).isEqualTo("newemail@example.com");
assertThat(updated.getUpdatedAt()).isNotNull();
}

@Test
@DisplayName("Should handle concurrent updates with optimistic locking")
void shouldHandleOptimisticLocking() {
// Given
User saved = userRepository.save(testUser);
entityManager.flush();
entityManager.clear();

// When - simulate concurrent modification
User user1 = userRepository.findById(saved.getId()).orElseThrow();
User user2 = userRepository.findById(saved.getId()).orElseThrow();

user1.setEmail("user1@example.com");
userRepository.save(user1);
entityManager.flush();

user2.setEmail("user2@example.com");

// Then
assertThatThrownBy(() -> {
userRepository.save(user2);
entityManager.flush();
}).isInstanceOf(OptimisticLockingFailureException.class);
}
}

Service Layer Integration Tests

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/service/UserServiceIntegrationTest.java
@SpringBootTest
@ActiveProfiles("test")
@Testcontainers
class UserServiceIntegrationTest {

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");

@DynamicPropertySource
static void configureProperties(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 UserRepository userRepository;

@BeforeEach
void setUp() {
userRepository.deleteAll();
}

@Test
@DisplayName("Should create user with all dependencies")
void shouldCreateUserWithAllDependencies() {
// Given
CreateUserRequest request = CreateUserRequest.builder()
.username("newuser")
.email("new@example.com")
.password("Password123!")
.firstName("New")
.lastName("User")
.roles(List.of("USER"))
.build();

// When
UserDto created = userService.createUser(request);

// Then
assertThat(created).isNotNull();
assertThat(created.getId()).isNotNull();
assertThat(created.getUsername()).isEqualTo("newuser");
assertThat(created.getEmail()).isEqualTo("new@example.com");

// Verify database persistence
User persisted = userRepository.findById(created.getId()).orElseThrow();
assertThat(persisted.getUsername()).isEqualTo("newuser");
assertThat(persisted.getPassword()).isNotEqualTo("Password123!"); // Should be encoded
}

@Test
@DisplayName("Should throw exception when creating duplicate username")
void shouldThrowExceptionForDuplicateUsername() {
// Given
CreateUserRequest request = CreateUserRequest.builder()
.username("duplicate")
.email("user1@example.com")
.password("Password123!")
.firstName("User")
.lastName("One")
.roles(List.of("USER"))
.build();

userService.createUser(request);

// When & Then
CreateUserRequest duplicate = CreateUserRequest.builder()
.username("duplicate")
.email("user2@example.com")
.password("Password123!")
.firstName("User")
.lastName("Two")
.roles(List.of("USER"))
.build();

assertThatThrownBy(() -> userService.createUser(duplicate))
.isInstanceOf(UserAlreadyExistsException.class)
.hasMessageContaining("Username already exists");
}

@Test
@DisplayName("Should update user and persist changes")
void shouldUpdateUserAndPersistChanges() {
// Given
CreateUserRequest createRequest = CreateUserRequest.builder()
.username("updatetest")
.email("original@example.com")
.password("Password123!")
.firstName("Original")
.lastName("Name")
.roles(List.of("USER"))
.build();

UserDto created = userService.createUser(createRequest);

UpdateUserRequest updateRequest = UpdateUserRequest.builder()
.email("updated@example.com")
.firstName("Updated")
.lastName("Name")
.roles(List.of("USER", "ADMIN"))
.build();

// When
UserDto updated = userService.updateUser(created.getId(), updateRequest);

// Then
assertThat(updated.getEmail()).isEqualTo("updated@example.com");
assertThat(updated.getFirstName()).isEqualTo("Updated");
assertThat(updated.getRoles()).contains("ADMIN");

// Verify persistence
User persisted = userRepository.findById(created.getId()).orElseThrow();
assertThat(persisted.getEmail()).isEqualTo("updated@example.com");
}

@Test
@DisplayName("Should handle transaction rollback on error")
@Transactional
void shouldRollbackTransactionOnError() {
// Given
CreateUserRequest request = CreateUserRequest.builder()
.username("rollbacktest")
.email("rollback@example.com")
.password("Password123!")
.firstName("Rollback")
.lastName("Test")
.roles(List.of("USER"))
.build();

// When - simulate error during transaction
// This test verifies that failed operations don't persist

long countBefore = userRepository.count();

try {
userService.createUser(request);
// Simulate error
throw new RuntimeException("Simulated error");
} catch (RuntimeException e) {
// Transaction should rollback
}

// Then
long countAfter = userRepository.count();
assertThat(countAfter).isEqualTo(countBefore);
}
}

REST API Integration Tests

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/controller/UserControllerIntegrationTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class UserControllerIntegrationTest {

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");

@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}

@LocalServerPort
private int port;

@Autowired
private TestRestTemplate restTemplate;

@Autowired
private UserRepository userRepository;

@Autowired
private ObjectMapper objectMapper;

private String baseUrl;

@BeforeEach
void setUp() {
baseUrl = "http://localhost:" + port + "/api/v1/users";
userRepository.deleteAll();
}

@Test
@DisplayName("Should create user via API and return 201 Created")
void shouldCreateUserViaApi() throws Exception {
// Given
CreateUserRequest request = CreateUserRequest.builder()
.username("apiuser")
.email("api@example.com")
.password("Password123!")
.firstName("API")
.lastName("User")
.roles(List.of("USER"))
.build();

// When
ResponseEntity<String> response = restTemplate
.withBasicAuth("admin", "admin") // Mock authentication
.postForEntity(baseUrl, request, String.class);

// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);

JsonNode responseBody = objectMapper.readTree(response.getBody());
assertThat(responseBody.get("success").asBoolean()).isTrue();
assertThat(responseBody.get("data").get("username").asText()).isEqualTo("apiuser");

// Verify database persistence
Optional<User> persisted = userRepository.findByUsername("apiuser");
assertThat(persisted).isPresent();
}

@Test
@DisplayName("Should get user by ID and return 200 OK")
void shouldGetUserById() {
// Given
User user = User.builder()
.username("gettest")
.email("get@example.com")
.password("password")
.roles(Set.of("USER"))
.build();
User saved = userRepository.save(user);

// When
ResponseEntity<String> response = restTemplate
.withBasicAuth("admin", "admin")
.getForEntity(baseUrl + "/" + saved.getId(), String.class);

// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}

@Test
@DisplayName("Should return 404 when user not found")
void shouldReturn404WhenUserNotFound() {
// When
ResponseEntity<String> response = restTemplate
.withBasicAuth("admin", "admin")
.getForEntity(baseUrl + "/999999", String.class);

// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}

@Test
@DisplayName("Should return 400 for invalid request")
void shouldReturn400ForInvalidRequest() {
// Given - invalid request (missing required fields)
Map<String, Object> invalidRequest = Map.of(
"username", "ab", // Too short
"email", "invalid-email"
);

// When
ResponseEntity<String> response = restTemplate
.withBasicAuth("admin", "admin")
.postForEntity(baseUrl, invalidRequest, String.class);

// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}

@Test
@DisplayName("Should update user and return updated data")
void shouldUpdateUser() throws Exception {
// Given
User user = User.builder()
.username("updateapi")
.email("original@example.com")
.password("password")
.roles(Set.of("USER"))
.build();
User saved = userRepository.save(user);

UpdateUserRequest updateRequest = UpdateUserRequest.builder()
.email("updated@example.com")
.firstName("Updated")
.lastName("Name")
.roles(List.of("USER"))
.build();

// When
HttpEntity<UpdateUserRequest> request = new HttpEntity<>(updateRequest);
ResponseEntity<String> response = restTemplate
.withBasicAuth("admin", "admin")
.exchange(
baseUrl + "/" + saved.getId(),
HttpMethod.PUT,
request,
String.class
);

// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

JsonNode responseBody = objectMapper.readTree(response.getBody());
assertThat(responseBody.get("data").get("email").asText())
.isEqualTo("updated@example.com");
}

@Test
@DisplayName("Should delete user and return 204 No Content")
void shouldDeleteUser() {
// Given
User user = User.builder()
.username("deletetest")
.email("delete@example.com")
.password("password")
.roles(Set.of("USER"))
.build();
User saved = userRepository.save(user);

// When
ResponseEntity<Void> response = restTemplate
.withBasicAuth("admin", "admin")
.exchange(
baseUrl + "/" + saved.getId(),
HttpMethod.DELETE,
null,
Void.class
);

// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);

// Verify deletion (soft delete)
Optional<User> deleted = userRepository.findById(saved.getId());
assertThat(deleted).isPresent();
assertThat(deleted.get().isActive()).isFalse();
}

@Test
@DisplayName("Should get paginated users")
void shouldGetPaginatedUsers() throws Exception {
// Given - create multiple users
for (int i = 0; i < 25; i++) {
User user = User.builder()
.username("user" + i)
.email("user" + i + "@example.com")
.password("password")
.roles(Set.of("USER"))
.build();
userRepository.save(user);
}

// When
ResponseEntity<String> response = restTemplate
.withBasicAuth("admin", "admin")
.getForEntity(baseUrl + "?page=0&size=10", String.class);

// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

JsonNode responseBody = objectMapper.readTree(response.getBody());
assertThat(responseBody.get("content").size()).isEqualTo(10);
assertThat(responseBody.get("totalElements").asInt()).isEqualTo(25);
assertThat(responseBody.get("totalPages").asInt()).isEqualTo(3);
}
}

Usage Examples

Testing with MockMvc

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/controller/UserControllerMockMvcTest.java
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class UserControllerMockMvcTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private ObjectMapper objectMapper;

@Autowired
private UserRepository userRepository;

@BeforeEach
void setUp() {
userRepository.deleteAll();
}

@Test
@WithMockUser(roles = "ADMIN")
@DisplayName("Should create user with MockMvc")
void shouldCreateUserWithMockMvc() throws Exception {
// Given
CreateUserRequest request = CreateUserRequest.builder()
.username("mockmvcuser")
.email("mockmvc@example.com")
.password("Password123!")
.firstName("Mock")
.lastName("MVC")
.roles(List.of("USER"))
.build();

// When & Then
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.username").value("mockmvcuser"))
.andExpect(jsonPath("$.data.email").value("mockmvc@example.com"))
.andDo(print());
}

@Test
@WithMockUser(roles = "ADMIN")
@DisplayName("Should validate request body")
void shouldValidateRequestBody() throws Exception {
// Given - invalid request
Map<String, Object> invalidRequest = Map.of(
"username", "a", // Too short
"email", "not-an-email",
"password", "weak"
);

// When & Then
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").exists())
.andDo(print());
}
}

Kafka Integration Tests

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/messaging/UserEventProducerIntegrationTest.java
@SpringBootTest
@ActiveProfiles("test")
@Testcontainers
@EmbeddedKafka(
partitions = 1,
topics = {"user-events"}
)
class UserEventProducerIntegrationTest {

@Autowired
private UserEventProducer eventProducer;

@Autowired
private KafkaTemplate<String, UserEvent> kafkaTemplate;

@Test
@DisplayName("Should publish user created event to Kafka")
void shouldPublishUserCreatedEvent() throws Exception {
// Given
User user = User.builder()
.id(1L)
.username("kafkauser")
.email("kafka@example.com")
.build();

CountDownLatch latch = new CountDownLatch(1);
AtomicReference<UserEvent> receivedEvent = new AtomicReference<>();

// Setup consumer
Map<String, Object> consumerProps = KafkaTestUtils.consumerProps(
"test-group", "true", embeddedKafka);

DefaultKafkaConsumerFactory<String, UserEvent> cf =
new DefaultKafkaConsumerFactory<>(consumerProps);

Consumer<String, UserEvent> consumer = cf.createConsumer();
consumer.subscribe(Collections.singleton("user-events"));

// When
eventProducer.publishUserCreatedEvent(user);

// Poll for message
ConsumerRecords<String, UserEvent> records =
consumer.poll(Duration.ofSeconds(10));

// Then
assertThat(records.count()).isGreaterThan(0);

for (ConsumerRecord<String, UserEvent> record : records) {
assertThat(record.value().getUserId()).isEqualTo(1L);
assertThat(record.value().getEventType()).isEqualTo("USER_CREATED");
}

consumer.close();
}
}

Verification

Test Execution

# Run all integration tests
mvn verify

# Run specific integration test
mvn test -Dtest=UserServiceIntegrationTest

# Run tests with specific profile
mvn test -Dspring.profiles.active=test

# Run with TestContainers reuse
export TESTCONTAINERS_REUSE_ENABLE=true
mvn verify

Test Coverage Report

# Generate coverage report including integration tests
mvn clean verify jacoco:report

# View report
open target/site/jacoco/index.html

Troubleshooting

TestContainers Not Starting

# Check Docker is running
docker ps

# Enable TestContainers logs
export TESTCONTAINERS_RYUK_DISABLED=false

# Check port conflicts
lsof -i :5432
lsof -i :9092

Database State Issues

// Use @Sql to reset database state
@Sql(scripts = "/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
@Test
void testMethod() {
// Test code
}

// Or use @DirtiesContext to reload context
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class MyIntegrationTest {
// Tests
}

Slow Test Execution

// Reuse containers across tests
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withReuse(true); // Enable reuse

// Use parallel execution
// In pom.xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<parallel>classes</parallel>
<threadCount>4</threadCount>
</configuration>
</plugin>

Code Examples

Custom Test Data Builder

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/builder/UserTestDataBuilder.java
@Component
public class UserTestDataBuilder {

@Autowired
private UserRepository userRepository;

@Autowired
private PasswordEncoder passwordEncoder;

public User createAndSaveUser(String username) {
User user = User.builder()
.username(username)
.email(username + "@example.com")
.password(passwordEncoder.encode("Password123!"))
.firstName("Test")
.lastName("User")
.roles(Set.of("USER"))
.active(true)
.createdAt(Instant.now())
.build();

return userRepository.save(user);
}

public User createAdmin(String username) {
User admin = User.builder()
.username(username)
.email(username + "@example.com")
.password(passwordEncoder.encode("Admin123!"))
.firstName("Admin")
.lastName("User")
.roles(Set.of("ADMIN", "USER"))
.active(true)
.createdAt(Instant.now())
.build();

return userRepository.save(admin);
}
}

End-to-End Test Scenario

@Test
@DisplayName("End-to-end: Create user, update, and delete")
void endToEndUserLifecycle() throws Exception {
// 1. Create user
CreateUserRequest createRequest = CreateUserRequest.builder()
.username("e2euser")
.email("e2e@example.com")
.password("Password123!")
.firstName("E2E")
.lastName("Test")
.roles(List.of("USER"))
.build();

ResponseEntity<String> createResponse = restTemplate
.withBasicAuth("admin", "admin")
.postForEntity(baseUrl, createRequest, String.class);

assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);

JsonNode createBody = objectMapper.readTree(createResponse.getBody());
Long userId = createBody.get("data").get("id").asLong();

// 2. Get user
ResponseEntity<String> getResponse = restTemplate
.withBasicAuth("admin", "admin")
.getForEntity(baseUrl + "/" + userId, String.class);

assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);

// 3. Update user
UpdateUserRequest updateRequest = UpdateUserRequest.builder()
.email("updated@example.com")
.firstName("Updated")
.lastName("Name")
.roles(List.of("USER"))
.build();

HttpEntity<UpdateUserRequest> updateEntity = new HttpEntity<>(updateRequest);
ResponseEntity<String> updateResponse = restTemplate
.withBasicAuth("admin", "admin")
.exchange(baseUrl + "/" + userId, HttpMethod.PUT, updateEntity, String.class);

assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK);

JsonNode responseBody = objectMapper.readTree(updateResponse.getBody());
assertThat(responseBody.get("data").get("email").asText())
.isEqualTo("updated@example.com");

// 4. Delete user
ResponseEntity<Void> deleteResponse = restTemplate
.withBasicAuth("admin", "admin")
.exchange(baseUrl + "/" + userId, HttpMethod.DELETE, null, Void.class);

assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);

// 5. Verify deletion
Optional<User> deleted = userRepository.findById(userId);
assertThat(deleted).isPresent();
assertThat(deleted.get().isActive()).isFalse();
}

Best Practices

  1. Isolate Tests: Each test should be independent
  2. Use TestContainers: Test against real databases
  3. Clean State: Reset database between tests
  4. Test Real Scenarios: Cover entire workflows
  5. Mock External Services: Use WireMock for HTTP APIs
  6. Reuse Containers: Enable container reuse for speed
  7. Parallel Execution: Run tests in parallel when possible
  8. Meaningful Names: Use descriptive test names
  9. Verify Side Effects: Check database state, events sent
  10. Keep Tests Fast: Optimize slow tests

Performance Optimization

Container Reuse

# Enable container reuse
export TESTCONTAINERS_REUSE_ENABLE=true

# .testcontainers.properties
testcontainers.reuse.enable=true

Parallel Test Execution

<!-- filepath: /Users/jetstart/dev/jetrev/ink/pom.xml -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<parallel>classes</parallel>
<threadCount>4</threadCount>
<reuseForks>true</reuseForks>
<forkCount>2</forkCount>
</configuration>
</plugin>

Additional Resources


Next Steps: Learn about Test Database Setup for advanced database testing strategies.