Skip to content

单元测试策略

一、测试哲学:测什么、不测什么

单元测试的目标是验证业务逻辑正确性,而不是框架本身。

应该测试:

  • Service 中的业务规则(余额检查、状态机转换、权限逻辑)
  • 边界条件(空值、超出范围的输入、并发冲突)
  • 异常路径(未找到记录、外部服务失败时的回滚)

不需要测试:

  • repository.save() 是否真的写入了数据库(这是 TypeORM 的责任)
  • NestJS 的 DI 是否正常工作(这是框架的责任)
  • getter/setter(几乎没有逻辑)

二、TestingModule 基础

typescript
// users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

describe('UsersService', () => {
  let service: UsersService;
  let repo: jest.Mocked<Repository<User>>;

  beforeEach(async () => {
    // 创建 Mock Repository(只包含需要用到的方法)
    const mockRepo = {
      find: jest.fn(),
      findOne: jest.fn(),
      findOneBy: jest.fn(),
      create: jest.fn(),
      save: jest.fn(),
      update: jest.fn(),
      delete: jest.fn(),
      softDelete: jest.fn(),
      count: jest.fn(),
      existsBy: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),  // 替换真实 Repository
          useValue: mockRepo,
        },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);
    repo = module.get(getRepositoryToken(User));
  });

  // 每次测试后清除所有 mock 调用记录
  afterEach(() => jest.clearAllMocks());
});

三、测试业务逻辑

正常路径

typescript
describe('create()', () => {
  it('成功创建用户', async () => {
    const dto: CreateUserDto = {
      email: 'test@example.com',
      name: '张三',
      password: 'Password123!',
    };

    const createdUser = { id: 1, ...dto, password: 'hashed', role: Role.USER };

    repo.findOne.mockResolvedValue(null);   // 邮箱未被注册
    repo.create.mockReturnValue(createdUser as User);
    repo.save.mockResolvedValue(createdUser as User);

    const result = await service.create(dto);

    expect(result).toEqual(createdUser);
    expect(repo.findOne).toHaveBeenCalledWith({ where: { email: dto.email } });
    expect(repo.save).toHaveBeenCalledTimes(1);
  });
});

异常路径(关键!)

typescript
describe('create()', () => {
  it('邮箱已注册时抛出 ConflictException', async () => {
    const dto = { email: 'existing@example.com', name: '李四', password: '...' };

    repo.findOne.mockResolvedValue({ id: 1, email: dto.email } as User);

    await expect(service.create(dto)).rejects.toThrow(ConflictException);
    expect(repo.save).not.toHaveBeenCalled();  // 不应写入数据库
  });
});

describe('findOne()', () => {
  it('用户不存在时抛出 NotFoundException', async () => {
    repo.findOne.mockResolvedValue(null);

    await expect(service.findOne(9999)).rejects.toThrow(NotFoundException);
  });

  it('返回找到的用户', async () => {
    const user = { id: 1, email: 'a@b.com' } as User;
    repo.findOne.mockResolvedValue(user);

    const result = await service.findOne(1);

    expect(result).toBe(user);
    expect(repo.findOne).toHaveBeenCalledWith({ where: { id: 1 }, relations: expect.anything() });
  });
});

四、Mock 策略与技巧

三种 Mock 方式

typescript
// 方式 1:jest.fn() — 完全自定义实现
const mockFn = jest.fn().mockResolvedValue({ id: 1 });

// 方式 2:jest.spyOn() — 监听真实方法(可选择是否替换实现)
const spy = jest.spyOn(service, 'findOne').mockResolvedValue(mockUser);
// 恢复原始实现
spy.mockRestore();

// 方式 3:部分 Mock(保留部分真实方法)
const partialMock: Partial<UsersService> = {
  findOne: jest.fn(),
  // create 仍然使用真实实现
};

Mock 外部依赖(不 Mock 被测对象)

typescript
// ❌ 错误:Mock 被测 Service 自身的方法
jest.spyOn(service, 'create').mockResolvedValue(mockUser);
// 这样测不到任何业务逻辑

// ✅ 正确:Mock 外部依赖(Repository、EmailService 等)
repo.save.mockResolvedValue(mockUser);

