Java 程序员的 AI 进化论 | AI 加 Postman 跑接口测试,省了三天活

Java 程序员的 AI 进化论 | AI 加 Postman 跑接口测试,省了三天活

上周五发版,一个支付接口的边界条件没测到------金额传 0 的时候,系统直接抛了空指针。客户那边投诉电话打过来的时候,我正在工位上改 bug,脸上火辣辣的。

复盘的时候翻了翻测试用例文档,这个接口一共写了 12 条用例,但金额的边界值只测了正常范围。负数、零、超大数,一个没覆盖。

说实话,也不能全怪测试同学。我们项目 200 多个接口,全靠手写 Postman 用例,一轮回归测试要跑三天。人都跑麻了,漏几个边界条件太正常了。

那天晚上我加班到十一点,把 AI 生成测试用例的方案跑通了。用 AI 扫 Swagger 文档自动生成用例,Newman 跑批量执行,再让 AI 分析失败报告。一套流程下来,200 个接口的回归测试从三天压到了四小时。

今天把这套方案拆开讲讲,代码直接能跑。

一、问题背景

先说说我遇到的具体情况。我们是一个 Spring Boot 3 微服务项目,200+ 个 RESTful 接口,团队 6 个人。每次发版前要做一轮全量回归测试,流程是这样的:

测试同学拿到接口文档 → 在 Postman 里手动创建 Collection → 逐个接口填参数 → 跑一遍 → 检查返回值 → 截图贴到测试报告。

指标 人工方式 痛点
用例编写 每个接口 5-8 条,手写 200 接口 × 6 条 = 1200 条,写一周
回归执行 Postman Runner 串行跑 3 天,经常加班
边界覆盖 靠经验,漏测率高 发版后 bug 率 15%
报告生成 手动截图 + 填表格 每次 2 小时

最要命的是边界覆盖。人工写用例的时候,正常的增删改查大家都会测,但那些边界值------空字符串、null、超长字符串、负数、特殊字符------全凭个人经验,谁也保证不了。

我查了下最近三个月的生产 bug,有 40% 都是边界条件没覆盖到。这个比例太高了。

二、方案设计

我想的方案是三步走:AI 生成用例 → Newman 批量执行 → AI 分析报告。

2.1 整体架构

整个流程串起来是这样的:

Swagger JSON 文档 → Java 程序解析接口定义 → 调 AI 接口生成测试用例(含正常 + 边界 + 异常) → 输出 Postman Collection JSON → Newman 命令行批量执行 → 收集测试结果 → AI 分析失败原因 → 生成可读报告。

不用搞什么花哨的架构,核心就是 AI 帮你写用例、Newman 帮你跑用例、AI 再帮你分析结果。三个环节各司其职。

2.2 技术选型

组件 选型 理由
接口文档源 Swagger / OpenAPI 3.0 项目已有,零成本接入
用例生成 OpenAI API + 自定义 Prompt 能根据字段类型自动推断边界值
批量执行 Newman 6.x Postman 官方 CLI,支持断言和环境变量
结果分析 OpenAI API + 结构化 Prompt 自动归类失败原因
调度 Spring Boot 定时任务 每天凌晨自动跑一轮

Newman 比 Postman Runner 快得多,因为 Newman 可以多线程并发跑,而 Runner 默认是串行的。200 个接口 1200 条用例,Newman 并发 10 跑完只要 15 分钟。

三、代码实现

3.1 依赖配置

依赖 groupId artifactId 版本 作用
Spring Boot Web org.springframework.boot spring-boot-starter-web 3.2.x Web 框架
OkHttp com.squareup.okhttp3 okhttp 4.12.0 调 AI API
Jackson com.fasterxml.jackson.core jackson-databind 2.16.x JSON 解析
Newman npm 全局安装 newman 6.x 命令行跑 Postman 用例

Newman 是 Node.js 工具,用 npm install -g newman 安装就行。Java 这边通过 ProcessBuilder 调用。

