第2465篇:AI驱动的测试生成——自动生成单元测试和集成测试用例
2026/4/30大约 7 分钟
第2465篇:AI驱动的测试生成——自动生成单元测试和集成测试用例
适读人群:Java工程师、测试工程师、技术负责人 | 阅读时长:约17分钟 | 核心价值:用LLM自动生成高质量的JUnit 5单元测试,覆盖边界条件和异常路径
写测试这件事,大多数工程师的真实态度是:知道应该写,但总是觉得"稍后再补",然后就没有然后了。
我做了一次小调查,问了10个同事"为什么不写测试",答案基本就是三个:
- "太花时间了,写测试的时间可以多写几个功能"
- "不知道要测什么,测不好"
- "测试很快就过时了,需求一改测试就要改"
这三个原因里,前两个是AI可以解决的。
AI生成测试的能力边界
先说清楚AI能做什么,不能做什么:
能做:
- 根据方法签名和实现,生成正常路径的测试用例
- 识别边界条件(null值、空集合、边界数值)并生成对应测试
- 生成异常路径测试(模拟依赖抛出异常)
- 识别需要Mock的依赖,生成Mock代码
- 为已有的测试生成更多边界情况
不能做:
- 理解复杂的业务含义("金额超过10000需要风控审核"这类业务规则,AI不知道)
- 生成端到端的业务场景测试(需要人来定义场景)
- 确保测试有意义(AI生成的测试可能只是验证了代码能运行,而不是验证了业务逻辑)
测试生成引擎设计
整体流程
核心实现
@Service
public class TestGenerationService {
private final JavaParser javaParser;
private final LLMTestGenerator llmGenerator;
private final TestCompiler testCompiler;
private final CoverageAnalyzer coverageAnalyzer;
public TestGenerationResult generate(Path sourceFile, GenerationConfig config) {
// 1. 解析源文件
ParseResult<CompilationUnit> parseResult = javaParser.parse(sourceFile);
if (!parseResult.isSuccessful()) {
return TestGenerationResult.failed("无法解析源文件: " + parseResult.getProblems());
}
CompilationUnit cu = parseResult.getResult().get();
ClassAnalysis analysis = analyzeClass(cu, sourceFile);
// 2. 过滤出需要测试的方法(跳过getter/setter/简单委托等)
List<MethodAnalysis> testableMetho = analysis.getMethods().stream()
.filter(this::isWorthTesting)
.collect(toList());
if (testableMethods.isEmpty()) {
return TestGenerationResult.skipped("没有值得测试的方法");
}
// 3. 生成测试类
String testCode = llmGenerator.generateTestClass(analysis, testableMethods, config);
// 4. 编译验证(最多重试3次)
CompileResult compileResult = compileWithRetry(testCode, analysis, 3);
if (!compileResult.isSuccess()) {
return TestGenerationResult.partialSuccess(testCode, compileResult.getErrors());
}
return TestGenerationResult.success(testCode, compileResult.getTestFile());
}
private boolean isWorthTesting(MethodAnalysis method) {
// 跳过简单的getter/setter
if (method.isAccessor()) return false;
// 跳过toString/hashCode/equals(除非被显式覆盖且有逻辑)
if (method.isBoilerplate() && !method.hasCustomLogic()) return false;
// 跳过private方法(通过public方法间接测)
if (method.isPrivate()) return false;
// 行数太少的方法可能不值得专门测
if (method.getLineCount() < 3 && !method.hasComplexLogic()) return false;
return true;
}
}LLM测试生成器
@Service
public class LLMTestGenerator {
private final ChatClient chatClient;
public String generateTestClass(
ClassAnalysis analysis,
List<MethodAnalysis> methods,
GenerationConfig config) {
String sourceContext = buildSourceContext(analysis, methods);
String prompt = buildGenerationPrompt(analysis, methods, sourceContext, config);
ChatResponse response = chatClient.call(new Prompt(
List.of(
new SystemMessage(TEST_GENERATION_SYSTEM_PROMPT),
new UserMessage(prompt)
),
OpenAiChatOptions.builder()
.withModel("gpt-4o")
.withTemperature(0.2f)
.withMaxTokens(8000)
.build()
));
String generatedCode = response.getResult().getOutput().getContent();
return extractJavaCode(generatedCode);
}
private String buildSourceContext(ClassAnalysis analysis, List<MethodAnalysis> methods) {
StringBuilder sb = new StringBuilder();
sb.append("// 源文件: ").append(analysis.getClassName()).append("\n\n");
sb.append("// 类的完整实现:\n");
sb.append(analysis.getSourceCode()).append("\n\n");
// 提供依赖接口的签名(帮助LLM生成正确的Mock)
sb.append("// 依赖接口签名:\n");
for (DependencyInfo dep : analysis.getDependencies()) {
sb.append("// interface ").append(dep.getInterfaceName()).append(" {\n");
dep.getMethods().forEach(m ->
sb.append("// ").append(m.getSignature()).append(";\n")
);
sb.append("// }\n\n");
}
return sb.toString();
}
private String buildGenerationPrompt(
ClassAnalysis analysis,
List<MethodAnalysis> methods,
String sourceContext,
GenerationConfig config) {
String testFramework = config.isUseMockito() ? "JUnit 5 + Mockito" : "JUnit 5";
return """
为以下Java类生成完整的单元测试:
%s
测试要求:
1. 使用 %s 框架
2. 测试类名: %sTest
3. 每个被测方法至少包含:
- 正常路径测试
- null参数测试(如果参数可以为null)
- 边界值测试(数字的最大/最小值,空字符串,空集合等)
- 依赖抛出异常时的测试
4. 测试方法名要有描述性(如:givenValidInput_whenProcess_thenReturnResult)
5. 使用@DisplayName注解添加可读性强的测试描述
6. 如果有异步逻辑,使用CompletableFuture.get()等待结果
7. 断言尽量具体(不要只是assertNotNull,要验证实际值)
特别关注这些方法的边界条件:
%s
生成完整的Java测试类代码。
""".formatted(
sourceContext,
testFramework,
analysis.getClassName(),
methods.stream()
.map(m -> "- " + m.getName() + ": " + m.getComplexityNote())
.collect(joining("\n"))
);
}
private static final String TEST_GENERATION_SYSTEM_PROMPT = """
你是一个专业的Java测试工程师,擅长编写高质量的单元测试。
生成测试时:
1. 测试要有实际价值,不要写只是验证"方法不抛异常"的无意义测试
2. Mock的行为要真实,不要Mock一个永远不会失败的场景
3. 边界条件是重点:null、空字符串、空列表、最大值、最小值、负数
4. 对于有副作用的方法(修改数据库、发送消息等),验证副作用是否发生
5. 测试相互独立,不要有顺序依赖
只生成Java代码,不要有解释文字。
""";
}自动编译修复
@Service
public class TestCompilerAndFixer {
private final JavaCompiler compiler;
private final ChatClient chatClient;
public CompileResult compileWithRetry(String testCode, ClassAnalysis sourceAnalysis, int maxRetries) {
String currentCode = testCode;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
CompileResult result = compiler.compile(currentCode, sourceAnalysis.getClasspath());
if (result.isSuccess()) {
return result;
}
log.info("编译失败(第{}次尝试),尝试自动修复: {}", attempt, result.getErrors());
if (attempt == maxRetries) {
return result; // 最后一次还是失败,返回错误
}
// 让LLM修复编译错误
String fixedCode = fixCompileErrors(currentCode, result.getErrors(), sourceAnalysis);
if (fixedCode == null) break;
currentCode = fixedCode;
}
return CompileResult.failed("超过最大重试次数");
}
private String fixCompileErrors(
String testCode,
List<CompileError> errors,
ClassAnalysis sourceAnalysis) {
String errorsDescription = errors.stream()
.map(e -> String.format("第%d行: %s", e.getLine(), e.getMessage()))
.collect(joining("\n"));
String fixPrompt = """
以下Java测试代码有编译错误,请修复:
编译错误:
%s
被测试的类信息(用于理解正确的API):
%s
测试代码:
```java
%s
```
请只返回修复后的完整Java代码。
""".formatted(errorsDescription, sourceAnalysis.getSummary(), testCode);
ChatResponse response = chatClient.call(new Prompt(
new UserMessage(fixPrompt),
OpenAiChatOptions.builder()
.withModel("gpt-4o")
.withTemperature(0.1f)
.build()
));
return extractJavaCode(response.getResult().getOutput().getContent());
}
}生成的测试示例
给定这个方法:
public BigDecimal calculateDiscount(BigDecimal originalPrice, int memberLevel, String couponCode) {
if (originalPrice == null || originalPrice.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("价格不合法");
}
BigDecimal discount = BigDecimal.ONE;
// 会员折扣
discount = discount.multiply(getMemberDiscount(memberLevel));
// 优惠券折扣
if (couponCode != null && !couponCode.isBlank()) {
Optional<Coupon> coupon = couponRepository.findByCode(couponCode);
if (coupon.isPresent() && coupon.get().isValid()) {
discount = discount.multiply(coupon.get().getDiscountRate());
}
}
return originalPrice.multiply(discount).setScale(2, RoundingMode.HALF_UP);
}AI会生成类似这样的测试:
@ExtendWith(MockitoExtension.class)
class PriceCalculatorTest {
@Mock
private CouponRepository couponRepository;
@InjectMocks
private PriceCalculator calculator;
@Test
@DisplayName("正常价格和有效优惠券,返回折扣后价格")
void givenValidPriceAndCoupon_whenCalculate_thenApplyDiscount() {
Coupon coupon = new Coupon("SAVE20", new BigDecimal("0.8"), true);
when(couponRepository.findByCode("SAVE20")).thenReturn(Optional.of(coupon));
BigDecimal result = calculator.calculateDiscount(
new BigDecimal("100.00"), 1, "SAVE20"
);
assertThat(result).isEqualByComparingTo(new BigDecimal("72.00")); // 0.9 * 0.8 = 0.72
}
@Test
@DisplayName("price为null时抛出IllegalArgumentException")
void givenNullPrice_whenCalculate_thenThrowException() {
assertThatThrownBy(() -> calculator.calculateDiscount(null, 1, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("价格不合法");
}
@Test
@DisplayName("price为0时抛出IllegalArgumentException")
void givenZeroPrice_whenCalculate_thenThrowException() {
assertThatThrownBy(() -> calculator.calculateDiscount(BigDecimal.ZERO, 1, null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("优惠券code为空时不应用优惠券折扣")
void givenBlankCouponCode_whenCalculate_thenNoApplyCouponDiscount() {
BigDecimal result = calculator.calculateDiscount(
new BigDecimal("100.00"), 1, ""
);
verify(couponRepository, never()).findByCode(any());
assertThat(result).isEqualByComparingTo(new BigDecimal("90.00")); // 只有会员折扣0.9
}
@Test
@DisplayName("优惠券已过期时不应用折扣")
void givenExpiredCoupon_whenCalculate_thenNoApplyCouponDiscount() {
Coupon expiredCoupon = new Coupon("EXPIRED", new BigDecimal("0.8"), false);
when(couponRepository.findByCode("EXPIRED")).thenReturn(Optional.of(expiredCoupon));
BigDecimal result = calculator.calculateDiscount(
new BigDecimal("100.00"), 1, "EXPIRED"
);
assertThat(result).isEqualByComparingTo(new BigDecimal("90.00"));
}
}工程实践经验
最重要的经验:AI生成测试之后,一定要人工review。
不是因为AI生成的测试不对,而是因为测试的价值不只是"代码能跑",更是"测试在描述正确的业务行为"。AI不理解业务,有时候会生成一个逻辑正确但业务上有误的断言。比如,优惠券叠加的计算逻辑,AI按代码逻辑生成了测试,但产品经理说"优惠券不能跟会员折扣叠加",这个规则在代码里没有体现(是个bug),AI生成的测试就掩盖了这个bug。
