最近在整理一套 AI 测试用例管理项目时,发现一个很实用的协作思路:先用 AI 的 Plan 模式把需求和技术方案理清楚,再让它按模块逐个生成代码,整个过程比传统手工搭建快很多。这篇文章基于实际落地过程整理成一份完整教程,包含 Plan 模式的用法说明、全栈项目目录设计、数据库脚本、后端接口、前端页面和常见坑点,新手可以直接照着搭,有经验的开发者也能用来规范自己的 AI 辅助开发流程。
1. Plan 模式是什么,为什么要用它
1.1 从手动改写代码到 AI 规划式开发
很多开发者第一次用 AI 编程工具时,习惯直接描述需求,让 AI 一次性输出整段代码。这种方式在小功能上很快,但遇到“测试用例管理系统”这种同时包含前端页面、后端接口、数据库设计、权限控制的全栈项目时,一次性生成的代码往往会出现结构混乱、重复代码多、数据库字段前后不一致等问题。
Plan 模式解决的就是这个问题。它不会立即生成代码,而是先根据你的需求,输出一份完整的实施计划。计划里会包含:
- 项目模块如何划分;
- 数据库表结构设计;
- 后端接口清单;
- 前端页面与组件拆分;
- 功能实现的先后顺序;
- 每步的验收标准。
你可以先审查这份计划,发现问题直接修改,等计划确认后再让 AI 按计划逐块实现代码。这样生成的代码整体一致性更高,模块边界也更清晰。
1.2 常见工作模式:手动、Plan、自动
在使用 AI 编程工具时,经常看到“手动模式”“Plan 模式”“自动模式”等说法。不同工具对这些模式的命名略有差异,但含义基本一致:
| 模式类型 | 工作方式 | 适用场景 |
|---|---|---|
| 手动模式 | 你逐条发送指令,AI 按指令执行,每一步都等待你确认 | 精确改代码、查问题、单点修改 |
| Plan 模式 | AI 先分析需求并输出方案,确认后再实施 | 项目初始化、重构、多模块功能开发 |
| 自动模式 | AI 根据目标自动迭代执行,连续修改多个文件 | 已完成规划后的批量编码实现 |
这三种模式不是互斥的,甚至可以结合使用。比如项目初期用 Plan 模式确定框架,中期用自动模式生成批量样板代码,后期用手动模式精准修复问题。理解这些模式的区别,能显著提升 AI 辅助开发的效率。
1.3 为什么 AI 测试用例项目适合用 Plan 模式起步
测试用例管理项目非常典型,它包含了数据建模、接口设计、前端表单、列表展示、统计分析等常见的全栈开发要素。用它作为 Plan 模式的练手项目,有几点明显收益:
- 需求容易拆解:用户、用例、测试计划、执行结果等模块边界清楚。
- 技术栈是通用栈:数据库 + 后端接口 + 前端页面,几乎每个开发都熟悉。
- 可运行、可验证:搭好后能真实录入数据、查询数据、统计结果,能直观看到效果。
- 便于扩展:后续还能增加用户登录、权限控制、Excel 导入导出、批量执行等能力。
所以这篇文章以“AI 测试用例管理系统”为目标,完整演示如何用 Plan 模式从零搭好全栈项目框架。
2. 环境准备与项目总体设计
2.1 本地环境要求
本文的示例涉及前后端分离项目,环境版本根据你自己的电脑情况调整,我这里给出一个常见组合:
- JDK:8 或 11 以上(示例以 Java 17 编写,如果你本机是 8,需要适当调整 pom 中的版本配置)
- Maven:3.6 以上
- MySQL:5.7 或 8.0
- Node.js:16 以上
- IDE:IntelliJ IDEA 或 VS Code 均可
- AI 编程工具:支持 Plan 模式的 CLI 或 IDE 插件均可
如果你没有安装 JDK 或 Node.js,建议先安装完成后再继续。下面的示例命令以 macOS / Linux 环境为主,Windows 用户将路径和命令稍作调整即可。
2.2 项目整体功能范围
我们搭建的 AI 测试用例管理系统,初版只做最核心的闭环:
- 用例管理:新增、查询、修改、删除测试用例;
- 用例字段:用例名称、所属模块、优先级、前置条件、操作步骤、预期结果;
- 执行记录:记录每条用例最后一次执行结果;
- 数据统计:按模块统计用例数量、按结果类型统计通过率。
这样一个范围,足以覆盖“前后端 + 数据库”的完整链路,又不至于让项目规模过大。
2.3 技术栈与目录规划
建议采用前后端分离结构:
ai-test-case-project/ ├── backend/ # Spring Boot 后端 │ ├── src/main/java/ │ ├── src/main/resources/ │ └── pom.xml ├── frontend/ # Vue3 前端 │ ├── src/ │ ├── index.html │ ├── package.json │ └── vite.config.js ├── database/ │ └── init.sql └── docs/ ├── 01-需求文档.md ├── 02-设计文档.md └── 03-接口文档.md等下我们会让 AI 按这个结构生成对应代码。先规划好目录,可以避免后续文件乱放。
3. 用 Plan 模式生成项目框架的具体步骤
3.1 向 AI 描述完整需求
使用 Plan 模式时,第一件事不是让 AI 直接写代码,而是把需求描述清楚。以下是一个可以直接复制使用的需求描述模板:
请用 Plan 模式帮我规划一个 AI 测试用例管理系统。 需求背景: 测试团队需要维护测试用例,方便查看用例详情、快速筛选用例、统计执行结果。 功能需求: 1. 用例管理:支持新增、编辑、删除、查询测试用例; 2. 用例字段:用例名称、所属模块、优先级(高/中/低)、前置条件、操作步骤、预期结果; 3. 执行记录:记录每条用例的执行结果(通过/失败/阻塞); 4. 统计报表:按模块展示用例数量,按执行结果展示通过率; 5. 提供 RESTful 接口,供前端页面调用。 技术栈: 后端:Spring Boot 3 + MyBatis-Plus + MySQL 前端:Vue 3 + Vite + Element Plus + Axios 数据库:MySQL 5.7 或 8.0 补充要求: 1. 数据库表结构要合理,包含创建时间、更新时间; 2. 后端接口遵循统一返回格式; 3. 前端页面简洁,包含用例列表、新增用例弹窗、统计面板; 4. 需要输出项目目录结构和接口清单。 请先不要写完整代码,先输出实施方案,包括模块划分、数据库表设计、接口清单、前端页面结构、开发顺序。这段描述的关键词是“先输出实施方案”,AI 会据此进入规划状态,而不是立即埋头写代码。
3.2 审核 AI 输出的实施计划
AI 输出计划后,不要急着点“确认”或“继续”,先检查下面几个方面:
- 模块划分是否覆盖所有需求点;
- 数据库表是否包含全部字段;
- 接口清单能否支撑前端页面的数据交互;
- 开发顺序是否按“数据库 → 后端 → 前端”推进。
如果计划有缺漏,可以直接在对话里补充,比如:
计划里缺少“删除用例”的接口,请补充; 统计接口除了通过率,还需要返回每个模块的用例数量; 前端页面加入一个“重置查询条件”的按钮。Plan 模式的价值就在这里:它允许你在没有产出大量代码前,低成本调整方案。一旦方案确认,后续生成代码就有了明确依据。
3.3 确认计划后分批生成代码
计划确认后,让 AI 按顺序生成代码,建议一批一批来,而不是一次性生成所有文件。示例指令如下:
请按计划开始实施: 第一步,生成 database/init.sql 数据库初始化脚本; 第二步,生成后端项目的 pom.xml、application.yml、实体类、Mapper、Service、Controller; 第三步,生成前端项目结构、API 封装、用例列表页面、新增用例弹窗、统计面板。 每一步生成完成后,请等待我确认再继续下一步。分批生成的好处是,如果某一步代码有误,方便单独纠正,不需要从头再来。
4. 实战:AI 测试用例系统代码实现
下面是我用 Plan 模式逐步整理出来的核心代码。由于文章篇幅有限,这里给出最关键的文件,完整代码可以按同样方式让 AI 补齐。
4.1 数据库脚本 database/init.sql
-- 创建数据库 CREATE DATABASE IF NOT EXISTS test_case_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE test_case_db; -- 测试用例表 CREATE TABLE IF NOT EXISTS test_case ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', case_name VARCHAR(200) NOT NULL COMMENT '用例名称', module_name VARCHAR(100) NOT NULL COMMENT '所属模块', priority VARCHAR(10) NOT NULL DEFAULT '中' COMMENT '优先级:高/中/低', precondition TEXT COMMENT '前置条件', steps TEXT COMMENT '操作步骤', expected_result TEXT COMMENT '预期结果', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='测试用例表'; -- 用例执行结果表 CREATE TABLE IF NOT EXISTS test_case_result ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', case_id BIGINT NOT NULL COMMENT '用例ID', result VARCHAR(20) NOT NULL COMMENT '执行结果:通过/失败/阻塞', executor VARCHAR(50) COMMENT '执行人', execute_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '执行时间', remark VARCHAR(500) COMMENT '备注', CONSTRAINT fk_case_result FOREIGN KEY (case_id) REFERENCES test_case(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用例执行结果表'; -- 初始化一条示例数据 INSERT INTO test_case (case_name, module_name, priority, precondition, steps, expected_result) VALUES ('登录功能-正确账号密码登录', '登录模块', '高', '已注册测试账号', '1. 打开登录页\n2. 输入正确账号密码\n3. 点击登录', '登录成功,跳转首页');这里使用了utf8mb4字符集,是为了兼容中文和特殊字符。外键约束fk_case_result保证执行记录必须关联到真实存在的用例,避免脏数据。
4.2 后端 Spring Boot 核心代码
4.2.1 pom.xml 依赖配置
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>test-case-backend</artifactId> <version>1.0.0</version> <name>test-case-backend</name> <description>AI 测试用例管理系统后端</description> <properties> <java.version>17</java.version> <mybatis-plus.version>3.5.5</mybatis-plus.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project>如果你本机 JDK 版本是 8,需要把java.version改为 8,同时 Spring Boot 3 最低要求是 Java 17,建议直接使用 JDK 17 以上。
4.2.2 application.yml 配置
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/test_case_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto配置里map-underscore-to-camel-case: true的作用是把数据库字段case_name自动映射为 Java 属性caseName,这样实体类里就不用手动写一堆映射注解。
4.2.3 实体类 TestCase.java
// 文件路径:backend/src/main/java/com/example/testcase/entity/TestCase.java package com.example.testcase.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; @Data @TableName("test_case") public class TestCase { @TableId(type = IdType.AUTO) private Long id; private String caseName; private String moduleName; private String priority; private String precondition; private String steps; private String expectedResult; private LocalDateTime createTime; private LocalDateTime updateTime; }使用 MyBatis-Plus 时,实体类字段和数据库字段通过驼峰转换自动映射,所以不用每个字段都加@TableField注解。@Data是 Lombok 提供的,编译后自动生成 getter/setter,代码更简洁。
4.2.4 Mapper 接口
// 文件路径:backend/src/main/java/com/example/testcase/mapper/TestCaseMapper.java package com.example.testcase.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.testcase.entity.TestCase; import org.apache.ibatis.annotations.Mapper; @Mapper public interface TestCaseMapper extends BaseMapper<TestCase> { }Mapper 接口继承BaseMapper<T>后,无需编写 XML,就自动拥有插入、查询、更新、删除等基础方法。
4.2.5 Service 与实现类
// 文件路径:backend/src/main/java/com/example/testcase/service/TestCaseService.java package com.example.testcase.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.example.testcase.entity.TestCase; import java.util.List; public interface TestCaseService { IPage<TestCase> pageQuery(int pageNum, int pageSize, String moduleName, String priority); TestCase getById(Long id); int add(TestCase testCase); int update(TestCase testCase); int delete(Long id); }// 文件路径:backend/src/main/java/com/example/testcase/service/impl/TestCaseServiceImpl.java package com.example.testcase.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.testcase.entity.TestCase; import com.example.testcase.mapper.TestCaseMapper; import com.example.testcase.service.TestCaseService; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import jakarta.annotation.Resource; @Service public class TestCaseServiceImpl implements TestCaseService { @Resource private TestCaseMapper testCaseMapper; @Override public IPage<TestCase> pageQuery(int pageNum, int pageSize, String moduleName, String priority) { LambdaQueryWrapper<TestCase> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(moduleName), TestCase::getModuleName, moduleName) .eq(StringUtils.hasText(priority), TestCase::getPriority, priority) .orderByDesc(TestCase::getCreateTime); return testCaseMapper.selectPage(new Page<>(pageNum, pageSize), wrapper); } @Override public TestCase getById(Long id) { return testCaseMapper.selectById(id); } @Override public int add(TestCase testCase) { return testCaseMapper.insert(testCase); } @Override public int update(TestCase testCase) { return testCaseMapper.updateById(testCase); } @Override public int delete(Long id) { return testCaseMapper.deleteById(id); } }分页查询使用了 MyBatis-Plus 的LambdaQueryWrapper,条件用动态判断:moduleName为空时,不拼接该查询条件。这种方式比手动拼接 SQL 更安全,也能避免 SQL 注入问题。
4.2.6 统一返回结果封装
// 文件路径:backend/src/main/java/com/example/testcase/common/Result.java package com.example.testcase.common; import lombok.Data; @Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }4.2.7 控制器 Controller
// 文件路径:backend/src/main/java/com/example/testcase/controller/TestCaseController.java package com.example.testcase.controller; import com.baomidou.mybatisplus.core.metadata.IPage; import com.example.testcase.common.Result; import com.example.testcase.entity.TestCase; import com.example.testcase.service.TestCaseService; import org.springframework.web.bind.annotation.*; import jakarta.annotation.Resource; @RestController @RequestMapping("/api/test-case") @CrossOrigin public class TestCaseController { @Resource private TestCaseService testCaseService; @GetMapping("/page") public Result<IPage<TestCase>> page( @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String moduleName, @RequestParam(required = false) String priority) { return Result.success(testCaseService.pageQuery(pageNum, pageSize, moduleName, priority)); } @GetMapping("/{id}") public Result<TestCase> getById(@PathVariable Long id) { return Result.success(testCaseService.getById(id)); } @PostMapping("/add") public Result<Void> add(@RequestBody TestCase testCase) { testCaseService.add(testCase); return Result.success(null); } @PutMapping("/update") public Result<Void> update(@RequestBody TestCase testCase) { testCaseService.update(testCase); return Result.success(null); } @DeleteMapping("/{id}") public Result<Void> delete(@PathVariable Long id) { testCaseService.delete(id); return Result.success(null); } }@CrossOrigin是为了解决本地开发时的前后端跨域问题。生产环境建议用 Nginx 反向代理或者在网关层统一配置跨域规则,而不是在每个 Controller 上直接加注解。
4.2.8 启动类
// 文件路径:backend/src/main/java/com/example/testcase/TestCaseApplication.java package com.example.testcase; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication @MapperScan("com.example.testcase.mapper") public class TestCaseApplication { public static void main(String[] args) { SpringApplication.run(TestCaseApplication.class, args); } }4.3 前端 Vue3 核心代码
4.3.1 创建 Vite 项目
npm create vite@latest frontend -- --template vue cd frontend npm install element-plus axios npm run dev4.3.2 API 请求封装
// 文件路径:frontend/src/api/request.js import axios from 'axios'; import { ElMessage } from 'element-plus'; const request = axios.create({ baseURL: 'http://localhost:8080', timeout: 10000 }); request.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { ElMessage.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); } return res; }, error => { ElMessage.error(error.message || '网络异常'); return Promise.reject(error); } ); export default request;4.3.3 用例 API 模块
// 文件路径:frontend/src/api/testCase.js import request from './request'; export function pageQuery(params) { return request.get('/api/test-case/page', { params }); } export function addTestCase(data) { return request.post('/api/test-case/add', data); } export function updateTestCase(data) { return request.put('/api/test-case/update', data); } export function deleteTestCase(id) { return request.delete(`/api/test-case/${id}`); }4.3.4 用例列表页面
<!-- 文件路径:frontend/src/views/TestCaseList.vue --> <template> <div class="page-container"> <el-card shadow="never"> <el-form :inline="true"> <el-form-item label="模块"> <el-input v-model="query.moduleName" placeholder="请输入模块名称" clearable /> </el-form-item> <el-form-item label="优先级"> <el-select v-model="query.priority" placeholder="请选择优先级" clearable> <el-option label="高" value="高" /> <el-option label="中" value="中" /> <el-option label="低" value="低" /> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="loadData">查询</el-button> <el-button @click="resetQuery">重置</el-button> <el-button type="success" @click="openAddDialog">新增用例</el-button> </el-form-item> </el-form> <el-table :data="tableData" border stripe> <el-table-column prop="id" label="ID" width="70" /> <el-table-column prop="caseName" label="用例名称" /> <el-table-column prop="moduleName" label="模块" width="120" /> <el-table-column prop="priority" label="优先级" width="90" /> <el-table-column prop="expectedResult" label="预期结果" show-overflow-tooltip /> <el-table-column label="操作" width="160"> <template #default="scope"> <el-button size="small" @click="openEditDialog(scope.row)">编辑</el-button> <el-button size="small" type="danger" @click="handleDelete(scope.row.id)">删除</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" layout="total, prev, pager, next" @current-change="loadData" /> </el-card> <!-- 新增/编辑弹窗 --> <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑用例' : '新增用例'" width="600px" destroy-on-close> <el-form ref="formRef" :model="form" label-width="100px"> <el-form-item label="用例名称" required> <el-input v-model="form.caseName" placeholder="请输入用例名称" /> </el-form-item> <el-form-item label="所属模块"> <el-input v-model="form.moduleName" placeholder="请输入所属模块" /> </el-form-item> <el-form-item label="优先级"> <el-select v-model="form.priority"> <el-option label="高" value="高" /> <el-option label="中" value="中" /> <el-option label="低" value="低" /> </el-select> </el-form-item> <el-form-item label="前置条件"> <el-input v-model="form.precondition" type="textarea" /> </el-form-item> <el-form-item label="操作步骤"> <el-input v-model="form.steps" type="textarea" /> </el-form-item> <el-form-item label="预期结果"> <el-input v-model="form.expectedResult" type="textarea" /> </el-form-item> </el-form> <template #footer> <el-button @click="dialogVisible = false">取消</el-button> <el-button type="primary" @click="submitForm">确定</el-button> </template> </el-dialog> </div> </template> <script setup> import { reactive, ref, onMounted } from 'vue'; import { ElMessage, ElMessageBox } from 'element-plus'; import { pageQuery, addTestCase, updateTestCase, deleteTestCase } from '../api/testCase'; const tableData = ref([]); const total = ref(0); const dialogVisible = ref(false); const isEdit = ref(false); const query = reactive({ pageNum: 1, pageSize: 10, moduleName: '', priority: '' }); const defaultForm = () => ({ id: null, caseName: '', moduleName: '', priority: '中', precondition: '', steps: '', expectedResult: '' }); const form = reactive(defaultForm()); const loadData = async () => { const res = await pageQuery(query); tableData.value = res.data.records; total.value = res.data.total; }; const resetQuery = () => { query.moduleName = ''; query.priority = ''; query.pageNum = 1; loadData(); }; const openAddDialog = () => { isEdit.value = false; Object.assign(form, defaultForm()); dialogVisible.value = true; }; const openEditDialog = (row) => { isEdit.value = true; Object.assign(form, { id: row.id, caseName: row.caseName, moduleName: row.moduleName, priority: row.priority, precondition: row.precondition, steps: row.steps, expectedResult: row.expectedResult }); dialogVisible.value = true; }; const submitForm = async () => { if (!form.caseName) { ElMessage.warning('请输入用例名称'); return; } if (isEdit.value) { await updateTestCase(form); ElMessage.success('修改成功'); } else { await addTestCase(form); ElMessage.success('新增成功'); } dialogVisible.value = false; loadData(); }; const handleDelete = async (id) => { await ElMessageBox.confirm('确定删除该用例吗?', '提示', { type: 'warning' }); await deleteTestCase(id); ElMessage.success('删除成功'); loadData(); }; onMounted(loadData); </script>这个页面集中展示了列表查询、条件筛选、分页、弹窗表单、新增、编辑、删除等常见操作,是全栈项目中最常出现的页面形态,也是 Plan 模式生成代码时最有代表性的产物。
4.4 运行与验证
后端启动:
cd backend mvn spring-boot:run前端启动:
cd frontend npm run dev打开浏览器访问http://localhost:3000,应该能看到用例列表页面。如果页面能正常加载用例数据,并且新增、编辑、删除都能生效,说明前后端链路已经打通。
4.5 结果说明
初始化时init.sql插入了一条登录用例数据。启动后进入页面,默认表格应该能显示这条数据。点击“新增用例”,填写字段并保存,列表会自动刷新并显示新数据。
这里体现的完整流程是:
- 前端发送请求;
- 后端 Controller 接收请求;
- Service 调用 Mapper;
- MyBatis-Plus 执行 SQL;
- 数据写入 MySQL;
- 前端刷新列表展示最新数据。
调试时,如果接口报错,可以先看后端的控制台日志。MyBatis-Plus 配置了StdOutImpl日志,会打印实际执行的 SQL,方便定位是字段映射问题还是 SQL 拼接问题。
5. 常见问题与排查思路
5.1 前端跨域请求报错
现象:浏览器控制台出现Access-Control-Allow-Origin相关报错,接口能通但页面拿不到数据。
原因:前端运行在http://localhost:3000,后端运行在http://localhost:8080,端口不同,浏览器默认阻止跨源访问。
解决思路:
- 开发环境先在 Controller 上添加
@CrossOrigin注解,或在WebMvcConfigurer中配置全局跨域; - 生产环境优先使用 Nginx 反向代理,前端请求统一转发到后端;
- 不要在生产环境使用
*通配跨域,应指定具体来源域名。
5.2 MyBatis-Plus 分页不生效
现象:调用selectPage返回的总数正确,但records里数据不全,或 SQL 中没有出现LIMIT语句。
原因:MyBatis-Plus 3.5 以上版本需要手动配置分页插件。
解决思路:新增一个配置类,注册MybatisPlusInterceptor并添加PaginationInnerInterceptor。
// 文件路径:backend/src/main/java/com/example/testcase/config/MybatisPlusConfig.java package com.example.testcase.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); paginationInnerInterceptor.setMaxLimit(500L); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }这个坑很常见,很多 AI 生成的代码里容易漏掉分页插件配置。遇到分页不生效时,优先检查这一步。
5.3 项目启动后端口占用
现象:后端启动时提示Port 8080 was already in use。
原因:本机已有其他进程占用 8080 端口。
解决思路:
- 查找占用进程并结束;
- 或者修改
application.yml中server.port为其他端口; - 如果修改了后端端口,前端
request.js中的baseURL也要同步修改。
5.4 数据库连接失败
现象:启动时提示Access denied for user 'root'@'localhost'或Unknown database 'test_case_db'。
原因:MySQL 账号密码错误,或者没有先执行init.sql创建数据库。
解决思路:
- 先执行数据库脚本,确认
test_case_db已创建; - 核对
application.yml中的username和password; - 确认 MySQL 服务已启动;
- 如果使用 MySQL 8 以上版本,driver 使用
com.mysql.cj.jdbc.Driver通常没问题,低版本驱动会出现连接失败。
5.5 AI 生成的代码一键粘贴后跑不起来
现象:代码看起来没问题,但启动报错或页面白屏。
原因:AI 生成的代码可能面向不同目录结构、包名、版本,直接粘贴可能不完全适配。
解决思路:
- 优先让 AI 按“整个文件路径”输出,而不是零散代码片段;
- 检查包名是否和启动类一致;
- 检查前端依赖是否全部安装;
- 检查数据库表是否存在;
- 让 AI 阅读报错信息后自行修复,把完整异常堆栈发给它,通常比人肉排查更快。
6. 最佳实践与工程建议
6.1 Plan 模式提示词模板沉淀
建议把适合 Plan 模式的提示词沉淀成团队文档。一套好的提示词应该包含“项目目标、功能范围、技术栈、目录建议、输出要求”五个要素。这样以后再做同类项目,只要替换其中的业务描述,就可以快速得到一份高质量实施方案。
6.2 AI 生成代码必须人工审查
AI 生成代码能大幅提升效率,但不代表可以直接跳过审查。重点关注以下方面:
- SQL 语句,特别是批量更新、删除语句,必须确认是否带 WHERE 条件;
- 接口鉴权逻辑,不能裸奔暴露敏感数据;
- 配置文件中是否包含明文密码、密钥;
- 异常处理,是否捕获了数据库连接失败等情况;
- 生产环境相关配置是否写在
application-prod.yml中隔离。
尤其是数据库变更,任何更新、删除操作都应在测试库中验证备份,确认影响范围后再在正式环境执行。
6.3 配置敏感信息管理
目前application.yml中直接写了数据库密码。本地开发可以接受,但项目如需共享或部署到正式环境,建议:
- 使用环境变量替代明文密码;
- 使用配置中心管理多环境配置;
- 数据库账号按最小权限分配,不直接使用 root 账号。
6.4 测试用例项目本身的数据设计建议
作为测试用例管理系统,它的核心价值在于用例的结构化和可追溯性。实际项目落地时,建议:
- 增加用例与需求的关联字段,便于做需求覆盖率统计;
- 增加用例与缺陷的关联,执行失败时能直接关联缺陷单;
- 增加用例附件能力,上传测试截图或测试数据文件;
- 记录用例历史版本,变更时可以回溯;
- 支持批量导入导出,方便与 Excel 测试用例模板互通。
这些能力在初版框架稳定后,可以分批加入。每个新功能都建议先走一遍 Plan 模式,确认方案后再编码。
6.5 前后端协作规范
当项目变大后,建议提前约定前后端协作规范:
- 接口路径统一
/api/模块名/动作; - 返回结构统一
{ code, message, data }; - 日期时间格式统一
yyyy-MM-dd HH:mm:ss; - 分页参数统一
pageNum、pageSize; - 接口文档使用 Swagger / Knife4j 自动生成,或者用 Apifox 维护在线接口文档。
这套规范越早定下来,团队协作的摩擦越小。
7. 总结与后续学习路线
通过这次实操,我们完成了 AI 测试用例系统的全栈框架搭建,核心收获有三个:
- 理解 Plan 模式的工作方式:先规划后编码,能有效避免项目从第一步就陷入混乱。手动模式适合精调细节,自动模式适合方案确认后的批量实现,三种模式结合使用效率最高。
- 掌握了一个典型全栈项目的完整结构:数据库初始化脚本、Spring Boot 后端接口、Vue3 前端页面、接口调用链贯穿前后端。
- 积累了一批高频坑点的排查思路:跨域配置、分页插件、数据库连接、AI 代码适配问题,遇到类似场景可以直接复用。
下一步建议按顺序做三件事:
- 给项目增加用户登录和权限管理,让普通用户只能操作自己创建的用例,管理员可以管理所有人;
- 增加用例执行记录模块,按计划执行用例,并生成通过率和缺陷统计报表;
- 引入自动化测试脚本,让用例执行结果能自动回填到系统中,形成从测试设计到测试执行的闭环。
如果这篇文章对你有帮助,可以收藏备用。实际动手跑一遍项目,比收藏十篇教程更有效。遇到 AI 生成代码跑不起来的情况,不要急着全部推翻,先把报错信息整理清楚发给 AI 让它自己修,往往能省下不少时间。