1. 背景与目标
本文将介绍一种声明式、数据驱动的API集成测试框架的构建方法。
其核心目标是:将测试执行逻辑(Java代码)与测试用例数据(YAML/JSON配置)分离。
实现此目标后,测试人员或开发人员在添加新的测试用例时,应仅需编写易于理解的配置文件,而无需修改或添加Java代码。
2. 框架核心组件
此框架的设计基于以下关键技术的组合:
YAML 场景定义:使用YAML文件作为“测试用例”,以声明方式描述多步骤的测试流程。
JUnit 5 参数化测试:利用
@ParameterizedTest和@MethodSource作为测试“引擎”,动态加载并执行所有YAML中定义的测试场景。Testcontainers:作为测试环境,为每次测试运行提供隔离且干净的依赖服务,确保测试的可重复性。
JSONAssert:作为断言提供者,提供灵活的JSON比对逻辑。
3. 实施步骤
步骤 1:规划资源目录结构
在 src/test/resources/ 目录下建立清晰的目录结构,以实现配置与代码的分离。
src/test/resources/
├── test-scenarios/ # 存放所有测试场景定义 (.yaml)
├── requests/ # 存放所有API请求体 (.json)
└── responses/ # 存放所有API期望的响应数据 (.json)
步骤 2:定义声明式测试场景 (YAML)
YAML文件用于定义一个或多个业务相关的测试场景。每个场景由一个或多个按顺序执行的步骤组成。
test-scenarios/product-scenarios.yaml
# 一个文件可包含多个相关场景
- name: "场景1:成功创建并查询产品"
steps:
- description: "步骤1:调用 /add 接口添加新产品"
endpoint: "/add" # 调用的API路径
requestFile: "requests/add-product.json" # 引用的请求体JSON
expectedStatus: 200 # 期望的HTTP状态码
expectedRetCode: 0 # 期望的业务码
- description: "步骤2:调用 /query 接口查询该产品"
endpoint: "/query"
requestFile: "requests/query-product.json"
expectedStatus: 200
expectedRetCode: 0
expectedDataFile: "responses/expect-product.json" # 期望的 data 字段内容
jsonCompareMode: "LENIENT" # 使用宽松模式,忽略 expect-product.json 中未定义的字段
- name: "场景2:查询不存在的产品,返回业务异常"
steps:
- description: "步骤1:查询一个不存在的 SKU"
endpoint: "/query"
requestFile: "requests/query-non-existent.json"
expectedStatus: 200
expectedRetCode: 4004 # 期望的业务错误码
expectedDescriptionRegex: "^SKU .* 不存在$" # 对 description 字段进行正则校验
步骤 3:创建 POJO 数据模型
创建Java对象以映射YAML文件的结构,Jackson库将自动完成YAML到POJO的转换。
TestCase.java
import lombok.Data;
import java.util.List;
@Data
public class TestCase {
private String name;
private List<TestStep> steps;
@Override
public String toString() { return name; } // 用于Junit报告显示
}
TestStep.java
import lombok.Data;
@Data
public class TestStep {
private String description;
private String endpoint;
private String requestFile;
private int expectedStatus;
// 所有校验字段均为可选(使用包装类以便Jackson在缺失时设为null)
private String expectedResponseFile;
private String expectedDataFile;
private Integer expectedRetCode;
private String expectedDescriptionContains;
private String expectedDescriptionRegex;
private String jsonCompareMode = "STRICT"; // 默认严格比对
}
Response.java
import lombok.Data;
import java.util.List;
@Data
public class Response {
private int retCode;
private String description;
private List<?> data;
}
步骤 4:实现测试执行器 (Java)
这是框架的核心Java代码,它是一个通用执行器,负责加载、解析并执行所有YAML文件中定义的测试。
ApiIntegrationTest.java
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.skyscreamer.jsonassert.JSONAssert;
import org.skyscreamer.jsonassert.JSONCompareMode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ApiIntegrationTest {
@Autowired private TestRestTemplate restTemplate;
@Autowired private ObjectMapper objectMapper;
// --- 1. Testcontainers 环境沙箱 ---
@Container
static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
// ... 其他数据源配置
}
// --- 2. YAML 加载器 (MethodSource) ---
static Stream<TestCase> scenarioProvider() throws Exception {
ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource[] resources = resolver.getResources("classpath:test-scenarios/*.yaml");
List<TestCase> allTestCases = new ArrayList<>();
for (Resource resource : resources) {
try (InputStream inputStream = resource.getInputStream()) {
allTestCases.addAll(mapper.readValue(inputStream, new TypeReference<List<TestCase>>() {}));
}
}
return allTestCases.stream();
}
// --- 3. 测试执行器 (ParameterizedTest) ---
@DisplayName("执行声明式API测试")
@ParameterizedTest(name = "{index} => {0}") // {0} 会调用 TestCase.toString()
@MethodSource("scenarioProvider")
void executeApiScenario(TestCase testCase) throws Exception {
for (TestStep step : testCase.getSteps()) {
String validationContext = "场景 '" + testCase.getName() + "' 步骤 '" + step.getDescription() + "' 的";
// 1. 准备请求
String requestBody = readResourceFile(step.getRequestFile());
RequestEntity<String> requestEntity = RequestEntity.post(new URI(step.getEndpoint()))
.header("Content-Type", "application/json").body(requestBody);
// 2. 发送请求 (获取原始字符串响应)
ResponseEntity<String> responseEntity = restTemplate.exchange(requestEntity, String.class);
String rawResponseBody = responseEntity.getBody();
// 3. 基础断言
assertEquals(step.getExpectedStatus(), responseEntity.getStatusCode().value(), validationContext + "HTTP状态码不匹配");
assertNotNull(rawResponseBody, validationContext + "响应体为null");
// 4. 可选:全量响应体校验 (expectedResponseFile)
if (step.getExpectedResponseFile() != null) {
String expectedFullResponse = readResourceFile(step.getExpectedResponseFile());
JSONAssert.assertEquals(validationContext + "完整响应体校验失败",
expectedFullResponse, rawResponseBody, getCompareMode(step));
}
// 5. 可选:精准字段校验 (仅在需要时反序列化)
Response responseBody = null;
if (step.getExpectedRetCode() != null || step.getExpectedDataFile() != null ||
step.getExpectedDescriptionContains() != null || step.getExpectedDescriptionRegex() != null) {
responseBody = objectMapper.readValue(rawResponseBody, .class);
}
if (responseBody != null) {
if (step.getExpectedRetCode() != null) {
assertEquals(step.getExpectedRetCode(), responseBody.getRetCode(), validationContext + "'retCode' 校验失败");
}
if (step.getExpectedDescriptionContains() != null) {
assertTrue(responseBody.getDescription().contains(step.getExpectedDescriptionContains()), validationContext + "'description' 未包含期望内容");
}
if (step.getExpectedDescriptionRegex() != null) {
assertTrue(Pattern.matches(step.getExpectedDescriptionRegex(), responseBody.getDescription()), validationContext + "'description' 未匹配正则表达式");
}
if (step.getExpectedDataFile() != null) {
String expectedDataJson = readResourceFile(step.getExpectedDataFile());
String actualDataJson = objectMapper.writeValueAsString(responseBody.getData());
JSONAssert.assertEquals(validationContext + "业务数据 'data' 校验失败",
expectedDataJson, actualDataJson, getCompareMode(step));
}
}
}
}
// --- 辅助方法 ---
private JSONCompareMode getCompareMode(TestStep step) {
return "LENIENT".equalsIgnoreCase(step.getJsonCompareMode()) ? JSONCompareMode.LENIENT : JSONCompareMode.STRICT;
}
private String readResourceFile(String filePath) throws Exception {
if (filePath == null || filePath.isBlank()) return "{}";
Resource resource = new PathMatchingResourcePatternResolver().getResource("classpath:" + filePath);
return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
}
}
步骤 5:配置项目依赖 (pom.xml)
确保 pom.xml 中包含了必要的测试依赖。
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId> <scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.skyscreamer</groupId>
<artifactId>jsonassert</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
4. 框架的效果与优势
逻辑与数据分离:测试执行器(Java)保持稳定,而测试用例(YAML/JSON)可以被快速添加、修改或删除。
降低维护成本:当API的统一响应结构或鉴权方式变更时,仅需修改
ApiIntegrationTest.java执行器一处,所有测试用例自动适配。提升协作效率:QA或不熟悉Java的开发人员也能通过编写YAML文件来定义测试用例,降低了协作门槛。
健壮的断言:通过
jsonCompareMode: "LENIENT",测试用例可以忽略响应中的动态字段(如时间戳、请求ID),使测试更稳定。可靠的执行环境:
Testcontainers确保每次测试都在一个干净、隔离的数据库环境中运行,避免了数据污染导致的“随机失败”。