SpringBoot测试与安全
大约 6 分钟
SpringBoot测试与安全
SpringBoot测试概述
Spring Boot提供了全面的测试支持,包括单元测试、集成测试、Web层测试、数据访问层测试等。通过[spring-boot-starter-test](file:///Users/ldf/app/docs/pom.xml#L49-L53)起步依赖,可以轻松进行各种类型的测试。
测试支持的组件
单元测试
添加测试依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>服务层单元测试
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
    
    @Mock
    private UserRepository userRepository;
    
    @InjectMocks
    private UserService userService;
    
    @Test
    void shouldCreateUser() {
        // 准备测试数据
        User inputUser = new User("张三", "zhangsan@example.com");
        User savedUser = new User(1L, "张三", "zhangsan@example.com");
        
        // 设置Mock行为
        when(userRepository.save(inputUser)).thenReturn(savedUser);
        
        // 执行测试
        User result = userService.createUser(inputUser);
        
        // 验证结果
        assertThat(result.getId()).isEqualTo(1L);
        assertThat(result.getName()).isEqualTo("张三");
        assertThat(result.getEmail()).isEqualTo("zhangsan@example.com");
        
        // 验证Mock调用
        verify(userRepository).save(inputUser);
    }
    
    @Test
    void shouldThrowExceptionWhenUserNotFound() {
        // 设置Mock行为
        when(userRepository.findById(999L)).thenReturn(Optional.empty());
        
        // 执行测试并验证异常
        assertThatThrownBy(() -> userService.getUserById(999L))
            .isInstanceOf(UserNotFoundException.class)
            .hasMessage("用户未找到: 999");
    }
}控制器单元测试
@WebMvcTest(UserController.class)
class UserControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @Test
    void shouldReturnUserById() throws Exception {
        // 准备测试数据
        User user = new User(1L, "张三", "zhangsan@example.com");
        
        // 设置Mock行为
        when(userService.getUserById(1L)).thenReturn(user);
        
        // 执行测试
        mockMvc.perform(get("/api/users/1")
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1L))
                .andExpect(jsonPath("$.name").value("张三"))
                .andExpect(jsonPath("$.email").value("zhangsan@example.com"));
        
        // 验证Mock调用
        verify(userService).getUserById(1L);
    }
    
    @Test
    void shouldCreateUser() throws Exception {
        // 准备测试数据
        User inputUser = new User(null, "李四", "lisi@example.com");
        User savedUser = new User(1L, "李四", "lisi@example.com");
        
        // 设置Mock行为
        when(userService.createUser(any(User.class))).thenReturn(savedUser);
        
        // 执行测试
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"李四\",\"email\":\"lisi@example.com\"}"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1L))
                .andExpect(jsonPath("$.name").value("李四"));
    }
}集成测试
全局集成测试
@SpringBootTest
@TestPropertySource(locations = "classpath:application-test.properties")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ApplicationIntegrationTest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    void shouldCreateAndRetrieveUser() {
        // 准备测试数据
        UserDto userDto = new UserDto();
        userDto.setName("测试用户");
        userDto.setEmail("test@example.com");
        
        // 创建用户
        ResponseEntity<User> createResponse = restTemplate.postForEntity(
            "/api/users", userDto, User.class);
        
        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(createResponse.getBody()).isNotNull();
        assertThat(createResponse.getBody().getId()).isNotNull();
        
        // 获取用户
        Long userId = createResponse.getBody().getId();
        ResponseEntity<User> getResponse = restTemplate.getForEntity(
            "/api/users/" + userId, User.class);
        
        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(getResponse.getBody().getName()).isEqualTo("测试用户");
    }
}Web层集成测试
@WebMvcTest
class WebLayerIntegrationTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @Test
    void shouldHandleUserCreation() throws Exception {
        User user = new User(1L, "测试用户", "test@example.com");
        
        when(userService.createUser(any(User.class))).thenReturn(user);
        
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {
                        "name": "测试用户",
                        "email": "test@example.com"
                    }
                    """))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1L));
    }
}数据访问层测试
JPA测试
@DataJpaTest
class UserRepositoryTest {
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    void shouldFindUserByEmail() {
        // 准备测试数据
        User user = new User("张三", "zhangsan@example.com");
        entityManager.persistAndFlush(user);
        
        // 执行测试
        User found = userRepository.findByEmail("zhangsan@example.com");
        
        // 验证结果
        assertThat(found).isNotNull();
        assertThat(found.getName()).isEqualTo("张三");
    }
    
    @Test
    void shouldFindUsersByNameContaining() {
        // 准备测试数据
        entityManager.persist(new User("张三", "zhangsan@example.com"));
        entityManager.persist(new User("张四", "zhangsi@example.com"));
        entityManager.persist(new User("李四", "lisi@example.com"));
        entityManager.flush();
        
        // 执行测试
        List<User> users = userRepository.findByNameContainingIgnoreCase("张");
        
        // 验证结果
        assertThat(users).hasSize(2);
        assertThat(users).extracting(User::getName)
            .containsExactlyInAnyOrder("张三", "张四");
    }
}数据库测试配置
# src/test/resources/application-test.properties
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.h2.console.enabled=true测试配置和工具
测试配置类
@TestConfiguration
public class TestConfig {
    
    @Bean
    @Primary
    public UserService mockUserService() {
        return Mockito.mock(UserService.class);
    }
    
    @Bean
    public Clock fixedClock() {
        return Clock.fixed(Instant.parse("2023-01-01T10:00:00Z"), ZoneId.of("UTC"));
    }
}测试工具类
public class TestUtil {
    
    public static String asJsonString(final Object obj) {
        try {
            return new ObjectMapper().writeValueAsString(obj);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    
    public static <T> T fromJsonString(final String json, Class<T> clazz) {
        try {
            return new ObjectMapper().readValue(json, clazz);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}Spring Security安全框架
添加安全依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>基本安全配置
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/users/**").hasRole("USER")
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .httpBasic(withDefaults())
            .formLogin(withDefaults())
            .csrf(csrf -> csrf.disable()); // 仅用于演示,生产环境应启用CSRF保护
        return http.build();
    }
    
    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails user = User.builder()
            .username("user")
            .password("{noop}password") // {noop}表示不加密
            .roles("USER")
            .build();
            
        UserDetails admin = User.builder()
            .username("admin")
            .password("{noop}admin")
            .roles("USER", "ADMIN")
            .build();
            
        return new InMemoryUserDetailsManager(user, admin);
    }
}JWT安全配置
@Configuration
@EnableWebSecurity
public class JwtSecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
            .csrf(csrf -> csrf.disable());
        return http.build();
    }
    
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter();
    }
}
// JWT过滤器
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    @Autowired
    private JwtTokenProvider tokenProvider;
    
    @Autowired
    private UserDetailsService userDetailsService;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                  HttpServletResponse response, 
                                  FilterChain filterChain) throws ServletException, IOException {
        try {
            String jwt = getJwtFromRequest(request);
            
            if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
                String username = tokenProvider.getUsernameFromToken(jwt);
                
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken authentication = 
                    new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception ex) {
            logger.error("无法设置用户认证", ex);
        }
        
        filterChain.doFilter(request, response);
    }
    
    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}JWT工具类
@Component
public class JwtTokenProvider {
    
    private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);
    
    @Value("${app.jwtSecret}")
    private String jwtSecret;
    
    @Value("${app.jwtExpirationInMs}")
    private int jwtExpirationInMs;
    
    public String generateToken(Authentication authentication) {
        UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
        
        Date expiryDate = new Date(System.currentTimeMillis() + jwtExpirationInMs);
        
        return Jwts.builder()
                .setSubject(userPrincipal.getUsername())
                .setIssuedAt(new Date())
                .setExpiration(expiryDate)
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .compact();
    }
    
    public String getUsernameFromToken(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(jwtSecret)
                .parseClaimsJws(token)
                .getBody();
        
        return claims.getSubject();
    }
    
    public boolean validateToken(String authToken) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
            return true;
        } catch (SignatureException ex) {
            logger.error("无效的JWT签名");
        } catch (MalformedJwtException ex) {
            logger.error("无效的JWT令牌");
        } catch (ExpiredJwtException ex) {
            logger.error("过期的JWT令牌");
        } catch (UnsupportedJwtException ex) {
            logger.error("不支持的JWT令牌");
        } catch (IllegalArgumentException ex) {
            logger.error("JWT声明为空");
        }
        return false;
    }
}安全测试
安全集成测试
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(locations = "classpath:application-test.properties")
class SecurityIntegrationTest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Test
    void shouldAccessPublicEndpointWithoutAuthentication() {
        ResponseEntity<String> response = restTemplate.getForEntity(
            "/api/public/info", String.class);
        
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }
    
    @Test
    void shouldNotAccessProtectedEndpointWithoutAuthentication() {
        ResponseEntity<String> response = restTemplate.getForEntity(
            "/api/users", String.class);
        
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }
    
    @Test
    @WithMockUser(roles = "USER")
    void shouldAccessProtectedEndpointWithAuthentication() {
        ResponseEntity<String> response = restTemplate.getForEntity(
            "/api/users", String.class);
        
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }
}Web层安全测试
@WebMvcTest(UserController.class)
class UserControllerSecurityTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @Test
    void shouldReturnUnauthorizedForProtectedEndpoint() throws Exception {
        mockMvc.perform(get("/api/users"))
                .andExpect(status().isUnauthorized());
    }
    
    @Test
    @WithMockUser(roles = "USER")
    void shouldReturnUsersForAuthenticatedUser() throws Exception {
        when(userService.getAllUsers()).thenReturn(Collections.emptyList());
        
        mockMvc.perform(get("/api/users"))
                .andExpect(status().isOk());
    }
    
    @Test
    @WithMockUser(roles = "USER")
    void shouldReturnForbiddenForAdminEndpoint() throws Exception {
        mockMvc.perform(get("/api/admin/users"))
                .andExpect(status().isForbidden());
    }
    
    @Test
    @WithMockUser(roles = "ADMIN")
    void shouldAllowAccessForAdminUser() throws Exception {
        mockMvc.perform(get("/api/admin/users"))
                .andExpect(status().isOk());
    }
}JWT安全测试
@WebMvcTest(AuthController.class)
class AuthControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private AuthenticationManager authenticationManager;
    
    @MockBean
    private JwtTokenProvider tokenProvider;
    
    @Test
    void shouldAuthenticateUserAndReturnJwtToken() throws Exception {
        LoginRequest loginRequest = new LoginRequest("user", "password");
        Authentication authentication = Mockito.mock(Authentication.class);
        
        when(authenticationManager.authenticate(any())).thenReturn(authentication);
        when(tokenProvider.generateToken(authentication)).thenReturn("jwt-token");
        
        mockMvc.perform(post("/api/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(TestUtil.asJsonString(loginRequest)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.accessToken").value("jwt-token"))
                .andExpect(jsonPath("$.tokenType").value("Bearer"));
    }
}测试最佳实践
1. 测试分层
// 单元测试 - 只测试当前类的逻辑
@ExtendWith(MockitoExtension.class)
class UserServiceUnitTest {
    // ...
}
// 集成测试 - 测试多个组件的协作
@SpringBootTest
class UserServiceIntegrationTest {
    // ...
}
// Web层测试 - 测试HTTP接口
@WebMvcTest(UserController.class)
class UserControllerTest {
    // ...
}2. 测试数据管理
@TestMethodOrder(OrderAnnotation.class)
class UserServiceTest {
    
    @Test
    @Order(1)
    void shouldCreateUser() {
        // 创建测试数据
    }
    
    @Test
    @Order(2)
    void shouldUpdateUser() {
        // 使用之前创建的数据
    }
}3. 测试配置
# application-test.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: false
    
logging:
  level:
    org.springframework: WARN
    com.example: DEBUG
    
app:
  jwtSecret: testSecretKey
  jwtExpirationInMs: 600004. 测试工具封装
public class TestDataProvider {
    
    public static User createTestUser() {
        return new User("测试用户", "test@example.com");
    }
    
    public static List<User> createTestUsers(int count) {
        return IntStream.range(1, count + 1)
            .mapToObj(i -> new User("用户" + i, "user" + i + "@example.com"))
            .collect(Collectors.toList());
    }
}通过以上内容,我们可以全面了解Spring Boot测试和安全的各个方面,包括单元测试、集成测试、Web层测试、数据访问层测试、Spring Security配置、JWT安全机制以及相关的测试最佳实践。合理的测试策略和安全配置是构建高质量、可维护应用的重要保障。
