🧭 Series Navigation:
⬅️ Introduce AdventureTube Microservice Hub
⬅️ Part 1: Architecture Overview & Design Patterns
⬅️ Part 2: Development Environment & Debugging
✅ Part 3: Testing Strategies (Current)
▶️ Part 4: CI/CD Deployment with Jenkins
Comprehensive Microservice Testing: Unit, Integration & API Testing Strategies
Testing microservices isn’t just unit tests – here’s the complete strategy
After setting up our efficient development environment in Part 2, we need to ensure our AdventureTube microservices are bulletproof. Testing distributed systems presents unique challenges that go far beyond traditional unit testing. Today I’ll show you my comprehensive testing approach that catches bugs before they reach production.
The Microservice Testing Challenge
Traditional testing approaches fall short with microservices because:
- Service Dependencies: Your service depends on config servers, databases, and other services
- Network Communication: HTTP calls, service discovery, and load balancing add complexity
- Configuration Management: Different environments and profiles affect behavior
- Data Consistency: Distributed transactions and eventual consistency
My solution? A multi-layered testing pyramid specifically designed for microservice architectures.
AdventureTube Testing Strategy Overview
I organize my tests into five distinct levels, each serving a specific purpose:
Testing Pyramid Structure
- Unit Tests (70%): Fast, isolated component testing
- Integration Tests (20%): Database and external service integration
- API Tests (7%): HTTP endpoint and contract validation
- Contract Tests (2%): Service interaction agreements
- End-to-End Tests (1%): Complete workflow validation
Test Organization with Maven
I use Maven Failsafe plugin to separate different test types:
# Unit tests (fast, no external dependencies)
mvn test
# Integration tests (requires infrastructure)
mvn verify -Pintegration
# All tests including end-to-end
mvn verify -Pfull-test
Unit Testing Deep Dive
Unit tests form the foundation of my testing strategy. Here’s how I structure them for the auth-service:
Core Unit Test Structure
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@TestPropertySource(properties = {
"spring.cloud.config.enabled=false",
"eureka.client.enabled=false"
})
class AuthServiceTest {
@MockBean
private UserRepository userRepository;
@MockBean
private ConfigServicePropertySourceLocator configLocator;
@Autowired
private JwtUtil jwtUtil;
@Test
void shouldGenerateValidJwtToken() {
// Given
UserDetails userDetails = createTestUser();
// When
String token = jwtUtil.generateToken(userDetails);
// Then
assertThat(token).isNotNull();
assertThat(jwtUtil.extractUsername(token)).isEqualTo("testuser");
assertThat(jwtUtil.isTokenValid(token, userDetails)).isTrue();
}
}
Key Unit Testing Patterns
Mocking External Dependencies:
@MockBean
for Spring components@Mock
for regular dependencies@TestConfiguration
for test-specific setup
Testing Focus Areas:
- JWT token generation and validation
- Password encoding and verification
- Custom authentication provider logic
- MapStruct mapper transformations
- Exception handling scenarios
Example: Testing JWT Authentication
@Test
void shouldValidateJwtTokenCorrectly() {
// Given
UserDetails userDetails = User.builder()
.username("testuser")
.password("encoded-password")
.authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
.build();
String token = jwtUtil.generateToken(userDetails);
// When
boolean isValid = jwtUtil.isTokenValid(token, userDetails);
// Then
assertThat(isValid).isTrue();
assertThat(jwtUtil.extractUsername(token)).isEqualTo("testuser");
assertThat(jwtUtil.extractExpiration(token)).isAfter(new Date());
}
@Test
void shouldRejectExpiredToken() {
// Given
String expiredToken = createExpiredToken();
UserDetails userDetails = createTestUser();
// When & Then
assertThat(jwtUtil.isTokenValid(expiredToken, userDetails)).isFalse();
}
Integration Testing with Real Infrastructure
Integration tests validate that services work correctly with real infrastructure. Here’s my approach:
Integration Test Configuration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("integration")
@TestMethodOrder(OrderAnnotation.class)
class AuthControllerIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
@LocalServerPort
private int port;
@Test
@Order(1)
void shouldRegisterNewUser() {
// Given
RegisterRequest request = RegisterRequest.builder()
.username("integration-user")
.email("test@adventuretube.net")
.password("securePassword123")
.build();
// When
ResponseEntity<AuthResponse> response = restTemplate.postForEntity(
"http://localhost:" + port + "/api/auth/register",
request,
AuthResponse.class
);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().getToken()).isNotNull();
// Verify database persistence
Optional<User> savedUser = userRepository.findByUsername("integration-user");
assertThat(savedUser).isPresent();
}
}
Testing Against Pi2 Infrastructure
My integration tests connect to the actual infrastructure running on Raspberry Pi 2:
- Config Server Integration: Pull real configuration from Pi2
- Database Testing: Use actual PostgreSQL database
- Eureka Registration: Verify service discovery works
- Inter-service Communication: Test service-to-service calls
Integration Test Environment Setup
# application-integration.yml
spring:
config:
import: "configserver:http://192.168.1.105:9297"
cloud:
config:
profile: integration
eureka:
client:
service-url:
defaultZone: http://192.168.1.105:8761/eureka
datasource:
url: jdbc:postgresql://adventuretube.net:5432/adventuretube_test
API Testing with Swagger & Postman
API testing ensures your endpoints work correctly and return proper responses:
Swagger UI Testing
I use Swagger UI for interactive API testing during development:
- Navigate to
http://localhost:8010/swagger-ui.html
- Test authentication endpoints with real requests
- Validate request/response schemas
- Test error scenarios and edge cases
Automated API Test Suite
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AuthApiTest {
@Autowired
private WebTestClient webTestClient;
@Test
void shouldAuthenticateUser() {
LoginRequest loginRequest = LoginRequest.builder()
.username("testuser")
.password("password123")
.build();
webTestClient.post()
.uri("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(loginRequest)
.exchange()
.expectStatus().isOk()
.expectBody(AuthResponse.class)
.value(response -> {
assertThat(response.getToken()).isNotNull();
assertThat(response.getUsername()).isEqualTo("testuser");
});
}
@Test
void shouldReturn401ForInvalidCredentials() {
LoginRequest invalidRequest = LoginRequest.builder()
.username("testuser")
.password("wrongpassword")
.build();
webTestClient.post()
.uri("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(invalidRequest)
.exchange()
.expectStatus().isUnauthorized()
.expectBody(ErrorResponse.class)
.value(error -> {
assertThat(error.getMessage()).contains("Invalid credentials");
});
}
}
Postman Collection Testing
I maintain Postman collections for comprehensive API testing:
- Authentication Flow: Register → Login → Access Protected Endpoints
- Error Scenarios: Invalid tokens, expired sessions, malformed requests
- Data Validation: Schema validation and business rule verification
- Performance Testing: Response time benchmarks
Exception Handling Testing
Robust exception handling is crucial for microservices. Here’s how I test the GlobalExceptionHandler:
Exception Test Scenarios
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ExceptionHandlingTest {
@Test
void shouldReturn404WhenUserNotFound() {
// Given
String nonExistentUserId = "999";
// When
assertThatThrownBy(() -> userService.findById(nonExistentUserId))
.isInstanceOf(UserNotFoundException.class)
.hasMessage("User not found with id: 999");
}
@Test
void shouldReturn409ForDuplicateUser() {
// Given
RegisterRequest duplicateRequest = createExistingUserRequest();
// When & Then
assertThatThrownBy(() -> authService.register(duplicateRequest))
.isInstanceOf(DuplicateException.class)
.hasMessage("User already exists with username: testuser");
}
@Test
void shouldReturn401ForExpiredToken() {
// Given
String expiredToken = createExpiredJwtToken();
// When & Then
assertThatThrownBy(() -> jwtUtil.extractUsername(expiredToken))
.isInstanceOf(TokenExpiredException.class);
}
}
HTTP Status Code Validation
@Test
void shouldMapExceptionsToCorrectHttpStatus() {
Map<Class<? extends Exception>, HttpStatus> expectedMappings = Map.of(
UserNotFoundException.class, HttpStatus.NOT_FOUND,
DuplicateException.class, HttpStatus.CONFLICT,
TokenExpiredException.class, HttpStatus.UNAUTHORIZED,
ValidationException.class, HttpStatus.BAD_REQUEST
);
expectedMappings.forEach((exceptionClass, expectedStatus) -> {
// Test that GlobalExceptionHandler returns correct status
// for each exception type
});
}
Database Integration Testing
Database testing ensures data persistence and query performance:
Repository Testing
@DataJpaTest
@TestPropertySource(properties = {
"spring.jpa.hibernate.ddl-auto=create-drop",
"spring.datasource.url=jdbc:h2:mem:testdb"
})
class UserRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private UserRepository userRepository;
@Test
void shouldFindUserByUsername() {
// Given
User testUser = createTestUser();
entityManager.persistAndFlush(testUser);
// When
Optional<User> found = userRepository.findByUsername("testuser");
// Then
assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("test@adventuretube.net");
}
@Test
void shouldEnforceUniqueConstraints() {
// Given
User user1 = createTestUser();
User user2 = createTestUserWithSameUsername();
entityManager.persistAndFlush(user1);
// When & Then
assertThatThrownBy(() -> {
entityManager.persistAndFlush(user2);
}).isInstanceOf(DataIntegrityViolationException.class);
}
}
Real Database Performance Testing
@Test
void shouldPerformEfficientlyWithLargeDataset() {
// Given
List<User> users = createLargeUserDataset(10000);
userRepository.saveAll(users);
// When
long startTime = System.currentTimeMillis();
List<User> activeUsers = userRepository.findByActiveTrue();
long executionTime = System.currentTimeMillis() - startTime;
// Then
assertThat(executionTime).isLessThan(500); // Max 500ms
assertThat(activeUsers).hasSizeGreaterThan(5000);
}
Service Discovery Testing
Testing Eureka integration ensures services can find each other:
Service Registration Verification
@SpringBootTest
@TestPropertySource(properties = {
"eureka.client.service-url.defaultZone=http://192.168.1.105:8761/eureka"
})
class ServiceDiscoveryTest {
@Autowired
private EurekaClient eurekaClient;
@Test
void shouldRegisterWithEureka() {
// When
Application application = eurekaClient.getApplication("AUTH-SERVICE");
// Then
assertThat(application).isNotNull();
assertThat(application.getInstances()).isNotEmpty();
InstanceInfo instance = application.getInstances().get(0);
assertThat(instance.getStatus()).isEqualTo(InstanceInfo.InstanceStatus.UP);
}
@Test
void shouldDiscoverOtherServices() {
// When
List<ServiceInstance> memberServices = discoveryClient.getInstances("MEMBER-SERVICE");
// Then
assertThat(memberServices).isNotEmpty();
ServiceInstance instance = memberServices.get(0);
assertThat(instance.isSecure()).isFalse();
assertThat(instance.getPort()).isEqualTo(8070);
}
}
Performance & Load Testing
I use JMeter for load testing and performance benchmarking:
Load Test Configuration
- Authentication Endpoints: 100 concurrent users, 5-minute ramp-up
- Database Operations: Connection pool stress testing
- Memory Usage: Heap analysis under load
- Response Times: 95th percentile under 200ms
Performance Monitoring
@Test
void shouldMaintainPerformanceUnderLoad() {
// Given
int numberOfThreads = 50;
int requestsPerThread = 100;
// When
ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads);
List<Future<Long>> futures = new ArrayList<>();
for (int i = 0; i < numberOfThreads; i++) {
futures.add(executor.submit(() -> {
long totalTime = 0;
for (int j = 0; j < requestsPerThread; j++) {
long startTime = System.currentTimeMillis();
authService.authenticate("testuser", "password");
totalTime += System.currentTimeMillis() - startTime;
}
return totalTime / requestsPerThread;
}));
}
// Then
futures.forEach(future -> {
try {
Long avgResponseTime = future.get();
assertThat(avgResponseTime).isLessThan(100); // Max 100ms average
} catch (Exception e) {
fail("Performance test failed: " + e.getMessage());
}
});
}
Testing Best Practices
Here are the key principles I follow for microservice testing:
Test Pyramid Implementation
- Fast Feedback: Unit tests run in under 30 seconds
- Real Infrastructure: Integration tests use actual services
- Comprehensive Coverage: Exception scenarios and edge cases
- Automated Execution: All tests run in CI/CD pipeline
Test Data Management
- Test Isolation: Each test creates and cleans up its own data
- Realistic Data: Test data resembles production scenarios
- Database Seeding: Consistent test data setup
- Transaction Rollback: Automatic cleanup after tests
What’s Next?
With comprehensive testing ensuring our microservices are bulletproof, we’re ready for the final piece of the puzzle: automated deployment to production.
🧭 Continue Learning My Approach:
⬅️ Introduce AdventureTube Microservice Hub
⬅️ Part 1: Architecture Overview & Design Patterns
⬅️ Part 2: Development Environment & Debugging
✅ Part 3: Testing Strategies (Current)
▶️ Part 4: CI/CD Deployment with Jenkins – Complete automation from Git commit to production deployment
In Part 4, I’ll show you how to automate the entire deployment pipeline using Jenkins, Docker, and my Raspberry Pi infrastructure. You’ll see how every Git commit triggers a complete build, test, and deployment cycle that gets your code to production safely and efficiently.
Tags: #Microservices #Testing #SpringBoot #JUnit #Integration #API
Categories: BACKEND(spring-microservice), Testing