3.2 Swagger 文档解析

先把 Swagger 文档解析成结构化的接口列表,后面生成用例要用。

java 复制代码
package com.example.apitest.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

@Service
public class SwaggerParserService {

    private final ObjectMapper mapper = new ObjectMapper();

    /**
     * 从 Swagger URL 拉取文档,解析出所有接口定义
     */
    public List<ApiEndpoint> parse(String swaggerUrl) throws Exception {
        // 拉取 Swagger JSON
        HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(10))
                .build();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(swaggerUrl))
                .timeout(Duration.ofSeconds(30))
                .GET()
                .build();
        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

        JsonNode root = mapper.readTree(response.body());
        JsonNode paths = root.path("paths");

        List<ApiEndpoint> endpoints = new ArrayList<>();
        // 遍历每个路径和 HTTP 方法
        paths.fields().forEachRemaining(pathEntry -> {
            String path = pathEntry.getKey();
            pathEntry.getValue().fields().forEachRemaining(methodEntry -> {
                String method = methodEntry.getKey().toUpperCase();
                JsonNode operation = methodEntry.getValue();
                String operationId = operation.path("operationId").asText(path);
                String summary = operation.path("summary").asText("");

                // 解析参数列表
                List<ApiParam> params = parseParams(operation.path("parameters"));
                // 解析请求体
                ApiBody body = parseRequestBody(operation.path("requestBody"));

                endpoints.add(new ApiEndpoint(method, path, operationId, summary, params, body));
            });
        });
        return endpoints;
    }

    private List<ApiParam> parseParams(JsonNode parameters) {
        List<ApiParam> params = new ArrayList<>();
        if (parameters.isMissingNode()) return params;
        for (JsonNode param : parameters) {
            String name = param.path("name").asText();
            String in = param.path("in").asText("query");
            String type = param.path("schema").path("type").asText("string");
            boolean required = param.path("required").asBoolean(false);
            params.add(new ApiParam(name, in, type, required));
        }
        return params;
    }

    private ApiBody parseRequestBody(JsonNode requestBody) {
        if (requestBody.isMissingNode()) return null;
        JsonNode schema = requestBody.path("content")
                .path("application/json")
                .path("schema");
        if (schema.isMissingNode()) return null;
        // 简化处理:只取 properties 的字段名和类型
        JsonNode props = schema.path("properties");
        List<ApiParam> fields = new ArrayList<>();
        props.fields().forEachRemaining(entry -> {
            String name = entry.getKey();
            String type = entry.getValue().path("type").asText("string");
            fields.add(new ApiParam(name, "body", type, false));
        });
        return new ApiBody(fields);
    }

    // 接口定义数据类
    public record ApiEndpoint(String method, String path, String operationId,
                               String summary, List<ApiParam> params, ApiBody body) {}

    public record ApiParam(String name, String location, String type, boolean required) {}

    public record ApiBody(List<ApiParam> fields) {}
}

这段代码做的事情很简单:拉 Swagger JSON,把每个接口的路径、方法、参数、请求体都解析出来,装成结构化对象。后面 AI 生成用例就靠这些信息。

3.3 AI 生成测试用例

解析完接口定义,接下来调 AI 接口生成测试用例。核心是 Prompt 的设计。

java 复制代码
package com.example.apitest.service;

import com.example.apitest.dto.ApiTestCase;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.*;

@Service
public class TestCaseGeneratorService {

    @Value("${ai.api.url}")
    private String apiUrl;
    @Value("${ai.api.key}")
    private String apiKey;
    @Value("${ai.model}")
    private String model;

    private final ObjectMapper mapper = new ObjectMapper();
    private final OkHttpClient httpClient = new OkHttpClient.Builder()
            .connectTimeout(java.time.Duration.ofSeconds(10))
            .readTimeout(java.time.Duration.ofSeconds(60))
            .build();

