返回笔记首页

第 08 集:AI 辅助单元测试

主题配置

一、为什么单元测试很重要

单元测试解决一个核心问题:改了代码,怎么知道有没有把之前好的地方搞坏?

没有测试的项目:

  • 改一个方法,要手动测试所有相关功能
  • 随着项目越来越大,手动测试越来越慢、越来越容易遗漏
  • 最终结果:不敢改代码,技术债越积越多

有测试的项目:

  • 改完代码跑一次 npm test,30 秒知道有没有问题
  • 重构代码有安全网,可以大胆改
  • 新成员接手项目,测试就是最好的文档

二、NestJS 单元测试基础

2.1 测试文件命名规范

plain
被测文件:src/modules/user/user.service.ts
测试文件:src/modules/user/user.service.spec.ts

2.2 基本测试结构

typescript
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 模式)

每个测试用例按三步写:

typescript
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 对象的结构必须对应:

typescript
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 返回值的两种方式

typescript
// 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 验证方法被调用

typescript
// 验证某个方法被调用了
expect(mockPrismaService.user.create).toHaveBeenCalled()

// 验证调用次数
expect(mockPrismaService.user.create).toHaveBeenCalledTimes(1)

// 验证调用时传入的参数
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
    where: { id: 1 },
})

3.4 每次测试前重置 Mock

typescript
beforeEach(() => {
    jest.clearAllMocks() // 清除所有 Mock 的调用记录和返回值设置
})

不重置的话,上一个测试用例设置的 mockResolvedValue 会影响下一个。


四、让 AI 生成高质量测试的 Prompt 技巧

4.1 引用被测文件,不要让 AI 猜

plain
@user.service.ts @create-user.dto.ts @find-users.dto.ts

为 UserService 生成完整的单元测试...

AI 读了实际代码才能生成正确的 Mock 结构和合理的测试数据。

4.2 明确指定测试场景,不要让 AI 自由发挥

模糊的 Prompt(效果差)

plain
为 UserService 生成测试
精确的 Prompt(效果好)
plain
为 UserService 生成测试,覆盖以下场景:
- create:正常创建 / email 重复抛 ConflictException
- findAll:正常分页返回 / 按 username 过滤
- findOne:找到用户 / 找不到抛 NotFoundException
- remove:正常软删除 / 用户不存在抛 NotFoundException

4.3 要求覆盖异常路径,不只是快乐路径

AI 默认倾向于只写成功场景的测试。

明确加上:

plain
每个方法至少覆盖:正常路径(成功)+ 1 个异常路径(失败)

4.4 给 AI 看 Mock 的预期格式

如果 AI 反复生成错误的 Mock 结构,在 Prompt 里给一个正确示例:

plain
Mock 对象的结构应该是:
const mockPrismaService = {
  user: {
    create: jest.fn(),
    findMany: jest.fn(),
    findUnique: jest.fn(),
    update: jest.fn(),
  }
}
注意:方法在 user 属性下,不是直接在 mockPrismaService 下

五、覆盖率报告解读

5.1 运行覆盖率报告

bash
npm run test:cov
# 或者
npm test -- --coverage

5.2 报告字段含义

plain
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。


六、边缘场景的识别与补充

常见的容易遗漏的边缘场景:

数值边界

typescript
// 分页参数
page = 0 // 应该当作第 1 页处理
page = -1 // 应该当作第 1 页处理
pageSize = 0 // 应该用默认值
pageSize = 1000 // 超大分页,应该有上限限制

空值边界

typescript
username = '' // 空字符串
username = null // null
username = undefined // undefined

并发边界

typescript
// 同时创建两个相同 email 的用户
// 这个场景测试层面难以模拟,但可以验证 Service 对 Prisma 的唯一约束错误有没有正确处理

数据关系边界

typescript
// 删除一个被其他表引用的记录(外键约束)
// 更新一个不存在的记录

七、演示操作步骤

准备工作

Step 1:进入 agent-demo 项目

bash
cd agent-demo

Step 2:把现有测试文件临时移走,从零覆盖率开始演示

bash
mkdir /tmp/spec-backup
find src -name "*.spec.ts" -exec mv {} /tmp/spec-backup/ \;

Step 3:确认零覆盖率状态

