16 AI写单元测试实战:我用Copilot给一个没测试的项目加了100%覆盖率
摘要:本文通过一个真实实验,详细演示了如何利用 GitHub Copilot 为遗留 Java 工具类库快速生成高质量单元测试。文章将流程拆解为五个步骤:1)让 AI 理解代码逻辑;2)逐个类生成基础测试;3)纠正 AI 的盲区(如内部异常处理);4)利用 AI 擅长模式进行边界值轰炸;5)验证覆盖率报告。最终,仅用 40 分钟就为 5 个工具类生成了 67 个测试用例,实现了 92% 的行覆盖率和 87% 的分支覆盖率。文章最后总结了三个核心技巧:先总结后生成、提示词要具体、人机分工互补,揭示了 AI 如何将开发者从重复劳动中解放,聚焦于更高价值的测试设计。

上篇文章说了我用AI写代码半年踩的坑,评论区一直有人追着问:"那AI写测试呢?靠不靠谱?会不会写一些废话测试装样子?"
我没有直接回答,而是找了个周末,做了个实验。
我从公司GitLab上翻出一个Java工具类库——老项目了,上线三年,从没写过一行测试。八个工具类,几百个public方法,零覆盖率。我把项目拉到本地,用GitHub Copilot从头加测试,手写了不到20行pom配置,剩下的全部交给AI对话完成。最后JaCoCo报告拉出来:行覆盖率92%,分支覆盖率87%。
整个过程花了大概四十分钟,生成67个测试用例。换手写,至少一下午,而且大概率没有AI写得全面。
下面我把完整的操作流程拆成五个步骤,你看完就能直接上手。
第一步:让AI理解你的代码

