1. 概述
本文将探讨如何在Spring Security应用的单元测试中有效模拟JWT(JSON Web Token)令牌。测试受JWT保护的接口时,通常需要模拟不同的JWT场景,而不依赖实际的令牌生成或验证流程。 这种方法让我们能编写健壮的单元测试,避免在测试过程中管理真实JWT令牌的复杂性。
在单元测试中模拟JWT解码至关重要,因为它能将认证逻辑与外部依赖(如令牌生成服务或第三方身份提供商)解耦。 通过模拟不同的JWT场景,我们可以确保应用能正确处理有效令牌、自定义声明、无效令牌和过期令牌。
我们将学习如何使用Mockito模拟JwtDecoder
、创建自定义JWT声明,并测试各种场景。学完本教程后,你将能为基于Spring Security JWT的认证逻辑编写全面的单元测试。
2. 环境搭建与配置
在编写测试前,先搭建包含必要依赖的测试环境。
2.1. 依赖项
我们将使用Spring Security OAuth2、Mockito和JUnit 5进行测试:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
<version>6.4.2</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.15.2</version>
<scope>test</scope>
</dependency>
spring-security-oauth2-jose依赖为Spring Security提供JWT支持,包括用于解码和验证JWT的JwtDecoder
接口。mockito-core依赖允许我们在测试中模拟依赖项,确保能隔离被测单元UserController
与外部系统的交互。
2.2. 创建UserController
接下来创建UserController
,包含@GetMapping("/user")
接口,用于根据JWT令牌获取用户信息。它会验证令牌、检查过期时间并提取用户主体:
@GetMapping("/user")
public ResponseEntity<String> getUserInfo(@AuthenticationPrincipal Jwt jwt) {
if (jwt == null || jwt.getSubject() == null) {
throw new JwtValidationException("Invalid token", Arrays.asList(new OAuth2Error("invalid_token")));
}
Instant expiration = jwt.getExpiresAt();
if (expiration != null && expiration.isBefore(Instant.now())) {
throw new JwtValidationException("Token has expired", Arrays.asList(new OAuth2Error("expired_token")));
}
return ResponseEntity.ok("Hello, " + jwt.getSubject());
}
2.3. 设置测试类
创建测试类MockJwtDecoderJUnitTest
,使用Mockito模拟JwtDecoder
。初始设置如下:
@ExtendWith(MockitoExtension.class)
public class MockJwtDecoderJUnitTest {
@Mock
private JwtDecoder jwtDecoder;
@InjectMocks
private UserController userController;
@BeforeEach
void setUp() {
SecurityContextHolder.clearContext();
}
}
此设置中,我们使用@ExtendWith(MockitoExtension.class)
启用Mockito。JwtDecoder
通过@Mock
模拟,UserController
通过@InjectMocks
注入模拟的JwtDecoder
。每次测试前清除SecurityContextHolder
确保状态干净。
3. 模拟JWT解码
环境就绪后,我们编写测试模拟JWT解码。首先测试有效JWT令牌。
3.1. 测试有效令牌
当提供有效令牌时,应用应返回用户信息。测试场景如下:
@Test
void whenValidToken_thenReturnsUserInfo() {
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "john.doe");
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.claims(existingClaims -> existingClaims.putAll(claims))
.build();
JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
ResponseEntity<String> response = userController.getUserInfo(jwt);
assertEquals("Hello, john.doe", response.getBody());
assertEquals(HttpStatus.OK, response.getStatusCode());
}
此测试中,我们创建包含sub
(主体)声明的模拟JWT。使用JwtAuthenticationToken
设置安全上下文,UserController
处理令牌并返回响应。通过断言验证响应。
3.2. 测试自定义声明
JWT常包含角色或邮箱等自定义声明。例如,若UserController
使用roles
声明授权访问,测试应验证控制器基于声明角色的预期行为:
@Test
void whenTokenHasCustomClaims_thenProcessesCorrectly() {
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "john.doe");
claims.put("roles", Arrays.asList("ROLE_USER", "ROLE_ADMIN"));
claims.put("email", "john.doe@example.com");
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.claims(existingClaims -> existingClaims.putAll(claims))
.build();
List authorities = ((List) jwt.getClaim("roles"))
.stream()
.map(role -> new SimpleGrantedAuthority(role))
.collect(Collectors.toList());
JwtAuthenticationToken authentication = new JwtAuthenticationToken(
jwt,
authorities,
jwt.getClaim("sub")
);
SecurityContextHolder.getContext().setAuthentication(authentication);
ResponseEntity response = userController.getUserInfo(jwt);
assertEquals("Hello, john.doe", response.getBody());
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(authentication.getAuthorities().stream()
.anyMatch(auth -> auth.getAuthority().equals("ROLE_ADMIN")));
}
此测试验证roles
声明被正确处理,且用户拥有预期权限(此处为ROLE_ADMIN
)。
4. 测试其他场景
接下来探索测试不同边界情况。
4.1. 测试无效令牌
当提供无效令牌时,应用应抛出JwtValidationException
。编写测试验证JwtDecoder
在解码无效令牌时正确抛出异常:
@Test
void whenInvalidToken_thenThrowsException() {
Map<String, Object> claims = new HashMap<>();
claims.put("sub", null);
Jwt invalidJwt = Jwt.withTokenValue("invalid_token")
.header("alg", "none")
.claims(existingClaims -> existingClaims.putAll(claims))
.build();
JwtAuthenticationToken authentication = new JwtAuthenticationToken(invalidJwt);
SecurityContextHolder.getContext()
.setAuthentication(authentication);
JwtValidationException exception = assertThrows(JwtValidationException.class, () -> {
userController.getUserInfo(invalidJwt);
});
assertEquals("Invalid token", exception.getMessage());
}
此测试模拟JwtDecoder
在处理null
令牌时抛出JwtValidationException
。断言验证抛出包含"Invalid token"消息的异常。
4.2. 测试过期令牌
当提供过期令牌时,应用应抛出JwtValidationException
。以下测试验证JwtDecoder
在解码过期令牌时正确抛出异常:
@Test
void whenExpiredToken_thenThrowsException() throws Exception {
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "john.doe");
claims.put("exp", Instant.now().minus(1, ChronoUnit.DAYS));
Jwt expiredJwt = Jwt.withTokenValue("expired_token")
.header("alg", "none")
.claims(existingClaims -> existingClaims.putAll(claims))
.build();
JwtAuthenticationToken authentication = new JwtAuthenticationToken(expiredJwt);
SecurityContextHolder.getContext()
.setAuthentication(authentication);
JwtValidationException exception = assertThrows(JwtValidationException.class, () -> {
userController.getUserInfo(expiredJwt);
});
assertEquals("Token has expired", exception.getMessage());
}
此测试将过期时间设为1天前以模拟过期令牌。断言验证抛出包含"Token has expired"消息的异常。
5. 总结
本教程中,我们学习了如何在JUnit测试中使用Mockito模拟JWT解码。涵盖了多种场景,包括测试含自定义声明的有效令牌、处理无效令牌和管理过期令牌。
通过模拟JWT解码,我们能为Spring Security应用编写单元测试,而无需依赖外部令牌生成或验证服务。这种方法确保测试快速、可靠且独立于外部依赖。
✅ 核心收获:
- 使用Mockito隔离JWT解码逻辑
- 模拟有效/无效/过期令牌场景
- 处理自定义声明(如角色/邮箱)
- 避免测试中依赖真实JWT服务
⚠️ 踩坑提醒: 测试JWT时务必清除SecurityContextHolder
,避免测试间状态污染!