1. 概述

本文将探讨如何在Spring Security应用的单元测试中有效模拟JWT(JSON Web Token)令牌。测试受JWT保护的接口时,通常需要模拟不同的JWT场景,而不依赖实际的令牌生成或验证流程。 这种方法让我们能编写健壮的单元测试,避免在测试过程中管理真实JWT令牌的复杂性。

在单元测试中模拟JWT解码至关重要,因为它能将认证逻辑与外部依赖(如令牌生成服务或第三方身份提供商)解耦。 通过模拟不同的JWT场景,我们可以确保应用能正确处理有效令牌、自定义声明、无效令牌和过期令牌。

我们将学习如何使用Mockito模拟JwtDecoder、创建自定义JWT声明,并测试各种场景。学完本教程后,你将能为基于Spring Security JWT的认证逻辑编写全面的单元测试。

2. 环境搭建与配置

在编写测试前,先搭建包含必要依赖的测试环境。

2.1. 依赖项

我们将使用Spring Security OAuth2MockitoJUnit 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,避免测试间状态污染!


原始标题:Mock JWT with JwtDecoder in JUnit Test | Baeldung