// ✅ 正确:Mock 间接依赖(ConfigService)
{
  provide: ConfigService,
  useValue: { get: jest.fn().mockReturnValue('test-secret') },
}

Mock 链式调用(QueryBuilder)

typescript
const mockQueryBuilder = {
  where: jest.fn().mockReturnThis(),
  andWhere: jest.fn().mockReturnThis(),
  orderBy: jest.fn().mockReturnThis(),
  skip: jest.fn().mockReturnThis(),
  take: jest.fn().mockReturnThis(),
  leftJoinAndSelect: jest.fn().mockReturnThis(),
  getManyAndCount: jest.fn().mockResolvedValue([[mockUser], 1]),
  getOne: jest.fn().mockResolvedValue(mockUser),
};

// 在 repo mock 中
repo.createQueryBuilder = jest.fn().mockReturnValue(mockQueryBuilder);

五、测试 Guard、Interceptor、Pipe

测试 Guard

typescript
// roles.guard.spec.ts
import { createMock } from '@golevelup/ts-jest';

describe('RolesGuard', () => {
  let guard: RolesGuard;
  let reflector: Reflector;

  beforeEach(() => {
    reflector = new Reflector();
    guard = new RolesGuard(reflector);
  });

  it('未设置角色要求时放行所有用户', () => {
    jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
    const context = createMock<ExecutionContext>();

    expect(guard.canActivate(context)).toBe(true);
  });

  it('用户角色匹配时放行', () => {
    jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Role.ADMIN]);
    const context = createMock<ExecutionContext>({
      switchToHttp: () => ({
        getRequest: () => ({ user: { role: Role.ADMIN } }),
      }),
    });

    expect(guard.canActivate(context)).toBe(true);
  });

  it('用户角色不匹配时拒绝', () => {
    jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Role.ADMIN]);
    const context = createMock<ExecutionContext>({
      switchToHttp: () => ({
        getRequest: () => ({ user: { role: Role.USER } }),
      }),
    });

    expect(guard.canActivate(context)).toBe(false);
  });
});
bash
# @golevelup/ts-jest 提供 createMock 帮助函数
npm install -D @golevelup/ts-jest

测试 Pipe

typescript
describe('ParseIntPipe', () => {
  const pipe = new ParseIntPipe();

  it('将字符串数字转为 number', async () => {
    const result = await pipe.transform('123', { type: 'param', data: 'id' });
    expect(result).toBe(123);
    expect(typeof result).toBe('number');
  });

  it('非数字字符串抛出 BadRequestException', async () => {
    await expect(pipe.transform('abc', { type: 'param', data: 'id' }))
      .rejects.toThrow(BadRequestException);
  });
});

测试 Interceptor

typescript
describe('LoggingInterceptor', () => {
  let interceptor: LoggingInterceptor;
  let mockLogger: jest.Mocked<Logger>;

  beforeEach(() => {
    mockLogger = { log: jest.fn(), error: jest.fn() } as any;
    interceptor = new LoggingInterceptor(mockLogger);
  });

  it('请求完成后记录日志', done => {
    const context = createMock<ExecutionContext>();
    const next: CallHandler = {
      handle: () => of({ data: 'result' }),
    };

    interceptor.intercept(context, next).subscribe(() => {
      expect(mockLogger.log).toHaveBeenCalled();
      done();
    });
  });
});

六、测试事务逻辑

