【Nest】单元测试

一、为什么要写单元测试

  • 保持业务逻辑稳定,防回归。
  • 提高重构信心。
  • 文档化接口/行为(测试即规格)。
  • 更快定位 bug(单元测试通常比手动或集成测试更细粒度、更快)。

二、Nest 单元测试的基本思路

  1. 只测试单元(一个类 / 函数):不要启动真实服务器、不要访问真实数据库或第三方服务(因此需要使用 mock 数据),我们只是测试代码逻辑,不测试数据库操作 / 传参(class-transformer...) 等等。
  2. 使用依赖注入(DI)+ mock :通过 Nest 的 Test.createTestingModule 创建模块,把依赖替换成 mock(手工或 jest mock)。
  3. 断言行为而非实现:关注方法返回、调用了哪些依赖、抛出哪些错误等。
  4. 异步处理要正确 await :测试 async 函数或返回 Promise 的方法时必须 await 或使用 Jest 的 resolves/rejects

三、常用工具与配置(快速)

  • 测试框架:Jest@nestjs/testingts-jest
  • Mock:Jest 的 jest.fn()jest.spyOn() 或手动提供假的对象
  • 运行脚本(package.json):
json 复制代码
"scripts": {
  "test": "jest",
  "test:watch": "jest --watch",
  "test:cov": "jest --coverage"
}

四、示例代码

假设有一个简单业务:UsersService 提供 findById,内部依赖 UserRepository(模拟 TypeORM repository)。

目录:

复制代码
src/
  users/
    users.service.ts
    users.controller.ts
    dto/
    guards/
test/
  users.service.spec.ts
  users.controller.spec.ts

users.service.ts

ts 复制代码
// src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';

@Injectable()
export class UsersService {
  constructor(private readonly userRepo: any /* 实际为 Repository<User> */) {}

  async findById(id: number) {
    const user = await this.userRepo.findOne({ where: { id } });
    if (!user) throw new NotFoundException(`User ${id} not found`);
    return user;
  }

  async create(dto: any) {
    return this.userRepo.save(dto);
  }
}

users.service.spec.ts(单元测试:mock repository)

ts 复制代码
// test/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from '../src/users/users.service';
import { NotFoundException } from '@nestjs/common';

describe('UsersService', () => {
  let service: UsersService;
  // 手工创建 repo mock
  const mockRepo = {
    findOne: jest.fn(),
    save: jest.fn(),
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        { provide: 'UserRepository', useValue: mockRepo }, // 以 token 注入时用这种方式
      ],
    })
      // 如果是 constructor 注入具体 token,需要手动替换 providers 的注入,下面示例假设 UsersService 接收 repo 作为第一参数
      .compile();

    // 直接 new 或者 get 如果服务构造签名匹配
    service = new UsersService(mockRepo);
    jest.clearAllMocks();
  });

  it('findById - 返回用户', async () => {
    const fake = { id: 1, name: 'Alice' };
    mockRepo.findOne.mockResolvedValue(fake);

    const res = await service.findById(1);
    expect(res).toEqual(fake);
    expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
  });

  it('findById - 找不到抛 404', async () => {
    mockRepo.findOne.mockResolvedValue(null);
    await expect(service.findById(999)).rejects.toThrow(NotFoundException);
  });

  it('create - 调用 save 并返回', async () => {
    const dto = { name: 'Bob' };
    mockRepo.save.mockResolvedValue({ id: 2, ...dto });
    const res = await service.create(dto);
    expect(res).toEqual({ id: 2, name: 'Bob' });
    expect(mockRepo.save).toHaveBeenCalledWith(dto);
  });
});

要点说明

  • 没有启动 Nest 应用或数据库。
  • mockRepo 使用 jest.fn() 可精确断言调用参数与次数。
  • 测试关注返回值与错误抛出。

users.controller.ts

ts 复制代码
// src/users/users.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(':id')
  async getUser(@Param('id') id: string) {
    return this.usersService.findById(Number(id));
  }
}

users.controller.spec.ts(Controller 的单元测试:mock Service)

ts 复制代码
// test/users.controller.spec.ts
import { UsersController } from '../src/users/users.controller';
import { UsersService } from '../src/users/users.service';

describe('UsersController', () => {
  let controller: UsersController;
  const mockService = { findById: jest.fn() };

  beforeEach(() => {
    controller = new UsersController(mockService as any);
    jest.clearAllMocks();
  });

  it('getUser - 返回调用结果', async () => {
    mockService.findById.mockResolvedValue({ id: 1, name: 'Alice' });
    const res = await controller.getUser('1');
    expect(res).toEqual({ id: 1, name: 'Alice' });
    expect(mockService.findById).toHaveBeenCalledWith(1);
  });
});

要点

  • Controller 测试仅关心路由层调用 service 的行为,不测试 service 的实现(分离关注点)。

测试 Guard / Pipe 的示例

Guard、Pipe、Interceptor 都是可以单独实例化并直接调用对应方法测试的。例如测试一个简单 AuthGuard

ts 复制代码
// test/auth.guard.spec.ts
import { ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '../src/auth/auth.guard';

describe('AuthGuard', () => {
  let guard: AuthGuard;
  beforeEach(() => guard = new AuthGuard());

  it('canActivate - 允许已登录用户', async () => {
    const ctx = {
      switchToHttp: () => ({
        getRequest: () => ({ user: { id: 1 } }),
      }),
    } as unknown as ExecutionContext;
    await expect(guard.canActivate(ctx)).resolves.toBe(true);
  });

  it('canActivate - 拒绝无 user 的请求', async () => {
    const ctx = {
      switchToHttp: () => ({ getRequest: () => ({}) }),
    } as unknown as ExecutionContext;
    await expect(guard.canActivate(ctx)).resolves.toBe(false);
  });
});
相关推荐
编啊编程啊程19 小时前
【011】宠物共享平台
spring boot·log4j·maven·dubbo·宠物
ThreeAu.3 天前
pytest 实战:用例管理、插件技巧、断言详解
python·单元测试·pytest·测试开发工程师
程序员二黑3 天前
Selenium元素定位总失败?这8种定位策略你必须掌握
单元测试·测试·ab测试
卓码软件测评3 天前
第三方课题验收测试机构:【API测试工具Apifox使用指南】
功能测试·测试工具·单元测试·压力测试·可用性测试
兮动人4 天前
Java 单元测试中的 Mockito 使用详解与实战指南
java·开发语言·单元测试
安冬的码畜日常4 天前
【JUnit实战3_01】第一章:JUnit 起步
测试工具·junit·单元测试
程序员二黑4 天前
自动化测试入门:从零开始搭建你的第一个WebUI项目
单元测试·测试·ab测试