๐งช 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.