typescript
describe('TransferService', () => {
  let service: TransferService;
  let dataSource: jest.Mocked<DataSource>;
  let mockManager: jest.Mocked<EntityManager>;

  beforeEach(async () => {
    mockManager = {
      findOne: jest.fn(),
      decrement: jest.fn(),
      increment: jest.fn(),
      save: jest.fn(),
    } as any;

    const mockDataSource = {
      transaction: jest.fn(),
    };

    const module = await Test.createTestingModule({
      providers: [
        TransferService,
        { provide: DataSource, useValue: mockDataSource },
      ],
    }).compile();

    service = module.get(TransferService);
    dataSource = module.get(DataSource);
  });

  it('转账成功时执行所有操作', async () => {
    const sender = { id: 1, balance: 1000 };
    mockManager.findOne.mockResolvedValue(sender);

    // Mock transaction 回调立即执行
    dataSource.transaction.mockImplementation(async (fn) => fn(mockManager));

    await service.transfer(1, 2, 500);

    expect(mockManager.decrement).toHaveBeenCalledWith(Account, { id: 1 }, 'balance', 500);
    expect(mockManager.increment).toHaveBeenCalledWith(Account, { id: 2 }, 'balance', 500);
  });

  it('余额不足时抛出异常且不执行转账', async () => {
    mockManager.findOne.mockResolvedValue({ id: 1, balance: 100 });
    dataSource.transaction.mockImplementation(async (fn) => fn(mockManager));

    await expect(service.transfer(1, 2, 500)).rejects.toThrow(BadRequestException);
    expect(mockManager.decrement).not.toHaveBeenCalled();
  });
});

七、覆盖率配置

javascript
// jest.config.js
module.exports = {
  moduleFileExtensions: ['js', 'json', 'ts'],
  rootDir: 'src',
  testRegex: '.*\\.spec\\.ts$',
  transform: { '^.+\\.(t|j)s$': 'ts-jest' },

  collectCoverageFrom: [
    '**/*.(t|j)s',
    '!**/*.module.ts',        // 模块配置文件无逻辑
    '!**/*.entity.ts',        // Entity 无业务逻辑
    '!**/*.dto.ts',           // DTO 只有类定义
    '!**/main.ts',            // 启动文件
    '!**/*.interface.ts',
    '!**/*.mock.ts',
  ],

  coverageThreshold: {
    global: {
      branches: 70,
      functions: 70,
      lines: 70,
      statements: 70,
    },
    // 对关键文件设置更高要求
    './src/auth/auth.service.ts': {
      functions: 100,
      lines: 90,
    },
  },

  coverageReporters: ['text', 'lcov', 'html'],
  testEnvironment: 'node',
};
bash
# 运行测试并查看覆盖率报告
npm run test:cov

# 只运行特定文件的测试
npx jest users.service.spec.ts --watch

# 查看未覆盖的代码行(在 coverage/lcov-report/index.html)
open coverage/lcov-report/index.html

八、测试最佳实践

typescript
// ✅ 测试名称描述行为,而不是实现
it('余额不足时抛出 BadRequestException', ...)  // ✅
it('transfer 方法测试 3', ...)                 // ❌

// ✅ AAA 结构:Arrange(准备)→ Act(执行)→ Assert(断言)
it('成功创建用户', async () => {
  // Arrange
  const dto = { email: 'test@example.com', name: '张三', password: '...' };
  repo.findOne.mockResolvedValue(null);
  repo.save.mockResolvedValue({ id: 1, ...dto });

  // Act
  const result = await service.create(dto);

  // Assert
  expect(result.id).toBe(1);
  expect(result.email).toBe(dto.email);
});

// ✅ 每个 it 只断言一件事(Single Responsibility)
// ❌ 一个测试验证 10 个不同的条件(测试失败时难以定位原因)

// ✅ 使用 beforeEach 重置 Mock(避免测试间污染)
afterEach(() => jest.clearAllMocks());

可运行 Demo: practice/06-testing — 单元测试(13 个)+ e2e 测试(9 个),npm test 直接运行


常见错误

错误原因解决
测试间互相污染Mock 状态未重置beforeEach(() => jest.clearAllMocks())
Cannot read property of undefined依赖未 Mock,注入了 undefined检查 providers 数组,确保所有依赖都有对应 useValue
覆盖率统计包含 node_modulescollectCoverageFrom 未配置设置 "!**/node_modules/**" 排除
Guard/Interceptor 测试 ExecutionContext 构造复杂NestJS 内部类难以手动构建@golevelup/ts-jestcreateMock<ExecutionContext>()
异步测试超时测试中有未 resolve 的 Promise确保 Mock 的方法都 mockResolvedValue(...) 而非留空
e2e 测试数据库状态污染测试间共享同一数据库beforeEachDELETE FROM table 清空;或每次测试用唯一邮箱

NestJS 深度学习体系