一个YAML定义的集成测试框架

一个YAML定义的集成测试框架

 次点击
31 分钟阅读

1. 背景与目标

本文将介绍一种声明式、数据驱动的API集成测试框架的构建方法。

其核心目标是:将测试执行逻辑(Java代码)与测试用例数据(YAML/JSON配置)分离

实现此目标后,测试人员或开发人员在添加新的测试用例时,应仅需编写易于理解的配置文件,而无需修改或添加Java代码。

2. 框架核心组件

此框架的设计基于以下关键技术的组合:

  1. YAML 场景定义:使用YAML文件作为“测试用例”,以声明方式描述多步骤的测试流程。

  2. JUnit 5 参数化测试:利用 @ParameterizedTest@MethodSource 作为测试“引擎”,动态加载并执行所有YAML中定义的测试场景。

  3. Testcontainers:作为测试环境,为每次测试运行提供隔离且干净的依赖服务,确保测试的可重复性。

  4. 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. 框架的效果与优势

  1. 逻辑与数据分离:测试执行器(Java)保持稳定,而测试用例(YAML/JSON)可以被快速添加、修改或删除。

  2. 降低维护成本:当API的统一响应结构或鉴权方式变更时,仅需修改 ApiIntegrationTest.java 执行器一处,所有测试用例自动适配。

  3. 提升协作效率:QA或不熟悉Java的开发人员也能通过编写YAML文件来定义测试用例,降低了协作门槛。

  4. 健壮的断言:通过 jsonCompareMode: "LENIENT",测试用例可以忽略响应中的动态字段(如时间戳、请求ID),使测试更稳定。

  5. 可靠的执行环境Testcontainers确保每次测试都在一个干净、隔离的数据库环境中运行,避免了数据污染导致的“随机失败”。

© 本文著作权归作者所有,未经许可不得转载使用。