之前在做自动化测试平台的过程中,最让我头疼的往往不是用例怎么写、调度怎么配,而是“测试结果散落各处”。每条用例跑完的数据在数据库里、日志里、报表插件里各存一份,想回答“最近一周质量是变好了还是变差了”这种基础问题,都要花不少时间到处找数据。后来我们专门做了一个独立的报告中心,把测试数据、报表展示、质量分析和 AI 辅助总结统一收敛到一个模块,整个平台才真正“闭环”起来。
本文将围绕AI 智能测试平台报告中心的完整实现展开,从需求背景、表结构设计到 Spring Boot 后端接口、AI 摘要接入,逐步带大家搭建一个可运行、可扩展的报告中心模块。无论是刚接触测试平台开发的初学者,还是正在做平台后端设计的朋友,都能从中找到可以直接落地的思路和代码。
1. 为什么测试平台需要一个独立的报告中心
1.1 测试平台发展到一定阶段后的痛点
很多团队在测试平台建设初期,只关心用例管理、定时任务和执行引擎,测试报告往往靠开源工具自带的 HTML 页面展示。这种方式在小规模场景下够用,但随着用例数量增多、团队协作变复杂,会出现几个明显问题:
- 报告分散在各个执行节点,历史结果不方便统一查询。
- 不同测试框架(JUnit、pytest、JMeter 等)输出格式不同,无法做横向对比。
- 执行记录虽然很多,但缺少通过率趋势、失败原因分布等统计分析。
- 质量数据没有被有效利用,报告停留在“看一次”的层面,无法辅助决策。
报告中心的出现,就是为了解决这些问题。它可以看作一个独立的领域服务,负责接收来自不同执行引擎的原始测试数据,经过标准化处理后统一存储,再通过接口对外提供查询、统计和展示能力。
1.2 报告中心在平台中的定位
报告中心不直接负责执行测试用例,它只关心“测试执行完之后的数据去往哪里,以及如何被使用”。在一次完整的测试流程中,报告中心通常处于执行引擎和展示端之间:
| 环节 | 产生或消费的数据 | 是否依赖报告中心 |
|---|---|---|
| 测试执行 | 用例结果、日志、耗时 | 不依赖 |
| 结果回传 | 标准化的测试结果 JSON | 依赖 |
| 数据入库 | 报告主表、明细表、趋势表 | 核心场景 |
| 统计查询 | 通过率、失败分布、耗时趋势 | 核心场景 |
| AI 总结 | 自动生成质量分析和改进建议 | 扩展场景 |
从架构上理解,报告中心做的事情本质上是对“测试结果数据”的采集、清洗、存储、计算和展示。它把测试平台从“能跑用例”升级为“能看质量”。
1.3 AI 能为报告中心带来什么
传统报告中心做的是“数据可视化”,比如展示通过率、失败用例列表、耗时曲线,这些都是历史结论的描述。但很多情况下,测试人员还需要从一堆数据和日志中提炼“为什么失败”“哪些模块风险高”“下一步应该关注什么”。
AI 大模型的出现,让报告中心多了一个新身份:质量分析助手。我们可以把结构化报告数据、失败日志摘要、可能相关的代码变更信息等,拼接成提示词交给大模型,由它生成自然语言的分析结论。相比纯看图表,这种形式能明显提升报告解读效率。
当然,AI 总结并不是必须所有报告中都生成。考虑到成本,一般建议只对失败率高于阈值的重要报告启用 AI 分析,或者由用户主动点击触发。
2. 报告中心的技术选型与整体设计
2.1 技术栈选型
报告中心作为一个后端服务模块,技术选型不需要太复杂。本文以 Java Spring Boot 为例,原因是团队内部使用 Java 技术栈较多,而且 Spring Boot 与 MySQL、Redis、消息队列等基础设施的集成成本低,适合做平台类项目。
- 后端框架:Spring Boot
- 数据库:MySQL 8.x
- 持久层:Spring Data JPA 或 MyBatis
- 接口风格:RESTful API
- AI 接入:通过 HttpClient 调用大模型 API,不绑定具体厂商
- 前端展示:Vue 或 React 均可,本文重点讲后端
这里需要说明,版本号应结合项目实际情况调整,不同 Spring Boot 版本在 starter 引入和配置上存在差异。本文以常见版本为例,重点演示完整的设计思路和核心代码。
2.2 功能模块划分
报告中心通常包含几个核心模块:
- 数据采集模块:定义统一接收接口,接收执行引擎推送的测试结果,做格式校验和转换。
- 存储模块:将标准化后的报告数据写入数据库,包括报告主表、执行明细、失败详情等。
- 查询模块:提供报告列表、详情、统计趋势等查询接口。
- AI 分析模块:将报告数据整理成提示词,调用大模型生成总结和风险建议。
- 展示模块:前端页面,展示汇总卡片、图表和 AI 分析结果。
如果项目已经采用了消息队列,数据采集模块可以改成监听消息,由执行引擎发送 MQ 消息,报告中心异步消费。这种方案更利于削峰,但对于中小型平台,直接 HTTP 推送反而更简单。
2.3 报告中心的数据流
我们可以用文字把这条链路描述清楚:
执行引擎在测试跑完后,将用例结果整理成约定好的 JSON 格式,调用报告中心的/api/report/push接口。报告中心对 JSON 做校验,通过后写入 test_report 主表和 test_report_case 明细表,同时更新当天的统计记录。前端在展示页根据条件查询统计接口,用户点击“AI 分析”时,后端从数据库读取报告摘要,拼接提示词调用大模型,返回分析结果。
这里需要注意,执行引擎和报告中心的接口协议一定要先约定清楚,否则后续接入新框架时会反复改代码。
3. 项目初始化与核心依赖
3.1 项目结构建议
为了便于后续维护,建议按模块分包,而不是把所有类堆在同一个包下。
report-center ├── pom.xml └── src/main/java/com/example/report ├── ReportCenterApplication.java ├── controller │ └── ReportController.java ├── service │ ├── ReportService.java │ ├── ReportSummaryService.java │ └── AiAnalyzeService.java ├── repository │ ├── ReportRepository.java │ └── ReportCaseRepository.java ├── entity │ ├── TestReportEntity.java │ └── TestReportCaseEntity.java ├── dto │ ├── ReportPushRequest.java │ ├── ReportQueryRequest.java │ └── ReportSummaryVO.java └── common └── Result.java这种分包方式比较常见,Controller 层负责参数接收和响应包装,Service 层处理业务逻辑,Repository 层操作数据库,Entity 对应表结构,DTO 负责接口入参和出参。
3.2 Maven 依赖
在 pom.xml 中引入必要的依赖。核心依赖包括 Web、数据校验、MySQL 驱动和 Spring Data JPA。如果项目还没引入 Redis,可以先不加,避免依赖过多干扰理解。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson2</artifactId> <version>2.0.25</version> </dependency> </dependencies>说明一下,httpclient用于后续调用大模型 API,fastjson2用于处理 JSON 序列化和解析。如果不希望引入 fastjson,也可以用 Spring 自带的 Jackson,只是代码写法上略有不同。
3.3 配置文件
在 application.yml 中配置数据源和 JPA 相关参数。这里要注意,数据库连接信息不要写死在代码里,建议通过环境变量或配置中心注入。
spring: datasource: url: jdbc:mysql://localhost:3306/report_center?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: ${MYSQL_PASSWORD:root} driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true server: port: 8080 report: ai: # 接入大模型的 HTTP 接口地址,具体以实际部署为准 endpoint: ${AI_ENDPOINT:} api-key: ${AI_API_KEY:} max-example-length: 500 enable: true密码部分使用了${MYSQL_PASSWORD:root}这种默认值写法,既能在本地快速启动,又不会把生产密码暴露在配置文件里。AI 相关的 endpoint 和 api-key 同理,优先从环境变量读取。
4. 数据库设计与实体类
4.1 表结构设计
报告中心至少要设计两张核心表:报告主表和用例明细表。主表保存一次测试执行的汇总信息,明细表保存具体的用例执行结果。
报告主表:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| report_no | varchar(64) | 报告编号,业务唯一 |
| project_name | varchar(128) | 项目名称 |
| suite_name | varchar(128) | 测试套件名称 |
| trigger_type | varchar(32) | 触发方式,手动/定时/流水线 |
| total_case | int | 用例总数 |
| pass_case | int | 通过用例数 |
| fail_case | int | 失败用例数 |
| skip_case | int | 跳过用例数 |
| pass_rate | decimal(5,2) | 通过率 |
| duration | bigint | 总耗时,单位毫秒 |
| status | varchar(16) | 报告状态,completed/failed/running |
| summary | text | 测试摘要,AI 生成或执行引擎填充 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
用例明细表:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| report_id | bigint | 报告主表ID |
| case_name | varchar(256) | 用例名称 |
| case_module | varchar(128) | 模块名称 |
| case_status | varchar(16) | 用例结果,pass/fail/skip |
| duration | bigint | 用例耗时 |
| error_message | text | 错误信息 |
| log_path | varchar(512) | 日志路径 |
这里没有列出全部冗余字段,实际项目中可以根据需要增加执行机 IP、构建号、分支信息等。
4.2 实体类定义
对应主表的实体类:
// 文件路径:src/main/java/com/example/report/entity/TestReportEntity.java package com.example.report.entity; import javax.persistence.*; import java.math.BigDecimal; import java.time.LocalDateTime; @Entity @Table(name = "test_report") public class TestReportEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "report_no", length = 64, unique = true) private String reportNo; @Column(name = "project_name", length = 128) private String projectName; @Column(name = "suite_name", length = 128) private String suiteName; @Column(name = "trigger_type", length = 32) private String triggerType; @Column(name = "total_case") private Integer totalCase; @Column(name = "pass_case") private Integer passCase; @Column(name = "fail_case") private Integer failCase; @Column(name = "skip_case") private Integer skipCase; @Column(name = "pass_rate", precision = 5, scale = 2) private BigDecimal passRate; @Column(name = "duration") private Long duration; @Column(name = "status", length = 16) private String status; @Column(name = "summary", columnDefinition = "text") private String summary; @Column(name = "create_time") private LocalDateTime createTime; @Column(name = "update_time") private LocalDateTime updateTime; // 省略 getter/setter }对应明细表的实体类:
// 文件路径:src/main/java/com/example/report/entity/TestReportCaseEntity.java package com.example.report.entity; import javax.persistence.*; @Entity @Table(name = "test_report_case") public class TestReportCaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "report_id") private Long reportId; @Column(name = "case_name", length = 256) private String caseName; @Column(name = "case_module", length = 128) private String caseModule; @Column(name = "case_status", length = 16) private String caseStatus; @Column(name = "duration") private Long duration; @Column(name = "error_message", columnDefinition = "text") private String errorMessage; @Column(name = "log_path", length = 512) private String logPath; // 省略 getter/setter }字段上加了@Column便于明确定义列名,避免 JPA 自动命名策略导致表结构和实体不一致。
4.3 Repository 层
使用 Spring Data JPA 可以大幅减少 CRUD 代码。对于报告主表,除了继承 JpaRepository 外,还需要自定义统计查询方法。
// 文件路径:src/main/java/com/example/report/repository/ReportRepository.java package com.example.report.repository; import com.example.report.entity.TestReportEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; import java.util.List; public interface ReportRepository extends JpaRepository<TestReportEntity, Long> { TestReportEntity findByReportNo(String reportNo); @Query("select r from TestReportEntity r where " + "(:projectName is null or r.projectName like concat('%', :projectName, '%')) " + "and (:startTime is null or r.createTime >= :startTime) " + "and (:endTime is null or r.createTime <= :endTime) " + "order by r.createTime desc") List<TestReportEntity> searchReports(@Param("projectName") String projectName, @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime); }这种动态查询写法在参数较少时非常清晰。如果过滤条件很多,可以改用 Specification 或 QueryDSL,但篇幅关系这里不展开。
5. 核心接口实现
5.1 报告数据接收与入库
执行引擎通过/api/report/push推送测试数据。这里先定义一个统一的 DTO:
// 文件路径:src/main/java/com/example/report/dto/ReportPushRequest.java package com.example.report.dto; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import java.util.List; public class ReportPushRequest { @NotBlank(message = "reportNo不能为空") private String reportNo; @NotBlank(message = "projectName不能为空") private String projectName; private String suiteName; private String triggerType; @NotNull(message = "totalCase不能为空") private Integer totalCase; private Integer passCase; private Integer failCase; private Integer skipCase; private Long duration; private String status; private List<CaseResultDTO> cases; public static class CaseResultDTO { private String caseName; private String caseModule; private String caseStatus; private Long duration; private String errorMessage; private String logPath; // 省略 getter/setter } // 省略 getter/setter }Service 层处理入库逻辑。需要特别注意的是,reportNo要作为业务唯一键做重复检查,避免执行引擎重试推送时产生重复数据。可以为每次推送生成一个全局 traceId,如果发现 reportNo 已存在,则直接返回成功,保证接口幂等。
// 文件路径:src/main/java/com/example/report/service/ReportService.java package com.example.report.service; import com.example.report.dto.ReportPushRequest; import com.example.report.entity.TestReportCaseEntity; import com.example.report.entity.TestReportEntity; import com.example.report.repository.ReportCaseRepository; import com.example.report.repository.ReportRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @Service public class ReportService { private final ReportRepository reportRepository; private final ReportCaseRepository reportCaseRepository; public ReportService(ReportRepository reportRepository, ReportCaseRepository reportCaseRepository) { this.reportRepository = reportRepository; this.reportCaseRepository = reportCaseRepository; } @Transactional public Long pushReport(ReportPushRequest request) { // 幂等处理:存在相同 reportNo 直接返回已有 ID TestReportEntity exist = reportRepository.findByReportNo(request.getReportNo()); if (exist != null) { return exist.getId(); } TestReportEntity entity = new TestReportEntity(); // 基础字段拷贝 entity.setReportNo(request.getReportNo()); entity.setProjectName(request.getProjectName()); entity.setSuiteName(request.getSuiteName()); entity.setTriggerType(request.getTriggerType()); entity.setTotalCase(request.getTotalCase()); entity.setPassCase(request.getPassCase() == null ? 0 : request.getPassCase()); entity.setFailCase(request.getFailCase() == null ? 0 : request.getFailCase()); entity.setSkipCase(request.getSkipCase() == null ? 0 : request.getSkipCase()); // 如果调用方没有传通过率,则后端计算 if (request.getTotalCase() != null && request.getTotalCase() > 0) { int pass = entity.getPassCase(); BigDecimal rate = BigDecimal.valueOf(pass) .multiply(BigDecimal.valueOf(100)) .divide(BigDecimal.valueOf(request.getTotalCase()), 2, RoundingMode.HALF_UP); entity.setPassRate(rate); } entity.setDuration(request.getDuration()); entity.setStatus(request.getStatus()); entity.setCreateTime(LocalDateTime.now()); entity.setUpdateTime(LocalDateTime.now()); TestReportEntity saved = reportRepository.save(entity); // 保存用例明细 if (request.getCases() != null && !request.getCases().isEmpty()) { List<TestReportCaseEntity> caseList = new ArrayList<>(); for (ReportPushRequest.CaseResultDTO caseDTO : request.getCases()) { TestReportCaseEntity caseEntity = new TestReportCaseEntity(); caseEntity.setReportId(saved.getId()); caseEntity.setCaseName(caseDTO.getCaseName()); caseEntity.setCaseModule(caseDTO.getCaseModule()); caseEntity.setCaseStatus(caseDTO.getCaseStatus()); caseEntity.setDuration(caseDTO.getDuration()); caseEntity.setErrorMessage(caseDTO.getErrorMessage()); caseEntity.setLogPath(caseDTO.getLogPath()); caseList.add(caseEntity); } reportCaseRepository.saveAll(caseList); } return saved.getId(); } }@Transactional保证报告主表和明细表的写入在同一个事务中,避免主表写成功但明细丢失的情况。
5.2 报告列表与统计查询
列表查询接口用于前端展示报告列表。为了减少数据库查询次数,每次查询报告时连通用例失败数做一次聚合展示。如果性能要求高,也可以在 report 表中冗余 case 明细信息,或者使用 Redis 缓存。
这里给出一个简单的搜索查询 Service 方法:
// ReportService 中补充 public List<TestReportEntity> searchReports(String projectName, LocalDateTime startTime, LocalDateTime endTime) { return reportRepository.searchReports(projectName, startTime, endTime); }Controller 层的写法:
// 文件路径:src/main/java/com/example/report/controller/ReportController.java package com.example.report.controller; import com.example.report.common.Result; import com.example.report.dto.ReportPushRequest; import com.example.report.entity.TestReportEntity; import com.example.report.service.ReportService; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; import java.util.List; @RestController @RequestMapping("/api/report") public class ReportController { private final ReportService reportService; public ReportController(ReportService reportService) { this.reportService = reportService; } @PostMapping("/push") public Result<Long> pushReport(@Validated @RequestBody ReportPushRequest request) { return Result.success(reportService.pushReport(request)); } @GetMapping("/list") public Result<List<TestReportEntity>> listReports( @RequestParam(required = false) String projectName, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) { return Result.success(reportService.searchReports(projectName, startTime, endTime)); } }Result 是统一的响应包装类,可以保持接口返回格式稳定:
// 文件路径:src/main/java/com/example/report/common/Result.java package com.example.report.common; public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 0; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(int code, String message) { Result<T> result = new Result<>(); result.code = code; result.message = message; return result; } // 省略 getter/setter }5.3 AI 总结分析接口
AI 模块是报告中心的亮点。设计思路是:读取报告汇总数据和失败用例信息,组装成提示词,调用大模型接口,返回分析结果。为了避免在测试环境没有大模型 API 时功能不可用,我建议增加一个mock开关。
// 文件路径:src/main/java/com/example/report/service/AiAnalyzeService.java package com.example.report.service; import com.example.report.entity.TestReportCaseEntity; import com.example.report.entity.TestReportEntity; import com.example.report.repository.ReportCaseRepository; import com.example.report.repository.ReportRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.List; import java.util.Map; @Service public class AiAnalyzeService { private final ReportRepository reportRepository; private final ReportCaseRepository reportCaseRepository; @Value("${report.ai.endpoint:}") private String aiEndpoint; @Value("${report.ai.api-key:}") private String apiKey; @Value("${report.ai.enable:true}") private boolean aiEnable; @Value("${report.ai.max-example-length:500}") private int maxExampleLength; public AiAnalyzeService(ReportRepository reportRepository, ReportCaseRepository reportCaseRepository) { this.reportRepository = reportRepository; this.reportCaseRepository = reportCaseRepository; } public String analyze(Long reportId) { TestReportEntity report = reportRepository.findById(reportId).orElse(null); if (report == null) { return "报告不存在"; } List<TestReportCaseEntity> caseList = reportCaseRepository.findByReportId(reportId); // 不开启 AI 或未配置 endpoint 时,使用本地拼装摘要 if (!aiEnable || aiEndpoint.isEmpty()) { return buildMockSummary(report, caseList); } // 组装提示词 Map<String, Object> body = buildRequestBody(report, caseList); // TODO 调用大模型 HTTP 接口 // String response = httpPost(aiEndpoint, apiKey, body); // 这里不写死具体实现,按实际部署的大模型服务调整 return buildMockSummary(report, caseList); } private String buildMockSummary(TestReportEntity report, List<TestReportCaseEntity> caseList) { StringBuilder sb = new StringBuilder(); sb.append("项目 [").append(report.getProjectName()) .append("] 本次执行完成,通过率 ") .append(report.getPassRate()) .append("%,总用例 ") .append(report.getTotalCase()) .append(" 条,失败 ") .append(report.getFailCase()) .append(" 条。"); long failCount = caseList.stream() .filter(c -> "fail".equals(c.getCaseStatus())) .count(); if (failCount > 0) { sb.append("失败用例 ").append(failCount).append(" 条,建议优先检查失败模块的回归场景。"); } else { sb.append("本次执行未发现失败用例,质量情况良好。"); } return sb.toString(); } private Map<String, Object> buildRequestBody(TestReportEntity report, List<TestReportCaseEntity> caseList) { Map<String, Object> body = new HashMap<>(); body.put("projectName", report.getProjectName()); body.put("passRate", report.getPassRate()); body.put("totalCase", report.getTotalCase()); body.put("failCase", report.getFailCase()); StringBuilder sb = new StringBuilder(); int count = 0; for (TestReportCaseEntity c : caseList) { if ("fail".equals(c.getCaseStatus()) && count < maxExampleLength) { sb.append("用例名称:").append(c.getCaseName()) .append(",错误信息:").append(c.getErrorMessage()).append("\n"); count++; } } body.put("failExample", sb.toString()); return body; } }对于真实环境中要调用大模型 API 的场景,我建议不要直接在 Service 里写 HttpClient 调用,而是抽象出一个AiClient接口,根据不同的模型部署方式实现不同客户端。这样后续更换模型时,只需要新增一个实现类,不影响上层业务。
5.4 完整 Controller 补充
在 ReportController 中补充 AI 分析接口:
@GetMapping("/analyze/{id}") public Result<String> analyzeReport(@PathVariable Long id) { return Result.success(aiAnalyzeService.analyze(id)); }在使用构造器注入时,如果一个类依赖多个 Service,Spring 会按参数类型自动装配。也可以采用@RequiredArgsConstructor配合final字段,代码更简洁,本文为了直观使用传统写法。
6. 运行与验证
6.1 初始化数据库
首次启动前,在 MySQL 中创建数据库:
CREATE DATABASE report_center DEFAULT CHARACTER SET utf8mb4;配置好 application.yml 的连接信息后,启动 Spring Boot 应用。如果ddl-auto设置为 update,JPA 会自动建表,不需要手动执行建表语句。不过生产环境建议将 ddl-auto 设置为 none,并通过 Flyway 或 Liquibase 管理表结构变更。
在本地开发阶段,update 模式比较方便。需要注意,JPA 自动建表不会自动添加索引,如果查询缓慢,需要手动补充索引。
6.2 构造模拟测试数据
启动完成后,用 curl 模拟一次测试结果推送:
curl -X POST http://localhost:8080/api/report/push \ -H "Content-Type: application/json" \ -d '{ "reportNo": "20250611-001", "projectName": "order-service", "suiteName": "订单服务回归测试", "triggerType": "pipeline", "totalCase": 120, "passCase": 115, "failCase": 4, "skipCase": 1, "duration": 86200, "status": "completed", "cases": [ { "caseName": "创建订单成功", "caseModule": "订单模块", "caseStatus": "pass", "duration": 1200 }, { "caseName": "取消订单并发校验", "caseModule": "订单模块", "caseStatus": "fail", "errorMessage": "expected status code 200 but got 500", "duration": 3000 } ] }'预期返回:
{ "code": 0, "message": "success", "data": 1 }此时数据库 test_report 表会新增一条记录,test_report_case 表新增两条明细。
6.3 查询报告列表
curl "http://localhost:8080/api/report/list?projectName=order-service"接口返回该项目的报告列表。可以看到 createTime 默认按倒序排列。
调用 AI 分析接口:
curl http://localhost:8080/api/report/analyze/1因为本地没有配置实际的大模型接口,默认走 mock 逻辑,返回类似:
项目 [order-service] 本次执行完成,通过率 95.83%,总用例 120 条,失败 4 条。失败用例 1 条,建议优先检查失败模块的回归场景。这里通过率已经由后端根据 passCase 自动计算。实际接入大模型后,返回内容会复杂很多,可以包含失败原因分类、风险模块和建议改进方向。
6.4 结果说明
从验证结果可以看出,报告中心的核心链路已经跑通:推送数据、入库、查询、统计汇总、AI 分析。这已经是报告中心的最小可用版本。后续在这个基础上,可以继续增加趋势图接口、报告导出、定时汇总等能力。
7. 常见问题与排查思路
报告中心在开发和使用过程中会遇到不少问题,下面整理几个高频场景。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 重复推送产生多份报告 | 未按 reportNo 做幂等 | 入库前查询 reportNo 是否存在,存在则直接返回 |
| 报告主表有记录但明细为空 | 明细写入失败或事务未生效 | 检查 Service 是否有 @Transactional,确认 request.cases 是否为空 |
| 通过率显示为 0 | 调用方未传 passCase | 后端根据 passCase/totalCase 自动计算并覆盖 |
| 中文乱码 | 数据库字符集不是 utf8mb4 | 创建数据库时指定 utf8mb4,连接串加 characterEncoding |
| 报告列表查询慢 | 缺少 create_time 索引 | 对 create_time、project_name 字段建联合索引 |
| AI 分析接口超时 | 大模型接口响应慢 | 设置合理的 HTTP 超时时间,建议 10-30 秒 |
| 调用方推送格式不统一 | 不同执行引擎构造的 JSON 结构不一致 | 制定统一协议文档,提供 SDK 或示例代码 |
7.1 幂等设计遗漏
很多团队在第一次实现推送接口时容易忽略幂等。执行引擎在做 HTTP 调用时,如果发生网络超时,往往会自动重试。如果没有幂等策略,一次执行会产生多条报告记录,统计结果全部失真。
解决方式有两种:
- 在业务层判断 reportNo 是否已存在,存在则直接返回旧记录 ID。
- 在数据库层对 report_no 建唯一索引,捕获 DuplicateKeyException 返回成功。
建议两种同时做,业务层提高用户体验,数据库唯一索引兜底。
7.2 报告状态异常
如果执行引擎在测试中途崩溃,报告中心可能只收到部分数据。这时不能把报告标记为 completed。建议增加一个状态机,比如 running、completed、failed。只有收到执行引擎发送的“完成事件”,或者达到超时时间,才能确认最终状态。否则一段数据很容易误导用户。
这个设计在做平台时非常重要,我在实际项目里就遇到过:测试执行到一半节点宕机,报告中心显示通过率 50%,下面的人以为真的只跑了一半用例。后来把状态管理完善之后,类似问题才彻底解决。
8. 最佳实践与工程建议
8.1 数据一致性优先
报告中心的写操作可能来自多个执行引擎并发推送。除了幂等设计外,还要关注事务边界。建议在 Service 层最外层添加@Transactional,保证报告和明细要么同时成功,要么同时回滚。
如果后续引入消息队列异步消费,还要考虑消费失败后的重试机制,确保消息不会丢失。重试时同样要保留 reportNo 幂等判断。
8.2 权限与数据隔离
报告中心涉及的质量数据比较敏感,不建议做成全平台匿名可见。至少要支持按项目隔离,不同团队只能看到自己项目的报告。可以在报告表增加project_code字段,查询接口强制带上该字段,由上层权限系统解析当前用户可见的项目列表。
建议在接口层通过注解校验权限,而不是在 Service 层重复判断,这样代码更统一。例如:
@PreAuthorize("hasPermission(#projectName, 'report:view')")这只是示例,具体权限注解视项目使用的安全框架而定。
8.3 AI 接入的安全与成本控制
调用大模型接口时,首先要保证 API Key 不暴露在前端代码或日志中。密钥统一从环境变量或配置中心读取,日志打印时要脱敏。
成本控制方面,不要对每份报告都自动调用 AI。推荐方案:
- 默认只在失败率超过阈值(比如 5%)或失败用例数量大于 0 时自动分析。
- 其他报告支持用户手动点击触发。
- 对提示词中的失败日志长度做截断,避免 token 消耗过大。
8.4 性能优化
报告中心如果服务多个团队,数据量增长会很快。建议从几个层面优化:
- 索引优化:针对 project_code + create_time 建联合索引。
- 归档策略:超过 90 天的明细数据迁移到归档表或冷存储。
- 查询缓存:热点统计接口可以加 Redis 缓存,缓存时间 3-5 分钟。
- 异步生成 AI 摘要:不要阻塞报告详情查询。
如果报告明细特别多,甚至可以考虑将 case 明细存到 ClickHouse 或 Elasticsearch,MySQL 只保留汇总数据和最近 N 天明细。不过这种方案会增加运维复杂度,建议数据量达到一定规模后再做。
8.5 接口兼容性
平台接入的测试框架会变化,比如从 JUnit 4 升级到 JUnit 5,测试报告格式可能会变化。所以在报告中心的推送接口设计上,建议把字段名做得尽量通用,比如caseStatus而不是junitStatus,这样格式转换的逻辑放在执行引擎侧,避免报告中心频繁改动。
如果确实需要增加字段,建议在 DTO 中新增可选字段,并设置默认值,保持接口兼容。
8.6 可观测性
报告中心本身是平台的一部分,它的稳定性也会影响使用体验。建议为推送接口、AI 分析接口增加埋点监控:
- 推送接口的成功率、耗时。
- 失败率高的项目 Top N。
- AI 分析接口的调用量和超时率。
- 报告数据延迟时间。
这些指标对排查问题会有很大帮助。
9. 总结
这篇文章从一个测试平台常见痛点出发,完整演示了 AI 智能测试平台报告中心的设计与实现。内容包括:
- 报告中心的定位和功能拆分。
- Spring Boot 项目的数据库表设计。
- 报告数据推送接口的幂等处理。
- 报告列表查询与通过率计算。
- AI 总结分析接口的抽象和 mock 实现。
- 常见问题的排查思路。
整个流程是一个可以直接落地的最小闭环。你可以把这里的代码作为基础,继续扩展趋势分析、定时汇总、报告导出、权限隔离等能力。尤其是 AI 分析模块,建议先梳理清楚自己的测试数据和提示词模板,再逐步接入真实大模型,期间注意控制调用成本和保护密钥。
如果你正在建设测试平台,可以先从报告中心入手,它是连接执行引擎和团队决策的关键一环。把这条链路做扎实,后续再增加用例管理、调度编排等功能时,会发现所有功能都更容易串起来了。