🧪 Step 5: Testing & Validation
🧭 Series Navigation:
- ▶️ Spring Security Hub
- ▶️ Step 1: Architecture Foundation
- ▶️ Step 2: Configuration Fundamentals
- ▶️ Step 3: Implementation Strategy
- ▶️ Step 4: Production Implementation
- ✅ Step 5: Testing & Validation (Current)
🎥 YouTube Video: Coming Soon – Subscribe to JavaiOS Channel
Custom Spring Security Testing in Spring Boot 🧪
Testing Spring Security implementations, especially custom authentication providers and JWT-based authentication, requires specific strategies and tools. This guide covers comprehensive testing approaches for the AdventureTube authentication system, including integration tests, unit tests, and common testing challenges.
🎯 Testing Strategy Overview
Testing Pyramid for Security
E2E Tests
↗ ↖
Integration Tests (Medium)
↗ ↖
Unit Tests (Many) Component Tests
↗ ↖
MockMvc Tests Security Tests
Testing Layers:
- Unit Tests – Individual components (AuthenticationProvider, UserDetailsService)
- Integration Tests – Complete authentication flow with mocked external services
- Security Tests – Authorization, JWT validation, endpoint protection
- Component Tests – Controller testing with security context
- E2E Tests – Full system testing including external integrations
🔧 Test Setup and Configuration
Test Dependencies
<dependencies>
<!-- Spring Boot Test Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Security Test -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!-- TestContainers for Integration Tests -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- WireMock for External Service Mocking -->
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Test Configuration Class
@TestConfiguration
public class TestSecurityConfig {
@Bean
@Primary
public PasswordEncoder testPasswordEncoder() {
// Use NoOpPasswordEncoder for faster tests
return NoOpPasswordEncoder.getInstance();
}
@Bean
@Primary
public JwtUtil testJwtUtil() {
return new JwtUtil() {
@Override
public String generateAccessToken(UserDetails userDetails) {
return "test-access-token-" + userDetails.getUsername();
}
@Override
public boolean validateToken(String token, UserDetails userDetails) {
return token.equals("test-access-token-" + userDetails.getUsername());
}
};
}
@Bean
@Primary
public RestTemplate testRestTemplate() {
return new RestTemplate();
}
}
🧪 Unit Testing Authentication Components
Testing Custom Authentication Provider
@ExtendWith(MockitoExtension.class)
class CustomAuthenticationProviderTest {
@Mock
private CustomUserDetailService userDetailsService;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private CustomAuthenticationProvider authenticationProvider;
private UserDetails testUserDetails;
private Authentication testAuthentication;
@BeforeEach
void setUp() {
testUserDetails = CustomUserDetails.builder()
.username("test@example.com")
.password("encoded-password")
.googleId("google-123")
.role("USER")
.enabled(true)
.build();
testAuthentication = new UsernamePasswordAuthenticationToken(
"test@example.com", "google-123");
}
@Test
@DisplayName("Should authenticate successfully with valid credentials")
void authenticate_WithValidCredentials_ShouldReturnAuthentication() {
// Given
when(userDetailsService.loadUserByUsername("test@example.com"))
.thenReturn(testUserDetails);
// When
Authentication result = authenticationProvider.authenticate(testAuthentication);
// Then
assertThat(result).isNotNull();
assertThat(result.isAuthenticated()).isTrue();
assertThat(result.getPrincipal()).isEqualTo(testUserDetails);
assertThat(result.getCredentials()).isEqualTo("google-123");
}
@Test
@DisplayName("Should throw BadCredentialsException for invalid Google ID")
void authenticate_WithInvalidGoogleId_ShouldThrowException() {
// Given
UserDetails invalidUser = CustomUserDetails.builder()
.username("test@example.com")
.googleId("different-google-id")
.build();
when(userDetailsService.loadUserByUsername("test@example.com"))
.thenReturn(invalidUser);
// When & Then
assertThatThrownBy(() -> authenticationProvider.authenticate(testAuthentication))
.isInstanceOf(BadCredentialsException.class)
.hasMessage("Invalid credentials");
}
@Test
@DisplayName("Should throw BadCredentialsException when user not found")
void authenticate_WithNonExistentUser_ShouldThrowException() {
// Given
when(userDetailsService.loadUserByUsername("test@example.com"))
.thenThrow(new UsernameNotFoundException("User not found"));
// When & Then
assertThatThrownBy(() -> authenticationProvider.authenticate(testAuthentication))
.isInstanceOf(BadCredentialsException.class)
.hasMessage("Invalid credentials");
}
@Test
@DisplayName("Should support UsernamePasswordAuthenticationToken")
void supports_WithCorrectAuthenticationType_ShouldReturnTrue() {
// When & Then
assertThat(authenticationProvider.supports(UsernamePasswordAuthenticationToken.class))
.isTrue();
}
}
Testing User Details Service with MockRestServiceServer
@ExtendWith(SpringExtension.class)
class CustomUserDetailServiceTest {
@Mock
private RestTemplate restTemplate;
@InjectMocks
private CustomUserDetailService userDetailsService;
private MockRestServiceServer mockServer;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
mockServer = MockRestServiceServer.createServer(restTemplate);
objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(userDetailsService, "memberServiceUrl",
"http://member-service");
}
@Test
@DisplayName("Should load user details successfully")
void loadUserByUsername_WithValidEmail_ShouldReturnUserDetails() throws Exception {
// Given
String email = "test@example.com";
MemberDTO memberDTO = MemberDTO.builder()
.email(email)
.password("encoded-password")
.googleId("google-123")
.role("USER")
.build();
mockServer.expect(requestTo("http://member-service/api/members/email/" + email))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess(objectMapper.writeValueAsString(memberDTO),
MediaType.APPLICATION_JSON));
// When
UserDetails result = userDetailsService.loadUserByUsername(email);
// Then
assertThat(result).isNotNull();
assertThat(result.getUsername()).isEqualTo(email);
assertThat(result.getPassword()).isEqualTo("encoded-password");
assertThat(((CustomUserDetails) result).getGoogleId()).isEqualTo("google-123");
mockServer.verify();
}
@Test
@DisplayName("Should throw UsernameNotFoundException when user not found")
void loadUserByUsername_WithNonExistentEmail_ShouldThrowException() {
// Given
String email = "nonexistent@example.com";
mockServer.expect(requestTo("http://member-service/api/members/email/" + email))
.andExpect(method(HttpMethod.GET))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
// When & Then
assertThatThrownBy(() -> userDetailsService.loadUserByUsername(email))
.isInstanceOf(UsernameNotFoundException.class)
.hasMessageContaining("User not found");
mockServer.verify();
}
@Test
@DisplayName("Should throw AuthenticationServiceException on service error")
void loadUserByUsername_WithServiceError_ShouldThrowException() {
// Given
String email = "test@example.com";
mockServer.expect(requestTo("http://member-service/api/members/email/" + email))
.andExpected(method(HttpMethod.GET))
.andRespond(withServerError());
// When & Then
assertThatThrownBy(() -> userDetailsService.loadUserByUsername(email))
.isInstanceOf(AuthenticationServiceException.class)
.hasMessageContaining("User service unavailable");
mockServer.verify();
}
}
🔒 Integration Testing with Spring Security
Testing Authentication Endpoints
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = {
"member.service.url=http://localhost:${wiremock.server.port}",
"jwt.secret=test-secret-key-for-testing-purposes"
})
class AuthControllerIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().port(0))
.build();
@BeforeEach
void setUp() {
System.setProperty("wiremock.server.port", String.valueOf(wireMock.getPort()));
}
@Test
@DisplayName("Should authenticate successfully with valid credentials")
void issueToken_WithValidCredentials_ShouldReturnTokens() throws Exception {
// Given
AuthRequest request = AuthRequest.builder()
.email("test@example.com")
.googleIdToken("valid-google-token")
.googleId("google-123")
.build();
MemberDTO memberDTO = MemberDTO.builder()
.email("test@example.com")
.password("encoded-password")
.googleId("google-123")
.role("USER")
.build();
// Mock member service response
wireMock.stubFor(get(urlEqualTo("/api/members/email/test@example.com"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody(objectMapper.writeValueAsString(memberDTO))));
// Mock Google token verification (if using external service)
mockGoogleTokenVerification("valid-google-token", "test@example.com");
// When
ResponseEntity<AuthResponse> response = restTemplate.postForEntity(
"/auth/token", request, AuthResponse.class);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getAccessToken()).isNotEmpty();
assertThat(response.getBody().getRefreshToken()).isNotEmpty();
assertThat(response.getBody().getTokenType()).isEqualTo("Bearer");
}
@Test
@DisplayName("Should return 401 for invalid credentials")
void issueToken_WithInvalidCredentials_ShouldReturn401() {
// Given
AuthRequest request = AuthRequest.builder()
.email("invalid@example.com")
.googleIdToken("invalid-token")
.googleId("invalid-google-id")
.build();
wireMock.stubFor(get(urlEqualTo("/api/members/email/invalid@example.com"))
.willReturn(aResponse().withStatus(404)));
// When
ResponseEntity<ErrorResponse> response = restTemplate.postForEntity(
"/auth/token", request, ErrorResponse.class);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getError()).isEqualTo("Authentication Failed");
}
private void mockGoogleTokenVerification(String token, String email) {
// Mock Google API response for token verification
wireMock.stubFor(get(urlMatching("/oauth2/v3/tokeninfo\\?id_token=" + token))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{"
+ "\"email\":\"" + email + "\","
+ "\"email_verified\":true,"
+ "\"aud\":\"your-google-client-id\""
+ "}")));
}
}
Testing JWT Filter and Security Chain
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestSecurityConfig.class)
class JwtAuthFilterIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private JwtUtil jwtUtil;
@MockBean
private CustomUserDetailService userDetailsService;
@Test
@DisplayName("Should allow access to public endpoints without token")
void publicEndpoint_WithoutToken_ShouldAllow() throws Exception {
mockMvc.perform(get("/auth/public/health"))
.andExpect(status().isOk());
}
@Test
@DisplayName("Should deny access to protected endpoints without token")
void protectedEndpoint_WithoutToken_ShouldDeny() throws Exception {
mockMvc.perform(get("/admin/users"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Should allow access to protected endpoints with valid token")
void protectedEndpoint_WithValidToken_ShouldAllow() throws Exception {
// Given
UserDetails userDetails = CustomUserDetails.builder()
.username("test@example.com")
.role("ADMIN")
.enabled(true)
.build();
when(userDetailsService.loadUserByUsername("test@example.com"))
.thenReturn(userDetails);
String token = jwtUtil.generateAccessToken(userDetails);
// When & Then
mockMvc.perform(get("/admin/users")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
@Test
@DisplayName("Should deny access with invalid token")
void protectedEndpoint_WithInvalidToken_ShouldDeny() throws Exception {
mockMvc.perform(get("/admin/users")
.header("Authorization", "Bearer invalid-token"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Should deny access to admin endpoints for non-admin users")
@WithMockUser(username = "user@example.com", roles = "USER")
void adminEndpoint_WithUserRole_ShouldDeny() throws Exception {
mockMvc.perform(get("/admin/users"))
.andExpect(status().isForbidden());
}
}
🎯 Testing Best Practices
1. Security Context Testing
@Test
@WithMockUser(username = "admin@example.com", roles = "ADMIN")
@DisplayName("Should process admin request with security context")
void adminOperation_WithSecurityContext_ShouldSucceed() {
// Given
SecurityContext context = SecurityContextHolder.getContext();
Authentication auth = context.getAuthentication();
// Then
assertThat(auth).isNotNull();
assertThat(auth.getName()).isEqualTo("admin@example.com");
assertThat(auth.getAuthorities())
.extracting("authority")
.contains("ROLE_ADMIN");
}
@Test
@WithUserDetails(value = "test@example.com", userDetailsServiceBeanName = "customUserDetailService")
@DisplayName("Should load real user details for testing")
void userOperation_WithRealUserDetails_ShouldWork() {
// Test with actual UserDetails loaded from service
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
assertThat(auth.getPrincipal()).isInstanceOf(CustomUserDetails.class);
}
2. Custom Security Annotations
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
String email() default "test@example.com";
String googleId() default "google-123";
String role() default "USER";
}
public class WithMockCustomUserSecurityContextFactory
implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser annotation) {
CustomUserDetails userDetails = CustomUserDetails.builder()
.username(annotation.email())
.googleId(annotation.googleId())
.role(annotation.role())
.enabled(true)
.build();
Authentication auth = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
return context;
}
}
// Usage
@Test
@WithMockCustomUser(email = "admin@example.com", role = "ADMIN")
void testWithCustomUser() {
// Test implementation
}
3. Performance Testing
@Test
@DisplayName("Authentication should complete within acceptable time")
void authentication_PerformanceTest() {
// Given
AuthRequest request = createValidAuthRequest();
// When & Then
assertTimeout(Duration.ofMillis(500), () -> {
for (int i = 0; i < 100; i++) {
authService.issueToken(request);
}
});
}
@Test
@DisplayName("JWT validation should be fast")
void jwtValidation_PerformanceTest() {
// Given
UserDetails userDetails = createTestUserDetails();
String token = jwtUtil.generateAccessToken(userDetails);
// When & Then
assertTimeout(Duration.ofMillis(10), () -> {
for (int i = 0; i < 1000; i++) {
jwtUtil.validateToken(token, userDetails);
}
});
}
⚠️ Common Testing Pitfalls
1. Forgetting to Reset Security Context
// ❌ Security context pollution between tests
@Test
void test1() {
SecurityContextHolder.getContext().setAuthentication(mockAuth);
// Test logic
} // Context not cleared
@Test
void test2() {
// This test might be affected by previous test's context
}
// ✅ Proper cleanup
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// Or use @DirtiesContext
@Test
@DirtiesContext
void isolatedTest() {
// Test logic
}
2. Not Mocking External Dependencies
// ❌ Tests calling real external services
@Test
void authTest() {
// Calls real Google API and member service
AuthResponse response = authController.issueToken(request);
}
// ✅ Properly mocked dependencies
@Test
void authTest() {
// Mock all external calls
when(googleTokenVerifier.verify(any())).thenReturn(mockToken);
when(memberServiceClient.getUser(any())).thenReturn(mockUser);
AuthResponse response = authController.issueToken(request);
}
3. Incomplete Test Coverage
// ✅ Comprehensive test coverage
@Nested
@DisplayName("Authentication Provider Tests")
class AuthenticationProviderTests {
@Nested
@DisplayName("Success Scenarios")
class SuccessScenarios {
@Test void validCredentials() { }
@Test void adminUser() { }
@Test void regularUser() { }
}
@Nested
@DisplayName("Failure Scenarios")
class FailureScenarios {
@Test void invalidCredentials() { }
@Test void userNotFound() { }
@Test void serviceUnavailable() { }
@Test void invalidGoogleId() { }
}
@Nested
@DisplayName("Edge Cases")
class EdgeCases {
@Test void nullCredentials() { }
@Test void emptyCredentials() { }
@Test void malformedToken() { }
}
}
📊 Test Coverage and Metrics
Coverage Configuration
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.7</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>CLASS</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
🎉 Security Journey Complete!
🧭 Complete Spring Security Series:
- ▶️ Spring Security Hub
- ✅ Step 1: Architecture Foundation
- ✅ Step 2: Configuration Fundamentals
- ✅ Step 3: Implementation Strategy
- ✅ Step 4: Production Implementation
- ✅ Step 5: Testing & Validation (Current)
Part of the AdventureTube technical blog series supporting the JavaiOS YouTube channel.