    /**
     * 根据接口定义,让 AI 生成测试用例
     * 每个接口生成:正常用例 + 边界用例 + 异常用例
     */
    public List<ApiTestCase> generate(SwaggerParserService.ApiEndpoint endpoint) throws Exception {
        String prompt = buildPrompt(endpoint);

        // 构造 AI 请求
        Map<String, Object> requestBody = new HashMap<>();
        requestBody.put("model", model);
        requestBody.put("messages", List.of(
                Map.of("role", "system", "content", "你是 API 测试专家,生成结构化测试用例。返回 JSON 数组格式。"),
                Map.of("role", "user", "content", prompt)
        ));
        requestBody.put("temperature", 0.3); // 低温度保证输出稳定

        Request request = new Request.Builder()
                .url(apiUrl)
                .header("Authorization", "Bearer " + apiKey)
                .header("Content-Type", "application/json")
                .post(RequestBody.create(
                        mapper.writeValueAsString(requestBody),
                        MediaType.parse("application/json")))
                .build();

        try (Response response = httpClient.newCall(request).execute()) {
            if (!response.isSuccessful()) {
                throw new RuntimeException("AI API 调用失败: " + response.code());
            }
            String body = response.body().string();
            // 解析 AI 返回的 JSON 数组
            return parseTestCases(body, endpoint);
        }
    }

    /**
     * 构造 Prompt:告诉 AI 接口信息,让它生成三类用例
     */
    private String buildPrompt(SwaggerParserService.ApiEndpoint ep) {
        StringBuilder sb = new StringBuilder();
        sb.append("接口信息:\n");
        sb.append(String.format("- 方法:%s\n- 路径:%s\n- 描述:%s\n", ep.method(), ep.path(), ep.summary()));

        if (ep.params() != null && !ep.params().isEmpty()) {
            sb.append("参数列表:\n");
            for (SwaggerParserService.ApiParam p : ep.params()) {
                sb.append(String.format("  - %s(位置:%s,类型:%s,必填:%s)\n",
                        p.name(), p.location(), p.type(), p.required()));
            }
        }

        if (ep.body() != null && ep.body().fields() != null) {
            sb.append("请求体字段:\n");
            for (SwaggerParserService.ApiParam f : ep.body().fields()) {
                sb.append(String.format("  - %s(类型:%s)\n", f.name(), f.type()));
            }
        }

        sb.append("""
            请生成 8-12 条测试用例,包含三类:
            1. 正常用例(3-4条):合法参数,验证正常流程
            2. 边界用例(3-4条):空值、null、超长字符串、负数、零值、最大值
            3. 异常用例(2-4条):缺必填字段、类型错误、特殊字符注入

            返回 JSON 数组,每条用例格式:
            {"name":"用例名称","category":"normal/boundary/exception","requestBody":{参数键值对},"expectedStatus":200或400或422}
            只返回 JSON 数组,不要其他文字。""");

        return sb.toString();
    }

    /**
     * 解析 AI 返回的内容,提取 JSON 数组
     */
    private List<ApiTestCase> parseTestCases(String aiResponse, SwaggerParserService.ApiEndpoint ep) throws Exception {
        // AI 可能在 JSON 外面包了 markdown 代码块,需要提取
        String json = extractJson(aiResponse);
        List<ApiTestCase> cases = new ArrayList<>();
        var nodes = mapper.readTree(json);
        for (var node : nodes) {
            ApiTestCase testCase = new ApiTestCase(
                    node.path("name").asText(),
                    node.path("category").asText("normal"),
                    ep.method(),
                    ep.path(),
                    node.path("requestBody").toString(),
                    node.path("expectedStatus").asInt(200)
            );
            cases.add(testCase);
        }
        return cases;
    }

    /** 从可能包含 markdown 的文本中提取 JSON 数组 */
    private String extractJson(String text) {
        int start = text.indexOf('[');
        int end = text.lastIndexOf(']');
        if (start >= 0 && end > start) {
            return text.substring(start, end + 1);
        }
        return text;
    }
}

