在Java后端开发中,Spring Boot已经成为事实上的标准框架。随着微服务架构的普及,一个服务可能依赖数据库、缓存、消息队列以及各种第三方API。如何保证这些组件的正确协作?如何验证业务逻辑在各种边界条件下的表现?这正是测试需要解决的问题。
测试金字塔理论告诉我们,单元测试应该占据最大比重,其次是集成测试,最后才是少量的端到端测试。但在实际开发中,很多团队要么只做单元测试,要么过度依赖集成测试,这两种极端都会带来问题。前者无法验证组件间的集成效果,后者则会导致测试执行缓慢、维护困难。
单元测试(Unit Test)是指对软件中的最小可测试单元进行检查和验证。在面向对象编程中,最小单元通常是一个类或方法。它的核心价值在于:
提示:好的单元测试应该遵循FIRST原则:Fast(快速)、Independent(独立)、Repeatable(可重复)、Self-validating(自验证)、Timely(及时)
Mockito是Java生态中最流行的Mock框架,下面通过一个完整的示例展示其高级用法:
java复制import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("ExternalTokenService单元测试")
class ExternalTokenServiceTest {
@Mock
private RedisTemplate<String, String> redisTemplate;
@Mock
private ExternalApiClient apiClient;
@InjectMocks
private ExternalTokenService tokenService;
@Captor
private ArgumentCaptor<String> keyCaptor;
@Nested
@DisplayName("获取Token场景")
class GetTokenScenarios {
@BeforeEach
void setup() {
when(redisTemplate.get(anyString()))
.thenReturn("cached-token");
}
@Test
@DisplayName("当Redis中有缓存时直接返回")
void shouldReturnCachedToken() {
String result = tokenService.getOrRefreshToken();
assertEquals("cached-token", result);
verify(redisTemplate).get(RedisConstants.TOKEN_KEY);
verifyNoInteractions(apiClient);
}
@Test
@DisplayName("当Redis无缓存时调用外部API")
void shouldCallApiWhenCacheMiss() {
when(redisTemplate.get(RedisConstants.TOKEN_KEY))
.thenReturn(null);
when(apiClient.fetchToken())
.thenReturn("new-token");
String result = tokenService.getOrRefreshToken();
assertEquals("new-token", result);
verify(redisTemplate).set(eq(RedisConstants.TOKEN_KEY),
eq("new-token"),
anyLong(),
any());
}
}
@Test
@DisplayName("验证Redis Key的正确性")
void shouldUseCorrectRedisKey() {
tokenService.getOrRefreshToken();
verify(redisTemplate).get(keyCaptor.capture());
assertEquals(RedisConstants.TOKEN_KEY, keyCaptor.getValue());
}
}
命名规范:
被测类名+Test,如UserServiceTestshould_When或given_when_then格式,如shouldReturnUserWhenIdExists测试结构:
java复制@Test
void shouldReturnOrderWhenPaymentSuccess() {
// Given - 准备测试数据
Order order = new Order(Status.PAID);
when(orderRepo.findById(1L)).thenReturn(Optional.of(order));
// When - 执行被测方法
Order result = orderService.getOrder(1L);
// Then - 验证结果
assertNotNull(result);
assertEquals(Status.PAID, result.getStatus());
}
边界条件覆盖:
null、空集合、空字符串测试隔离:
@BeforeEach而非@BeforeAll初始化测试数据集成测试(Integration Test)验证多个组件协同工作的情况。在Spring Boot中,典型场景包括:
java复制@ActiveProfiles("test")
@SpringBootTest(
classes = Application.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
"spring.datasource.url=jdbc:h2:mem:testdb",
"spring.redis.host=localhost",
"spring.redis.port=6379"
}
)
@AutoConfigureMockMvc
@Transactional
class OrderIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private OrderRepository orderRepo;
@Test
void shouldCreateOrder() throws Exception {
String requestBody = """
{
"productId": 1,
"quantity": 2,
"userId": "user123"
}
""";
mockMvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.orderId").exists());
List<Order> orders = orderRepo.findAll();
assertEquals(1, orders.size());
}
}
application-test.yml配置数据库隔离:
spring.datasource.url=jdbc:h2:mem:testdbjava复制@Testcontainers
class IntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
}
}
外部服务Mock:
java复制@Rule
public WireMockRule wireMockRule = new WireMockRule(8089);
@Test
void shouldCallExternalService() {
stubFor(get(urlEqualTo("/api/resource"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody("{\"status\":\"OK\"}")));
// 测试代码
}
Redis测试配置:
yaml复制# application-test.yml
spring:
redis:
host: localhost
port: 6379
database: 1 # 使用专用测试DB
测试数据管理:
@Sql注解初始化数据:java复制@Test
@Sql("/scripts/init-test-data.sql")
void shouldProcessExistingOrder() {
// 测试代码
}
java复制Order testOrder = Order.builder()
.id(1L)
.status(Status.NEW)
.build();
性能优化:
@DirtiesContext控制应用上下文刷新:java复制@DirtiesContext(classMode = AFTER_CLASS)
class HeavyIntegrationTest {
// 测试代码
}
@SpringBootTest(classes = {DataSourceConfig.class, JpaConfig.class})断言技巧:
java复制assertThat(order)
.hasFieldOrPropertyWithValue("status", Status.COMPLETED)
.hasNoNullFieldsOrProperties();
java复制mockMvc.perform(get("/orders/1"))
.andExpect(jsonPath("$.items.length()").value(3));
| 考虑因素 | 适合单元测试的场景 | 适合集成测试的场景 |
|---|---|---|
| 测试目标 | 验证单个类/方法的逻辑正确性 | 验证多个组件的交互 |
| 执行频率 | 每次代码变更后运行 | 提交前/持续集成时运行 |
| 执行速度 | 毫秒级,适合本地开发 | 秒级,适合CI流水线 |
| 环境依赖 | 无外部依赖,仅需JVM | 需要数据库、网络等外部服务 |
| 维护成本 | 低,仅随业务逻辑变化 | 较高,需随架构调整同步更新 |
| 缺陷定位 | 精确到具体方法 | 需要排查组件间交互问题 |
问题1:单元测试过多Mock导致测试价值降低
解决方案:
java复制// 不Mock内部协作对象
OrderValidator validator = new OrderValidator();
OrderService service = new OrderService(validator); // 注入真实validator
@Test
void shouldRejectInvalidOrder() {
Order order = new Order(null, null);
assertThrows(ValidationException.class,
() -> service.placeOrder(order));
}
问题2:集成测试随机失败
解决方案:
java复制@RetryableIntegrationTest
class FlakyIntegrationTest {
@Test
@Retry(3)
void shouldHandleNetworkIssues() {
// 测试代码
}
}
问题3:测试代码重复率高
解决方案:
java复制public class OrderAssertions {
public static void assertValidOrder(Order order) {
assertNotNull(order.getId());
assertFalse(order.getItems().isEmpty());
// 更多断言...
}
}
java复制public class TestHelper {
public static Order createTestOrder() {
return Order.builder()
.id(1L)
.status(Status.NEW)
.build();
}
}
分支覆盖:
xml复制<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>
</executions>
</plugin>
java复制@Test
void shouldAllowAdminAccess() {
assertTrue(service.hasAccess("admin", Resource.SENSITIVE));
}
@Test
void shouldDenyUserAccess() {
assertFalse(service.hasAccess("user", Resource.SENSITIVE));
}
异常场景覆盖:
java复制@Test
void shouldThrowWhenResourceNotFound() {
when(repository.findById(anyLong()))
.thenReturn(Optional.empty());
assertThrows(ResourceNotFoundException.class,
() -> service.getResource(1L));
}
性能测试集成:
java复制@Test
@Timeout(1) // 1秒超时
void shouldRespondQuickly() {
service.processRequest(createLargeRequest());
}
Testcontainers允许在测试中启动真实的Docker容器,完美解决集成测试的环境问题:
java复制@Testcontainers
class ProductIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Container
static RedisContainer<?> redis = new RedisContainer<>("redis:6.2")
.withExposedPorts(6379);
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.redis.host", redis::getHost);
registry.add("spring.redis.port", () -> redis.getMappedPort(6379));
}
@Test
void shouldSaveAndRetrieveProduct() {
// 测试代码将使用真实的MySQL和Redis
}
}
使用Pact进行消费者驱动的契约测试:
java复制// 消费者端测试
@PactTestFor(providerName = "product-service", port = "8080")
class ProductClientContractTest {
@Pact(consumer = "order-service")
RequestResponsePact productExistsPact(PactDslWithProvider builder) {
return builder
.given("product with id 1 exists")
.uponReceiving("get product by id")
.path("/products/1")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.integerType("id", 1)
.stringType("name", "Test Product"))
.toPact();
}
@Test
@PactTestFor(pactMethod = "productExistsPact")
void shouldParseProduct(MockServer mockServer) {
ProductClient client = new ProductClient(mockServer.getUrl());
Product product = client.getProduct(1);
assertEquals("Test Product", product.getName());
}
}
使用PITest检测测试用例的有效性:
xml复制<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.7.3</version>
<configuration>
<targetClasses>
<param>com.example.service.*</param>
</targetClasses>
<targetTests>
<param>com.example.service.*Test</param>
</targetTests>
</configuration>
</plugin>
执行后生成报告展示被"杀死"的突变体数量,反映测试套件的有效性。
构建器模式创建测试对象:
java复制public class TestOrderBuilder {
private Long id = 1L;
private Status status = Status.NEW;
public TestOrderBuilder withId(Long id) {
this.id = id;
return this;
}
public Order build() {
return new Order(id, status);
}
}
// 使用方式
Order order = new TestOrderBuilder()
.withId(2L)
.build();
自定义参数解析器:
java复制@ExtendWith(MockitoExtension.class)
class ServiceTest {
@RegisterExtension
static final MockitoSessionExtension mockito = MockitoSessionExtension
.builder()
.strictness(Strictness.STRICT_STUBS)
.build();
@Test
void shouldStrictlyVerifyMocks() {
// Mockito会检测不必要的stubbing
}
}
静态分析测试代码:
System.out.println测试代码评审要点:
测试代码指标监控:
在实际项目中,我通常会建立测试代码的CI流水线,包含以下步骤:
这种分层策略既能保证快速反馈,又能确保高质量的测试覆盖。随着项目演进,测试代码也需要定期重构,保持其可维护性和可靠性。