很多人一上来就对着Copilot说"给这个类写测试",结果AI写出来的测试驴唇不对马嘴——因为AI根本不知道这个类在整个项目里扮演什么角色。
正确的做法是:先让AI读代码,做总结。
我用Copilot Chat打开项目,输入了这条提示词:
请阅读 com.example.util 包下的所有类,总结每个类的功能、入口方法和关键逻辑分支
Copilot花了几秒钟读代码,给出了一个结构清晰的类功能清单,大概长这样:
DateUtils — 日期工具类
- 入口方法:formatDate(LocalDate, String)、daysBetween(LocalDate, LocalDate)、parseFlexible(String)、isWeekend(LocalDate)、firstDayOfMonth(LocalDate)
- 关键分支:parseFlexible内部分别处理了横线-和斜线/两种分隔符;isWeekend依赖DayOfWeek枚举,注意周六周日判定
StringUtils — 字符串安全处理类
- 入口方法:safeSubstring(String, int, int)、maskPhone(String)、isEmpty(String)、truncate(String, int)
- 关键分支:safeSubstring对负数索引和越界索引都有防御逻辑;maskPhone只对11位手机号做掩码,非11位返回原值
FileUtils — 文件读写类
- 入口方法:readToString(String)、writeToFile(String, String)、listFiles(String)
- 关键分支:readToString内部catch了IOException,文件不存在时返回空字符串""
JsonUtils — JSON序列化封装
- 入口方法:toJson(Object)、fromJson(String, Class)、toPrettyJson(Object)
- 关键分支:fromJson对各种非法JSON输入做了try-catch,抛出业务异常而非底层异常
CollectionUtils — 集合操作类
- 入口方法:paginate(List, int, int)、safeList(List)、groupBy(List, Function)
- 关键分支:paginate对pageSize=0和page=0有防御;safeList对null入参返回空列表
看到没有?Copilot连哪些方法内部有异常处理、哪些有特殊逻辑分支都给你标出来了。你脑子里立刻对每个类的"暗坑"有了数。
这一步有一个特别容易忽视的价值:你在读AI的总结时,会发现代码里你自己都忘了的细节。 比如我早就忘了FileUtils.readToString内部悄悄吞了IOException——这个信息在后面写测试时会直接救你一命。
第二步:逐个类生成测试
总结看完了,正式开始写测试。别想着一次性给AI七八个类让它一起搞定,按类逐个来。每个类一条独立的提示词,方便你后续修改和微调。
以 DateUtils 为例,我的提示词是这样写的:
为DateUtils类生成完整的单元测试,要求:
1. 覆盖所有public方法
2. 每个方法至少测试正常输入、边界值、null参数三种场景
3. 使用JUnit 5 + AssertJ
4. 测试方法名使用中文描述(@DisplayName)
5. 年份跨度和时区变化要覆盖
Copilot生成的第一个版本大概长这样:
@Test
@DisplayName("格式化日期-正常输入应返回指定格式字符串")
void formatDate_ValidInput_ReturnsFormattedString() {
LocalDate date = LocalDate.of(2026, 7, 18);
String result = DateUtils.formatDate(date, "yyyy-MM-dd");
assertThat(result).isEqualTo("2026-07-18");
}
@Test
@DisplayName("格式化日期-传入null应抛IllegalArgumentException")
void formatDate_NullDate_ThrowsException() {
assertThatThrownBy(() -> DateUtils.formatDate(null, "yyyy-MM-dd"))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("计算两个日期间隔天数-跨年场景")
void daysBetween_CrossYear_ReturnsCorrectDays() {
LocalDate start = LocalDate.of(2025, 12, 28);
LocalDate end = LocalDate.of(2026, 1, 3);
long result = DateUtils.daysBetween(start, end);
assertThat(result).isEqualTo(6);
}
@Test
@DisplayName("判断是否周末-周六应返回true")
void isWeekend_Saturday_ReturnsTrue() {
LocalDate saturday = LocalDate.of(2026, 7, 18);
boolean result = DateUtils.isWeekend(saturday);
assertThat(result).isTrue();
}
@Test
@DisplayName("获取当月第一天-应该返回1号")
void firstDayOfMonth_MidMonth_ReturnsFirstDay() {
LocalDate date = LocalDate.of(2026, 7, 15);
LocalDate result = DateUtils.firstDayOfMonth(date);
assertThat(result.getDayOfMonth()).isEqualTo(1);
}
接着处理 StringUtils。同样的套路:
为StringUtils类生成完整单元测试,特别注意:
- safeSubstring的负数索引和越界索引
- maskPhone的非11位号码
- truncate的超过和不超过最大长度
Copilot生成的 StringUtils 测试长这样:
@Test
@DisplayName("安全截取-正常索引应返回正确子串")
void safeSubstring_ValidIndices_ReturnsCorrectSubstring() {
String result = StringUtils.safeSubstring("HelloWorld", 0, 5);
assertThat(result).isEqualTo("Hello");
}
@Test
@DisplayName("安全截取-结束索引越界应自动截取到末尾")
void safeSubstring_EndIndexExceedsLength_TruncatesToEnd() {
String result = StringUtils.safeSubstring("HelloWorld", 3, 100);
assertThat(result).isEqualTo("loWorld");
}
@Test
@DisplayName("安全截取-起始索引为负数应从0开始")
void safeSubstring_NegativeStart_StartsFromZero() {
String result = StringUtils.safeSubstring("HelloWorld", -5, 7);
assertThat(result).isEqualTo("HelloWo");
}
@Test
@DisplayName("手机号掩码-11位手机号应正确掩码中间四位")
void maskPhone_ValidPhone_ReturnsMasked() {
String result = StringUtils.maskPhone("13812345678");
assertThat(result).isEqualTo("138****5678");
}
@Test
@DisplayName("手机号掩码-非11位号码应原样返回")
void maskPhone_InvalidLength_ReturnsOriginal() {
String result = StringUtils.maskPhone("12345");
assertThat(result).isEqualTo("12345");
}
再处理 FileUtils。这里我多留了个心眼,提示里主动提了那个吞IOException的细节:
为FileUtils类生成单元测试:
- readToString需要测试文件存在、文件不存在(返回空串)、文件内容为空三种场景
- writeToFile需要测试正常写入、路径不存在时抛异常
- 文件操作测试使用@TempDir创建临时目录
你看,如果第一步骤里你没记住那个吞异常的细节,这一步你压根不会想到要去测"文件不存在返回空串"这个场景。
第三步:纠正AI的盲区
这是整个流程里最有意思的一步。
AI生成的第一个版本看起来已经挺周到了——正常路径有、边界值有、异常有。但仔细看,它会漏掉一些只有"知道代码怎么写的人"才能发现的场景。
盲区案例1:日期分隔符
我的 DateUtils.parseFlexible 方法内部同时支持两种分隔符——横线 2026-07-18 和斜线 2026/07/18。但AI看到方法名里有"flexible",默认只生成了最常见的横线格式测试。你手动点一下:
DateUtils.parseFlexible 支持斜杠分隔的日期格式,请补充这个场景的测试
Copilot秒懂,立刻生成:
@Test
@DisplayName("灵活解析-斜杠格式应正确解析")
void parseFlexible_SlashFormat_ParsesCorrectly() {
LocalDate result = DateUtils.parseFlexible("2026/07/18");
assertThat(result).isEqualTo(LocalDate.of(2026, 7, 18));
}
@Test
@DisplayName("灵活解析-非法日期格式应抛异常")
void parseFlexible_InvalidFormat_ThrowsException() {
assertThatThrownBy(() -> DateUtils.parseFlexible("not-a-date"))
.isInstanceOf(IllegalArgumentException.class);
}
盲区案例2:吞掉的异常
前面提到了,FileUtils.readToString 内部catch了 IOException,文件不存在时返回空串。Copilot在第一版里只测了"文件存在、正常返回"这个场景。你得告诉它:
FileUtils.readToString在文件不存在时内部catch了IOException并返回空字符串""
请补充测试这个行为
Copilot补上:
@Test
@DisplayName("读取文件-文件不存在应返回空字符串")
void readToString_FileNotFound_ReturnsEmptyString() {
String result = FileUtils.readToString("/nonexistent/path.txt");
assertThat(result).isEmpty();
}
一个隐藏的bug就这样被测试覆盖了。如果有一天这个文件路径配置错了,单元测试会立刻告诉你,而不是让生产环境在IO异常时直接崩掉。
盲区案例3:JSON的非法输入
JsonUtils.fromJson 有个隐藏逻辑——它把底层Jackson的异常包了一层,转成自定义的业务异常。Copilot生成的测试里只测了"正常的JSON解析",完全没有"非法JSON抛自定义异常"这个场景。
JsonUtils.fromJson对非法JSON输入会抛出JsonUtilException(自定义异常),请补充这个场景
Copilot补上:
@Test
@DisplayName("JSON解析-非法JSON应抛自定义异常")
void fromJson_InvalidJson_ThrowsJsonUtilException() {
assertThatThrownBy(() -> JsonUtils.fromJson("{broken json", Map.class))
.isInstanceOf(JsonUtilException.class);
}
你注意到规律了吗?AI的盲区集中在"代码里有但方法签名上看不出来"的那些行为。 包括但不限于:内部catch异常后的兜底逻辑、数据类型转换的特殊规则、业务上约定俗成但代码里只有一行注释的"魔数"判断。这些东西你没法指望AI自己发现,因为它的训练数据里没有你的业务代码上下文。但只要你提一句,它就能立刻补上对应的测试。
第四步:边界值轰炸
AI写测试最擅长的一件事就是边界值——因为边界值场景太套路了,不同语言、不同框架之间的差异很小。你只需要告诉AI一句话,它就能给你扔出来十几个测例。
针对 CollectionUtils,我在Copilot Chat里直接说:
请为CollectionUtils补充以下边界场景的测试:
- 空集合、null集合
- 单元素集合、pageSize大于总数
- 超大集合分页(10000条数据)
- page为负数、pageSize为负数、pageSize为0
Copilot一口气生成了一堆测试用例,我挑几个典型的列一下:
@Test
@DisplayName("分页-空集合返回空列表")
void paginate_EmptyList_ReturnsEmptyList() {
List<String> result = CollectionUtils.paginate(Collections.emptyList(), 1, 10);
assertThat(result).isEmpty();
}
@Test
@DisplayName("分页-只有1条数据且pageSize大于总数")
void paginate_SingleElement_PageSizeLargerThanTotal() {
List<String> list = List.of("only");
List<String> result = CollectionUtils.paginate(list, 1, 100);
assertThat(result).hasSize(1).containsExactly("only");
}
@Test
@DisplayName("分页-of负应返回第一页")
void paginate_NegativePage_ReturnsFirstPage() {
List<String> list = List.of("a", "b", "c");
List<String> result = CollectionUtils.paginate(list, -1, 2);
assertThat(result).hasSize(2).containsExactly("a", "b");
}
@Test
@DisplayName("分页-pageSize为0应返回空列表或默认处理")
void paginate_ZeroPageSize_ReturnsEmptyOrDefault() {
List<String> list = List.of("a", "b", "c");
List<String> result = CollectionUtils.paginate(list, 1, 0);
assertThat(result).isEmpty();
}
然后是 DateUtils 的时间边界值。日期是最容易出现边界bug的类型——闰年、月末、跨年、冬令时夏令时切换,每个都是坑。我让AI专门生成了一组"日期极端值"测试。
我用表格的形式整理一下AI生成的边界值测试覆盖场景(这才是测试用例设计该有的样子,而不是对着代码一行行复制):
| 测试类 | 边界场景 | 测试值 | 预期行为 |
|---|---|---|---|
| DateUtils | 闰年2月 | 2024-02-29 |
正确格式化 |
| DateUtils | 非闰年2月 | 2023-02-29 |
抛异常(无效日期) |
| DateUtils | 月底滚动 | 2026-01-31 +1月 |
正确处理月尾 |
| DateUtils | 跨年跨度 | 2010-01-01到2030-12-31 |
正确计算天数 |
| DateUtils | 同一天差 | 当天和当天 | 返回0 |
| DateUtils | UNIX纪元 | 1970-01-01 |
正确处理 |
| StringUtils | 空字符串 | "" |
判空返回true |
| StringUtils | 纯空格 | " " |
判空取决于实现 |
| StringUtils | 超长字符串 | 10000字符截断时 | 正确截取 |
| StringUtils | 特殊字符 | "手机号:13812345678" |
掩码正确 |
| CollectionUtils | null输入 | paginate(null,1,10) | 不抛NPE |
| CollectionUtils | 超大基集 | 100000条数据分页 | 内存+性能 |
| FileUtils | 空文件 | 文件存在但0字节 | read返回空串 |
| FileUtils | 超大文件 | 10MB文本 | 内存不溢出 |
| JsonUtils | 嵌套对象 | 复杂多层级JSON | 正确序列化/反序列化 |
我特别喜欢这个表格——不是因为排版好看,而是因为每个场景你都能口头说一遍"这个值我测过了",然后睡得踏实。
关于边界值,我想多说一句:边界值是AI的甜区。 因为边界值的模式非常固定——字符串的null和空和超长、集合的null和空和单元素和超大量、日期的界限和闰年。这些模式在AI的训练数据里出现了无数次,写起来毫不费力。你真正要花精力的是那些业务特有的、代码里有但是方法签名看不见的逻辑。
第五步:验证覆盖率

测试代码写完了,跑一下覆盖率检测是真的爽。
我把所有生成的测试代码保存好,在 pom.xml 里加上JaCoCo配置:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
然后在终端里跑:
mvn clean test jacoco:report
等进度条走完,打开 target/site/jacoco/index.html 看报告:
- 5个工具类,67个测试用例,全部通过
- 行覆盖率:92%
- 分支覆盖率:87%
- 未覆盖的8%集中在哪里?其实是那些底层的IO异常处理分支——FileUtils.readToString里的IOException虽然catch了,但某些边缘路径(比如文件被锁定、磁盘空间满)在实际的单元测试中很难触发,这些留到集成测试阶段用真实的文件系统去覆盖更合理
如果你认真按照前四步做的,看到92%这个数字不需要惊讶——因为边界值测试加上AI的全面覆盖,实际上已经把95%的常规路径和异常路径都走了。
剩下的那5%到8%,是真正需要你手工介入的地方:那些只能通过伪造底层异常才能达到的分支,需要用Mockito或者PowerMock去mock静态方法或底层API。这一步不是AI不愿意做,而是你的开发环境里可能没有配mock框架,AI不知道。
复盘:AI写测试的三个核心技巧

好,全文最干的三个技巧来了。不绕弯子,直接说。
技巧一:先让AI读代码总结逻辑
这听起来像废话,但90%翻车的人都是跳过这步的。直接丢一句"给这个类写测试"然后抱怨AI不行——先检查一下AI对你的代码了解多少。给它3秒钟读代码,它能从一个盲写的生成器变成一个了解上下文的助手。具体操作就是打开Copilot Chat,输请阅读xx包下的所有类,总结每个类的功能、入口方法和关键逻辑分支,就这么简单。这个总结你还能顺手拿来做技术文档——一箭双雕。
技巧二:手写提示词要具体到场景,别笼统
"给DateUtils写测试"和"为DateUtils生成测试,覆盖所有public方法、每个方法至少测试正常输入+边界值+null三种场景"的区别,就是废稿和成品的区别。AI是一个"你给多细它出多精"的工具。你需要把你脑子里的测试设计思路显式地写进提示词。什么时候用@DisplayName、什么时候检查异常类型、哪些数据是典型的边界值——每个细节都会在输出质量上乘一个倍数。我在第二步写提示词的时候花了大概30秒设计措辞,换来了AI一次性生成的全套测试,这30秒是全程ROI最高的一笔投入。
技巧三:你补充暗坑,AI填常规坑
以前手写测试,你花80%的时间写那些"常规的"测试用例(参数校验、返回值检查、异常断言),只有20%的时间真正思考"这个方法的潜在坑是什么"。AI把常规的80%压缩成了几秒,于是你的精力可以全部集中在那20%的暗坑上——哪个内部catch吞了异常、哪个方法的参数顺序跟常规认知不一样、哪个魔术数字需要专门写文档解释。你得学会"利用AI给你的时间红利"去做更高层次的事情,而不是节省了时间就去刷抖音。
最后说一个感受。这次实验之前,我一直觉得写测试是程序员的良心活,AI再厉害也只能做个不会出错的工具人。但实际跑完JaCoCo报告的那几秒,看着92%的覆盖率数字,我突然理解了"AI辅助开发"到底是什么意思——不是替代你,而是把你从那些"必须做但谁都不想做的重复劳动"里解放出来。
四十分钟。五个类。零行手写测试。67个用例,92%覆盖。
这才是AI编程最实际的价值。
如果你手头也有一个没测试的老项目,别犹豫了,开个Copilot试一下。弄完之后在评论区告诉我你花了多久、覆盖了多少,我好奇你们的数据。下篇文章我聊聊用AI做代码审查——一个比写测试更刺激的场景。
私信回复「666」,一次性领走:
面试宝典:Java 高频考点速查表、HashMap/ConcurrentHashMap 源码笔记、JVM 调优案例、Spring Boot 面试 50 问
AI 编程工具箱:Cursor/Copilot/Codex 六工具对比表、10 个 Prompt 模板、Debug 万能公式、Cursor 速查手册、AI 图片生成入门、30+ 效率工具包
一份资料包,两个专栏都能用。「唠点键盘之外的」,只讲干货。
更多推荐


所有评论(0)