这段代码的关键在 buildPrompt 方法。我把接口的方法、路径、参数类型、必填属性都喂给 AI,让它根据字段类型自动推断边界值。比如 type: integer 的字段,AI 会自动生成 0、-1、999999999 这些边界值。type: string 的会生成空串、超长串、SQL 注入字符。

这里有个踩坑点我必须说------temperature 一定要设低。我一开始用默认的 0.7,AI 返回的用例格式五花八门,有的用 markdown 包裹,有的多了说明文字,解析经常失败。设成 0.3 之后,格式稳定性从 60% 提到 95% 以上。

3.4 Newman 批量执行

AI 生成的用例需要转成 Postman Collection 格式,然后用 Newman 跑。

java 复制代码
package com.example.apitest.service;

import com.example.apitest.dto.ApiTestCase;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.util.*;

@Service
public class NewmanExecutionService {

    private final ObjectMapper mapper = new ObjectMapper();

    /**
     * 把测试用例转成 Postman Collection JSON
     */
    public File buildCollection(List<ApiTestCase> testCases, String baseUrl) throws IOException {
        ObjectNode collection = mapper.createObjectNode();
        collection.put("info", mapper.createObjectNode()
                .put("name", "AI Generated Tests")
                .put("schema", "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"));

        ArrayNode items = collection.putArray("item");
        // 按接口路径分组
        Map<String, List<ApiTestCase>> grouped = new HashMap<>();
        for (ApiTestCase tc : testCases) {
            grouped.computeIfAbsent(tc.path(), k -> new ArrayList<>()).add(tc);
        }

        for (var entry : grouped.entrySet()) {
            ObjectNode folder = items.addObject();
            folder.put("name", entry.getKey());
            ArrayNode folderItems = folder.putArray("item");
            for (ApiTestCase tc : entry.getValue()) {
                folderItems.add(buildItem(tc, baseUrl));
            }
        }

        File file = new File("target/ai-test-collection.json");
        mapper.writerWithDefaultPrettyPrinter().writeValue(file, collection);
        return file;
    }

    /**
     * 单条用例转 Postman item
     */
    private ObjectNode buildItem(ApiTestCase tc, String baseUrl) {
        ObjectNode item = mapper.createObjectNode();
        item.put("name", tc.name());

        ObjectNode request = item.putObject("request");
        request.put("method", tc.method());
        ObjectNode url = request.putObject("url");
        url.put("raw", baseUrl + tc.path());
        url.put("host", baseUrl.replace("https://", "").replace("http://", ""));

        // 如果有请求体,加 body
        if (tc.requestBody() != null && !tc.requestBody().equals("{}")) {
            ObjectNode body = request.putObject("body");
            body.put("mode", "raw");
            body.put("raw", tc.requestBody());
            body.putObject("options").putObject("raw").put("language", "json");
        }

        // 加断言:检查状态码
        ArrayNode events = item.putArray("event");
        ObjectNode testEvent = events.addObject();
        testEvent.put("listen", "test");
        ObjectNode script = testEvent.putObject("script");
        ArrayNode exec = script.putArray("exec");
        exec.add("pm.test('状态码匹配', function () {");
        exec.add("  pm.response.to.have.status(" + tc.expectedStatus() + ");");
        exec.add("});");
        return item;
    }

    /**
     * 调用 Newman 执行测试,返回结果文件路径
     */
    public NewmanResult execute(File collectionFile, String environment, int concurrency) throws Exception {
        File resultFile = new File("target/newman-result.json");

        // 构造 Newman 命令
        List<String> command = new ArrayList<>();
        command.add("newman");
        command.add("run");
        command.add(collectionFile.getAbsolutePath());
        command.add("--reporters=json");
        command.add("--reporter-json-export=" + resultFile.getAbsolutePath());
        if (environment != null) {
            command.add("--environment=" + environment);
        }
        // 并发执行:每个文件夹独立跑,互不阻塞
        command.add("--folder=" + collectionFile.getName());

        ProcessBuilder pb = new ProcessBuilder(command);
        pb.redirectErrorStream(true);
        Process process = pb.start();

        // 读输出(Newman 的日志,不影响结果文件)
        String output = new String(process.getInputStream().readAllBytes());
        int exitCode = process.waitFor();

        // 解析结果文件
        return parseResult(resultFile, exitCode);
    }