bash
npm test -- --coverage 2>&1 | tail -20

截图保存 0% 的覆盖率报告。


生成第一份测试文件

Step 1:在 Cursor Chat(Cmd+L)里输入:

plain
@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:运行测试,查看结果

bash
npm test -- user.service.spec.ts --verbose

处理测试失败

常见失败原因一:Mock 结构不对

报错信息类似:

plain
TypeError: mockPrismaService.user.findMany is not a function

在 Chat 里说:

plain
测试报错:mockPrismaService.user.findMany is not a function
请检查 Mock 对象的结构是否正确,user.service.ts 里调用的是 this.prisma.user.findMany(),
所以 Mock 应该在 user 属性下
常见失败原因二:异步处理不对

报错信息类似:

plain
Expected: {"code": 0, ...}
Received: Promise {}

在 Chat 里说:

plain
测试里 service.create() 返回的是 Promise 而不是结果,
检查测试用例里有没有 await,以及 Mock 用的是 mockResolvedValue 还是 mockReturnValue
常见失败原因三:bcrypt 相关

create 测试可能因为真实的 bcrypt 调用失败。

在 Chat 里说:

plain
create 方法里调用了 bcrypt.hash(),在测试里需要 Mock 掉 bcrypt,
请在测试文件顶部加上:
jest.mock('bcrypt', () => ({
  hash: jest.fn().mockResolvedValue('hashed_password'),
  compare: jest.fn().mockResolvedValue(true),
}))

补充边缘场景

Step 1:测试全绿之后,在 Chat 里输入:

plain
在现有测试基础上,补充以下边缘场景:

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 代码:

plain
user.service.ts 的 findAll 方法里,page 参数如果小于 1 应该修正为 1,
请修复这个问题

然后再补充对应的测试。


查看最终覆盖率

bash
npm test -- --coverage

对比开头截图的 0% 和现在的覆盖率数字,重点看:

  • user.service.ts 的 Stmts 覆盖率
  • user.service.ts 的 Branch 覆盖率(分支覆盖率)

查看哪些行还没有被覆盖(报告里标红的行),在 Chat 里说:

plain
覆盖率报告里 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 方法,测试的结构几乎是固定的:

  1. 准备测试数据(Arrange)
  2. 调用被测方法(Act)
  3. 断言结果(Assert)
  4. 验证 Mock 调用情况

这种高度结构化的任务,正是 AI 最擅长的。给 AI 看 Service 的实现,它能快速推断出需要覆盖哪些场景,生成完整的测试用例。

实际节省的时间:一个有 5-6 个方法的 Service,手写测试大概需要 2-3 小时,AI 辅助下 20-30 分钟可以完成,包括调整和验证。


2. NestJS 单元测试基础设施

NestJS 默认使用 Jest,nest new 创建的项目已经配置好了。

关键依赖:

bash
npm install -D @nestjs/testing jest @types/jest ts-jest

jest.config.js(NestJS 默认配置):

javascript
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 模板:

plain
帮我为 @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 生成后经过验证可直接运行的测试文件:

typescript
// 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. 运行测试与覆盖率

bash
# 运行所有测试
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 补充边界场景:

plain
@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。

typescript
// 错误:只 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 的对象属性是只读的。

typescript
// 解决:用 jest.replaceProperty 或在 providers 里直接提供 Mock 对象
{
  provide: PrismaService,
  useValue: {
    user: {
      findUnique: jest.fn(),
      // ... 其他方法
    }
  }
}
问题三:每次测试之间 Mock 状态互相影响

原因:没有在每次测试后清除 Mock。

typescript
afterEach(() => {
    jest.clearAllMocks() // 清除调用记录和返回值设置
})

8. 小结

AI 辅助单元测试的价值不只是"少写代码",更重要的是:

  • 覆盖你没想到的场景:AI 会从被测代码的逻辑出发,推断边界条件
  • 保持测试和代码的一致性:当你让 AI 同时生成代码和测试,两者天然保持一致
  • 测试即文档:生成的测试文件清楚地描述了每个方法的期望行为

好的工作流:写 spec → AI 生成代码 → AI 生成测试 → 跑测试验证 → 测试不过 → 修代码或修测试。这个闭环跑通了,代码质量才真正有保障。