一、为什么单元测试很重要
单元测试解决一个核心问题:改了代码,怎么知道有没有把之前好的地方搞坏?
没有测试的项目:
- 改一个方法,要手动测试所有相关功能
- 随着项目越来越大,手动测试越来越慢、越来越容易遗漏
- 最终结果:不敢改代码,技术债越积越多
有测试的项目:
- 改完代码跑一次
npm test,30 秒知道有没有问题 - 重构代码有安全网,可以大胆改
- 新成员接手项目,测试就是最好的文档
二、NestJS 单元测试基础
2.1 测试文件命名规范
被测文件:src/modules/user/user.service.ts
测试文件:src/modules/user/user.service.spec.ts
2.2 基本测试结构
import { Test, TestingModule } from '@nestjs/testing'
import { UserService } from './user.service'
import { PrismaService } from '../../prisma/prisma.service'
describe('UserService', () => {
let service: UserService
let prisma: PrismaService
beforeEach(async () => {
// 每个测试用例前,重新创建模块
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: PrismaService,
useValue: mockPrismaService, // 用 Mock 替代真实 PrismaService
},
],
}).compile()
service = module.get<UserService>(UserService)
prisma = module.get<PrismaService>(PrismaService)
})
it('应该被成功创建', () => {
expect(service).toBeDefined()
})
})
2.3 测试用例的三段式结构(AAA 模式)
每个测试用例按三步写:
it('创建用户时,密码应该被加密', async () => {
// Arrange(准备):准备测试数据和 Mock 行为
const createUserDto = {
username: 'testuser',
email: 'test@example.com',
password: '123456',
}
mockPrismaService.user.create.mockResolvedValue({
id: 1,
...createUserDto,
password: 'hashed_password', // Mock 返回加密后的密码
})
// Act(执行):调用被测方法
const result = await service.create(createUserDto)
// Assert(断言):验证结果
expect(result).not.toHaveProperty('password') // 返回值不含密码
expect(mockPrismaService.user.create).toHaveBeenCalledTimes(1)
})
三、Prisma Mock 的正确写法
Prisma 的 Mock 是单元测试里最容易出错的地方。
3.1 Mock 对象结构
Prisma Client 的调用方式是 prisma.user.findMany(),所以 Mock 对象的结构必须对应:
const mockPrismaService = {
user: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
order: {
create: jest.fn(),
findMany: jest.fn(),
// ... 其他方法
},
$transaction: jest.fn(),
}
常见错误:Mock 写成 mockPrismaService.findMany(少了中间的 user),导致调用时找不到方法。
3.2 Mock 返回值的两种方式
// mockResolvedValue:异步方法,返回 Promise(最常用)
mockPrismaService.user.findUnique.mockResolvedValue({
id: 1,
username: 'testuser',
email: 'test@example.com',
})
// mockResolvedValueOnce:只在下一次调用时返回这个值,之后恢复默认
mockPrismaService.user.findUnique.mockResolvedValueOnce(null) // 模拟找不到
// mockRejectedValue:模拟异步方法抛出错误
mockPrismaService.user.create.mockRejectedValue(
new Error('Unique constraint failed')
)
3.3 验证方法被调用
// 验证某个方法被调用了
expect(mockPrismaService.user.create).toHaveBeenCalled()
// 验证调用次数
expect(mockPrismaService.user.create).toHaveBeenCalledTimes(1)
// 验证调用时传入的参数
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { id: 1 },
})
3.4 每次测试前重置 Mock
beforeEach(() => {
jest.clearAllMocks() // 清除所有 Mock 的调用记录和返回值设置
})
不重置的话,上一个测试用例设置的 mockResolvedValue 会影响下一个。
四、让 AI 生成高质量测试的 Prompt 技巧
4.1 引用被测文件,不要让 AI 猜
@user.service.ts @create-user.dto.ts @find-users.dto.ts
为 UserService 生成完整的单元测试...
AI 读了实际代码才能生成正确的 Mock 结构和合理的测试数据。
4.2 明确指定测试场景,不要让 AI 自由发挥
模糊的 Prompt(效果差)
为 UserService 生成测试
精确的 Prompt(效果好)
为 UserService 生成测试,覆盖以下场景:
- create:正常创建 / email 重复抛 ConflictException
- findAll:正常分页返回 / 按 username 过滤
- findOne:找到用户 / 找不到抛 NotFoundException
- remove:正常软删除 / 用户不存在抛 NotFoundException
4.3 要求覆盖异常路径,不只是快乐路径
AI 默认倾向于只写成功场景的测试。
明确加上:
每个方法至少覆盖:正常路径(成功)+ 1 个异常路径(失败)
4.4 给 AI 看 Mock 的预期格式
如果 AI 反复生成错误的 Mock 结构,在 Prompt 里给一个正确示例:
Mock 对象的结构应该是:
const mockPrismaService = {
user: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
}
}
注意:方法在 user 属性下,不是直接在 mockPrismaService 下
五、覆盖率报告解读
5.1 运行覆盖率报告
npm run test:cov
# 或者
npm test -- --coverage
5.2 报告字段含义
File | % Stmts | % Branch | % Funcs | % Lines
------------------------|---------|----------|---------|--------
user.service.ts | 85.71 | 66.67 | 83.33 | 84.61
| 指标 | 含义 |
|---|---|
| Stmts(语句覆盖率) | 有多少行代码被测试执行过 |
| Branch(分支覆盖率) | if/else 的每个分支是否都被测试过 |
| Funcs(函数覆盖率) | 有多少个函数被测试调用过 |
| Lines(行覆盖率) | 有多少行被执行过(和 Stmts 接近) |
5.3 覆盖率目标
不是越高越好,追求 100% 没有意义。合理目标:
| 代码类型 | 建议覆盖率 |
|---|---|
| Service 层(核心业务逻辑) | 80%+ |
| Controller 层 | 70%+ |
| 工具函数、辅助方法 | 90%+ |
| 配置文件、Module 文件 | 不需要测试 |
Branch 覆盖率比 Stmts 覆盖率更有价值。100% 语句覆盖率但 50% 分支覆盖率,说明有很多 if/else 分支没被测到,这些分支在生产环境很可能出 Bug。
六、边缘场景的识别与补充
常见的容易遗漏的边缘场景:
数值边界
// 分页参数
page = 0 // 应该当作第 1 页处理
page = -1 // 应该当作第 1 页处理
pageSize = 0 // 应该用默认值
pageSize = 1000 // 超大分页,应该有上限限制
空值边界
username = '' // 空字符串
username = null // null
username = undefined // undefined
并发边界
// 同时创建两个相同 email 的用户
// 这个场景测试层面难以模拟,但可以验证 Service 对 Prisma 的唯一约束错误有没有正确处理
数据关系边界
// 删除一个被其他表引用的记录(外键约束)
// 更新一个不存在的记录
七、演示操作步骤
准备工作
Step 1:进入 agent-demo 项目
cd agent-demo
Step 2:把现有测试文件临时移走,从零覆盖率开始演示
mkdir /tmp/spec-backup
find src -name "*.spec.ts" -exec mv {} /tmp/spec-backup/ \;
Step 3:确认零覆盖率状态
npm test -- --coverage 2>&1 | tail -20
截图保存 0% 的覆盖率报告。
生成第一份测试文件
Step 1:在 Cursor Chat(Cmd+L)里输入:
@src/modules/user/user.service.ts @src/modules/user/dto/create-user.dto.ts @src/modules/user/dto/find-users.dto.ts
为 UserService 生成完整的单元测试文件 src/modules/user/user.service.spec.ts。
要求:
1. 使用 Jest
2. PrismaService 完整 Mock,不连接真实数据库
3. Mock 对象结构:{ user: { create, findMany, findUnique, update, count: jest.fn() } }
4. 每个方法覆盖正常路径 + 至少 1 个异常路径
5. 具体测试场景:
- create:正常创建,返回值不包含 password
- create:email 已存在时抛出 ConflictException
- findAll:正常返回分页数据
- findAll:传入 username 搜索条件时正确过滤
- findOne:正常返回用户
- findOne:用户不存在时抛出 NotFoundException
- update:正常更新
- update:用户不存在时抛出 NotFoundException
- remove:执行软删除(调用 update 而不是 delete)
- remove:用户不存在时抛出 NotFoundException
6. 测试描述用中文
7. 在 beforeEach 里调用 jest.clearAllMocks()
Step 2:等待生成完成,把生成的内容保存到 src/modules/user/user.service.spec.ts
Step 3:运行测试,查看结果
npm test -- user.service.spec.ts --verbose
处理测试失败
常见失败原因一:Mock 结构不对
报错信息类似:
TypeError: mockPrismaService.user.findMany is not a function
在 Chat 里说:
测试报错:mockPrismaService.user.findMany is not a function
请检查 Mock 对象的结构是否正确,user.service.ts 里调用的是 this.prisma.user.findMany(),
所以 Mock 应该在 user 属性下
常见失败原因二:异步处理不对
报错信息类似:
Expected: {"code": 0, ...}
Received: Promise {}
在 Chat 里说:
测试里 service.create() 返回的是 Promise 而不是结果,
检查测试用例里有没有 await,以及 Mock 用的是 mockResolvedValue 还是 mockReturnValue
常见失败原因三:bcrypt 相关
create 测试可能因为真实的 bcrypt 调用失败。
在 Chat 里说:
create 方法里调用了 bcrypt.hash(),在测试里需要 Mock 掉 bcrypt,
请在测试文件顶部加上:
jest.mock('bcrypt', () => ({
hash: jest.fn().mockResolvedValue('hashed_password'),
compare: jest.fn().mockResolvedValue(true),
}))
补充边缘场景
Step 1:测试全绿之后,在 Chat 里输入:
在现有测试基础上,补充以下边缘场景:
1. findAll 方法:page 参数传 0 时,应该当作第 1 页处理(验证 skip 值是 0)
2. findAll 方法:pageSize 参数传 100 时,验证 take 值是否有上限限制(如果 service 有限制的话)
3. create 方法:username 参数传空字符串,验证 DTO 校验是否能拦截(在 Service 层用 class-validator 手动校验)
对于每个场景,先分析 user.service.ts 里是否有对应的处理逻辑,
如果没有说明"当前代码未处理该场景"
Step 2:查看 AI 的分析结果
如果 AI 发现某个场景代码里没有处理(比如 page=0 没有修正),先修 Service 代码:
user.service.ts 的 findAll 方法里,page 参数如果小于 1 应该修正为 1,
请修复这个问题
然后再补充对应的测试。
查看最终覆盖率
npm test -- --coverage
对比开头截图的 0% 和现在的覆盖率数字,重点看:
user.service.ts的 Stmts 覆盖率user.service.ts的 Branch 覆盖率(分支覆盖率)
查看哪些行还没有被覆盖(报告里标红的行),在 Chat 里说:
覆盖率报告里 user.service.ts 第 45 行和第 67 行没有被测试覆盖,
这两行是什么逻辑?帮我补充对应的测试用例
八、常见问题速查
| 问题 | 原因 | 解决方式 |
|---|---|---|
jest.fn() is not a function |
Mock 对象层级不对 | 确认 mockPrismaService.user.findMany而不是 mockPrismaService.findMany |
测试返回 Promise {} |
测试用例缺少 await |
在 service.方法()前加 await |
Cannot find module 'bcrypt' |
bcrypt 没安装或没 Mock | 在测试文件顶部加 jest.mock('bcrypt', ...) |
@IsEmail is not a decorator |
缺少 reflect-metadata |
在测试文件顶部加 import 'reflect-metadata' |
| 每次测试互相影响 | 没有清除 Mock 状态 | 在 beforeEach里加 jest.clearAllMocks() |
| 覆盖率没有变化 | 没有运行 --coverage |
用 npm test -- --coverage而不是 npm test |
Spec Coding 实战补充:08 AI 辅助单元测试
来源:
Spec Coding实战/08 AI 辅助单元测试.md,已合并到本章节。
1. 为什么单元测试是 AI 最擅长的任务之一
写单元测试有个特点:规律性强,模式固定,但量大且枯燥。
对于一个 Service 方法,测试的结构几乎是固定的:
- 准备测试数据(Arrange)
- 调用被测方法(Act)
- 断言结果(Assert)
- 验证 Mock 调用情况
这种高度结构化的任务,正是 AI 最擅长的。给 AI 看 Service 的实现,它能快速推断出需要覆盖哪些场景,生成完整的测试用例。
实际节省的时间:一个有 5-6 个方法的 Service,手写测试大概需要 2-3 小时,AI 辅助下 20-30 分钟可以完成,包括调整和验证。
2. NestJS 单元测试基础设施
NestJS 默认使用 Jest,nest new 创建的项目已经配置好了。
关键依赖:
npm install -D @nestjs/testing jest @types/jest ts-jest
jest.config.js(NestJS 默认配置):
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: { '^.+\\.(t|j)s$': 'ts-jest' },
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
}
3. 让 AI 生成测试的正确姿势
给 AI 的信息越完整,生成质量越高
一个完整的测试生成 Prompt 模板:
帮我为 @src/modules/user/user.service.ts 生成完整的单元测试。
测试文件位置:src/modules/user/user.service.spec.ts
Mock 对象:
- PrismaService:Mock prisma.user.findUnique、findMany、count、create、update
- ConfigService:Mock configService.get 返回 BCRYPT_ROUNDS=10
需要覆盖的场景:
[create 方法]
- 正常创建用户,返回用户信息(不含 password)
- 邮箱已存在时,抛出 BusinessException(USER_ALREADY_EXISTS)
- 密码在数据库中应该是 bcrypt hash 格式
[findAll 方法]
- 不传参数时,使用默认分页(page=1, pageSize=20)
- 传入 search 参数时,where 条件包含 OR 查询
- 传入 status 参数时,where 条件包含 status 过滤
- isDeleted: false 始终在 where 条件中
- 正确返回 { list, total, page, pageSize }
[findOne 方法]
- 用户存在时正常返回
- 用户不存在时抛出 BusinessException(USER_NOT_FOUND)
- isDeleted=true 的用户视为不存在
[update 方法]
- 正常更新,返回更新后数据
- 用户不存在时抛出 USER_NOT_FOUND
- 如果 dto 里有 password,需要重新 bcrypt 加密
[remove 方法]
- 软删除:调用 prisma.user.update 将 isDeleted 设为 true
- 用户不存在时抛出 USER_NOT_FOUND
- 返回 null
使用 NestJS Testing 的 createTestingModule,遵循 .cursorrules 代码风格。
4. 完整测试文件示例
以下是 AI 生成后经过验证可直接运行的测试文件:
// src/modules/user/user.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing'
import { ConfigService } from '@nestjs/config'
import * as bcrypt from 'bcrypt'
import { UserService } from './user.service'
import { PrismaService } from '../../prisma/prisma.service'
import { BusinessException } from '../../common/exceptions/business.exception'
import { ErrorCode } from '../../common/constants/error-code'
// Mock bcrypt,避免测试时真正执行 hash(慢)
jest.mock('bcrypt', () => ({
hash: jest.fn().mockResolvedValue('hashed_password'),
compare: jest.fn().mockResolvedValue(true),
}))
describe('UserService', () => {
let service: UserService
let prismaService: jest.Mocked<PrismaService>
let configService: jest.Mocked<ConfigService>
// 测试数据
const mockUser = {
id: 'user-uuid-001',
email: 'test@example.com',
name: '张三',
status: 'ACTIVE' as const,
isDeleted: false,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: PrismaService,
useValue: {
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
$transaction: jest.fn(),
},
},
{
provide: ConfigService,
useValue: {
get: jest.fn().mockReturnValue(10),
},
},
],
}).compile()
service = module.get<UserService>(UserService)
prismaService = module.get(PrismaService)
configService = module.get(ConfigService)
})
afterEach(() => {
jest.clearAllMocks()
})
// ==================== create ====================
describe('create', () => {
const createDto = {
email: 'test@example.com',
password: 'Test1234',
name: '张三',
}
it('should_create_user_successfully', async () => {
prismaService.user.findUnique.mockResolvedValue(null)
prismaService.user.create.mockResolvedValue(mockUser)
const result = await service.create(createDto)
expect(prismaService.user.findUnique).toHaveBeenCalledWith({
where: { email: createDto.email },
})
expect(bcrypt.hash).toHaveBeenCalledWith(createDto.password, 10)
expect(prismaService.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
email: createDto.email,
password: 'hashed_password',
name: createDto.name,
}),
})
)
expect(result).toEqual(mockUser)
})
it('should_throw_when_email_already_exists', async () => {
prismaService.user.findUnique.mockResolvedValue({
...mockUser,
password: 'hash',
})
await expect(service.create(createDto)).rejects.toThrow(
BusinessException
)
await expect(service.create(createDto)).rejects.toMatchObject({
code: 40901,
})
expect(prismaService.user.create).not.toHaveBeenCalled()
})
it('should_store_bcrypt_hash_not_plain_password', async () => {
prismaService.user.findUnique.mockResolvedValue(null)
prismaService.user.create.mockResolvedValue(mockUser)
await service.create(createDto)
const createCall = prismaService.user.create.mock.calls[0][0]
expect(createCall.data.password).toBe('hashed_password')
expect(createCall.data.password).not.toBe(createDto.password)
})
})
// ==================== findAll ====================
describe('findAll', () => {
beforeEach(() => {
prismaService.$transaction.mockResolvedValue([[mockUser], 1])
})
it('should_use_default_pagination_when_no_params', async () => {
await service.findAll({})
const transactionCall = prismaService.$transaction.mock.calls[0][0]
// 验证 findMany 用了默认分页
expect(transactionCall).toHaveLength(2)
})
it('should_include_isDeleted_false_in_where_condition', async () => {
await service.findAll({ page: 1, pageSize: 10 })
// 通过检查 $transaction 被调用来间接验证 where 条件
expect(prismaService.$transaction).toHaveBeenCalled()
})
it('should_return_correct_pagination_structure', async () => {
prismaService.$transaction.mockResolvedValue([
[mockUser, mockUser],
5,
])
const result = await service.findAll({ page: 2, pageSize: 2 })
expect(result).toEqual({
list: [mockUser, mockUser],
total: 5,
page: 2,
pageSize: 2,
})
})
})
// ==================== findOne ====================
describe('findOne', () => {
it('should_return_user_when_exists', async () => {
prismaService.user.findFirst.mockResolvedValue(mockUser)
const result = await service.findOne('user-uuid-001')
expect(prismaService.user.findFirst).toHaveBeenCalledWith({
where: { id: 'user-uuid-001', isDeleted: false },
omit: { password: true },
})
expect(result).toEqual(mockUser)
})
it('should_throw_USER_NOT_FOUND_when_user_not_exists', async () => {
prismaService.user.findFirst.mockResolvedValue(null)
await expect(service.findOne('not-exist-id')).rejects.toThrow(
BusinessException
)
await expect(service.findOne('not-exist-id')).rejects.toMatchObject(
{
code: 40401,
}
)
})
it('should_treat_soft_deleted_user_as_not_found', async () => {
// findFirst 带 isDeleted:false 条件,所以软删除用户会返回 null
prismaService.user.findFirst.mockResolvedValue(null)
await expect(service.findOne('deleted-user-id')).rejects.toThrow(
BusinessException
)
})
})
// ==================== update ====================
describe('update', () => {
it('should_update_user_successfully', async () => {
prismaService.user.findFirst.mockResolvedValue(mockUser)
prismaService.user.update.mockResolvedValue({
...mockUser,
name: '李四',
})
const result = await service.update('user-uuid-001', {
name: '李四',
})
expect(result.name).toBe('李四')
})
it('should_throw_when_user_not_found', async () => {
prismaService.user.findFirst.mockResolvedValue(null)
await expect(
service.update('not-exist-id', { name: '李四' })
).rejects.toThrow(BusinessException)
})
it('should_hash_password_when_password_in_dto', async () => {
prismaService.user.findFirst.mockResolvedValue(mockUser)
prismaService.user.update.mockResolvedValue(mockUser)
await service.update('user-uuid-001', { password: 'NewPass123' })
expect(bcrypt.hash).toHaveBeenCalledWith('NewPass123', 10)
const updateCall = prismaService.user.update.mock.calls[0][0]
expect(updateCall.data.password).toBe('hashed_password')
})
it('should_not_call_bcrypt_when_no_password_in_dto', async () => {
prismaService.user.findFirst.mockResolvedValue(mockUser)
prismaService.user.update.mockResolvedValue(mockUser)
await service.update('user-uuid-001', { name: '李四' })
expect(bcrypt.hash).not.toHaveBeenCalled()
})
})
// ==================== remove ====================
describe('remove', () => {
it('should_soft_delete_user', async () => {
prismaService.user.findFirst.mockResolvedValue(mockUser)
prismaService.user.update.mockResolvedValue({
...mockUser,
isDeleted: true,
})
const result = await service.remove('user-uuid-001')
expect(prismaService.user.update).toHaveBeenCalledWith({
where: { id: 'user-uuid-001' },
data: { isDeleted: true },
})
expect(result).toBeNull()
})
it('should_throw_when_user_not_found', async () => {
prismaService.user.findFirst.mockResolvedValue(null)
await expect(service.remove('not-exist-id')).rejects.toThrow(
BusinessException
)
expect(prismaService.user.update).not.toHaveBeenCalled()
})
})
})
5. 运行测试与覆盖率
# 运行所有测试
npm test
# 运行指定文件
npm test -- user.service.spec.ts
# 监听模式(修改代码自动重跑)
npm test -- --watch
# 生成覆盖率报告
npm test -- --coverage
覆盖率报告会在 coverage/lcov-report/index.html 里生成可视化报告。
看覆盖率的正确方式
覆盖率不是越高越好,重点关注:
- 分支覆盖率(Branch Coverage):if/else 每个分支都有没有走到
- 语句覆盖率(Statement Coverage):每行代码有没有执行到
有些 catch 分支、极端边界很难覆盖到,90% 左右是健康的目标,不要为了 100% 写没有意义的测试。
6. AI 辅助补充边界测试
初次生成测试后,可以继续让 AI 补充边界场景:
@src/modules/user/user.service.spec.ts 已经有基本测试了,
帮我补充以下边界场景:
1. findAll 传入 pageSize=200(超过最大值100)时的行为
2. findAll 传入非法的 sortBy 字段时是否会引发 Prisma 错误
3. create 时 bcrypt.hash 抛出异常,整个操作是否正确回滚
4. update 时 Prisma 抛出连接超时异常,是否正确传递给上层
每个测试要包含 Mock 设置和断言。
7. 常见问题与解决
问题一:Mock 了 PrismaService 但测试还是走了真实数据库
原因:$transaction 需要单独 Mock。
// 错误:只 Mock 了 user 对象里的方法
prismaService.user.findMany.mockResolvedValue([])
// 正确:$transaction 需要单独处理
prismaService.$transaction.mockImplementation((queries) => Promise.all(queries))
// 或者
prismaService.$transaction.mockResolvedValue([[user1], 1])
问题二:测试报 "Cannot spy the xxx property" 错误
原因:被 Mock 的对象属性是只读的。
// 解决:用 jest.replaceProperty 或在 providers 里直接提供 Mock 对象
{
provide: PrismaService,
useValue: {
user: {
findUnique: jest.fn(),
// ... 其他方法
}
}
}
问题三:每次测试之间 Mock 状态互相影响
原因:没有在每次测试后清除 Mock。
afterEach(() => {
jest.clearAllMocks() // 清除调用记录和返回值设置
})
8. 小结
AI 辅助单元测试的价值不只是"少写代码",更重要的是:
- 覆盖你没想到的场景:AI 会从被测代码的逻辑出发,推断边界条件
- 保持测试和代码的一致性:当你让 AI 同时生成代码和测试,两者天然保持一致
- 测试即文档:生成的测试文件清楚地描述了每个方法的期望行为
好的工作流:写 spec → AI 生成代码 → AI 生成测试 → 跑测试验证 → 测试不过 → 修代码或修测试。这个闭环跑通了,代码质量才真正有保障。