    /**
     * 解析 Newman 输出的 JSON 结果
     */
    private NewmanResult parseResult(File resultFile, int exitCode) throws Exception {
        var root = mapper.readTree(resultFile);
        var run = root.path("run");
        var stats = run.path("stats");

        int total = stats.path("iterations").path("total").asInt();
        int failed = stats.path("iterations").path("failed").asInt();
        int passed = total - failed;

        return new NewmanResult(total, passed, failed, exitCode == 0, resultFile);
    }

    public record NewmanResult(int total, int passed, int failed, boolean success, File resultFile) {}
}

并发这块我踩了个大坑。Newman 本身不支持 --parallel 参数,我一开始以为是 Newman 的问题,后来发现得在 Java 侧用线程池来调多个 Newman 进程,每个进程跑一部分接口。或者更简单的办法------把 Collection 拆成多个文件,用 ProcessBuilder 并行启动多个 Newman 进程。

我实际跑下来的数据对比:

方式 用例数 耗时 失败检出率
人工 Postman Runner 1200 条 3 天 60%
Newman 串行 1200 条 45 分钟 75%
Newman 并发 5 进程 1200 条 12 分钟 78%
AI 生成 + Newman 并发 2400 条 18 分钟 92%

AI 生成的用例数翻倍了(每个接口从 6 条增到 12 条),但总耗时只多了 6 分钟。关键是检出率从 60% 飙到 92%------之前漏掉的那些边界值 bug,大部分都被 AI 生成的用例覆盖了。

四、踩坑记录

4.1 AI 生成的用例不是都能用

AI 生成的用例大概有 10% 是废的。比如有个分页参数 page=1&size=20,AI 生成了 page=-1 的边界用例,预期返回 400。但我们的代码没做这个校验,实际返回了 200 和一堆空数据。这反而帮我们发现了一个代码 bug------但用例本身的断言写错了。

我的做法是加了一层校验:AI 生成完用例后,先跑一遍基线测试(只跑正常用例),把实际返回的状态码反推给 AI,让它修正异常用例的 expectedStatus。这一步能把废用例从 10% 降到 3% 左右。

4.2 Newman 并发跑出数据污染

这个坑折腾了我半天。有两个接口------「创建订单」和「查询订单列表」------并发跑的时候,创建订单还没写完数据库,查询接口就去读,读到的是空列表,断言失败。但代码本身没问题,纯粹是执行顺序的问题。

问题 原因 解决方案
并发数据污染 有依赖关系的接口同时执行 按依赖关系分组,组内串行
Newman 进程卡死 个别接口超时无返回 --timeout-request 30000
JSON 结果解析失败 Newman 版本 5.x 格式不一致 升级到 6.x,格式统一

解决方案是给接口标依赖关系。创建类接口(POST)先跑,查询类接口(GET)后跑。我在 Swagger 文档里加了一个自定义字段 x-test-order,数字小的先跑。简单粗暴,但有效。

4.3 AI 分析报告的幻觉问题

剩收尾这步------让 AI 分析失败用例,生成可读报告。这里 AI 偶尔会"幻觉"------明明是断言状态码不匹配(期望 400 实际 200),AI 分析说是"网络超时导致请求失败"。完全不着边际。

我的解决办法是给 AI 喂结构化的失败信息,而不是原始日志:

java 复制代码
/**
 * 构造失败用例分析 Prompt
 * 关键:只给结构化数据,不给原始 Newman 日志
 */
private String buildAnalysisPrompt(List<FailedCase> failures) {
    StringBuilder sb = new StringBuilder();
    sb.append("以下是 API 测试失败用例的结构化信息:\n\n");
    for (FailedCase fc : failures) {
        sb.append(String.format("""
            用例名称:%s
            接口:%s %s
            请求参数:%s
            期望状态码:%d
            实际状态码:%d
            实际响应体:%s
            ---""", fc.name(), fc.method(), fc.path(),
                fc.requestBody(), fc.expectedStatus(),
                fc.actualStatus(), fc.responseBody()));
    }
    sb.append("\n请逐条分析失败原因,分类为以下几种:");
    sb.append("\n1. 断言预期错误(测试用例的 expectedStatus 写错了)");
    sb.append("\n2. 接口 bug(接口行为不符合预期)");
    sb.append("\n3. 数据依赖问题(前置数据未准备好)");
    sb.append("\n4. 环境问题(服务不可达、超时等)");
    sb.append("\n返回 JSON 数组,每条包含:caseName, category, analysis, suggestion");
    return sb.toString();
}

喂结构化数据之后,AI 的分析准确率从 70% 提到了 90% 以上。剩下那 10% 的误判,我加了一个「人工复核」标记,让测试同学看一眼就行,不用每条都看。

我的观点:AI 分析测试报告这步,定位是"帮人快速筛选",不是"替人做判断"。把 AI 当成一个初级测试工程师------它能帮你把 1200 条结果里最可能出问题的 50 条挑出来,但最终判断还是得人来看。想让它完全替代人,至少现阶段不现实。

五、效果总结

这套方案我跑了两周,发版回归测试的时间从 3 天降到 4 小时,发版后的 bug 率从 15% 降到 4%。团队里另外两个同学也开始用了。

指标 改造前 改造后 变化
回归测试耗时 3 天 4 小时 -83%
用例总数 1200 条 2400 条 +100%
边界覆盖 40% 85% +112%
发版后 bug 15% 4% -73%
测试报告 手写 2 小时 自动生成 -100%

收个尾,给一张落地检查清单,想试这套方案的可以直接照着来:

检查项 建议
Swagger 文档是否完整 所有接口必须有 operationId 和参数描述,否则 AI 生成的用例质量差
AI 模型选择 用 GPT-4o-mini 就够了,不用上大模型,省钱
temperature 设置 0.3 以下,保证输出格式稳定
Newman 并发数 3-5 个进程,太多会打爆数据库连接池
依赖接口排序 POST 接口先跑,GET 后跑,避免数据污染
失败用例复核 AI 标记的「接口 bug」类必须人工确认,别直接提单
定时执行 每天凌晨跑一轮,白天看报告,别占用工作时间
AI 分析 Prompt 只喂结构化数据,别喂原始日志,否则幻觉率很高

这套方案不复杂,核心就是把 AI 当用例生成器、把 Newman 当执行引擎。但要注意------AI 生成的用例不是万能的,得有人复核。工具是帮你提效的,不是替你思考的。

相关推荐
怕浪猫1 小时前
第8章 前端交互与可视化:构建Agent的用户界面
aigc·openai·ai编程
旺仔学长 哈哈2 小时前
基于SpringBoot的在线招聘测评系统的设计与实现----附源码35253+数据库文档
数据库·spring boot·后端·在线招聘
阿米亚波2 小时前
【C++ STL】std::array
java·开发语言·c++
奶糖 肥晨2 小时前
mac系统中Java项目环境配置与问题解决全记录| 项目构建篇:Maven编译与打包
java·macos·maven
Ivanqhz2 小时前
Rust #[derive(Serialize)]浅析
开发语言·后端·rust
卷无止境2 小时前
Python的魔术方法:那些藏在双下划线背后的魔法
后端·python
万亿少女的梦1682 小时前
基于Spring Boot的乐助在线助农系统设计与实现
java·spring boot·mysql·敏感词过滤·助农系统
Ivanqhz2 小时前
Rust parse() 浅析
开发语言·后端·rust
卷无止境2 小时前
别再被"乱码"吓到了:Python文件操作的门道
后端·python