Skip to content

测试

自动化测试被认为是任何严肃的软件开发工作的重要组成部分。自动化使得在开发过程中快速轻松地重复单个测试或测试套件成为可能。这有助于确保发布版本满足质量和性能目标。自动化有助于提高覆盖率,并为开发人员提供更快的反馈循环。自动化既提高了单个开发人员的生产力,又确保在关键的开发生命周期节点(如源代码控制签入、功能集成和版本发布)运行测试。

这些测试通常涵盖多种类型,包括单元测试、端到端(e2e)测试、集成测试等。虽然好处毋庸置疑,但设置它们可能很繁琐。Nest 致力于推广开发最佳实践,包括有效的测试,因此它包含以下功能来帮助开发人员和团队构建和自动化测试。Nest:

  • 自动为组件搭建默认的单元测试,为应用程序搭建 e2e 测试
  • 提供默认工具(例如构建隔离模块/应用程序加载器的测试运行器)
  • 提供与 JestSupertest 的开箱即用集成,同时保持对测试工具的不可知性
  • 使 Nest 依赖注入系统在测试环境中可用,以便轻松模拟组件

如前所述,你可以使用任何你喜欢的测试框架,因为 Nest 不强制使用任何特定的工具。只需替换所需的元素(例如测试运行器),你仍然可以享受 Nest 现成测试设施的好处。

安装

首先,安装所需的包:

bash
$ npm i --save-dev @nestjs/testing

单元测试

在下面的示例中,我们测试两个类:CatsControllerCatsService。如前所述,Jest 作为默认的测试框架提供。它充当测试运行器,还提供断言函数和测试替身工具,帮助进行模拟、监视等。在下面的基本测试中,我们手动实例化这些类,并确保控制器和服务履行其 API 契约。

typescript
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

describe('CatsController', () => {
  let catsController: CatsController;
  let catsService: CatsService;

  beforeEach(() => {
    catsService = new CatsService();
    catsController = new CatsController(catsService);
  });

  describe('findAll', () => {
    it('should return an array of cats', async () => {
      const result = ['test'];
      jest.spyOn(catsService, 'findAll').mockImplementation(() => result);

      expect(await catsController.findAll()).toBe(result);
    });
  });
});

提示

将测试文件放在它们测试的类附近。测试文件应该有 .spec.test 后缀。

因为上面的示例很简单,我们实际上没有测试任何 Nest 特定的东西。实际上,我们甚至没有使用依赖注入(注意我们将 CatsService 的实例传递给我们的 catsController)。这种测试形式——我们手动实例化被测试的类——通常被称为隔离测试,因为它独立于框架。让我们介绍一些更高级的功能,帮助你测试更广泛使用 Nest 功能的应用程序。

测试工具

@nestjs/testing 包提供了一组工具,可以实现更强大的测试过程。让我们使用内置的 Test 类重写前面的示例:

typescript
import { Test } from '@nestjs/testing';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

describe('CatsController', () => {
  let catsController: CatsController;
  let catsService: CatsService;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
        controllers: [CatsController],
        providers: [CatsService],
      }).compile();

    catsService = moduleRef.get(CatsService);
    catsController = moduleRef.get(CatsController);
  });

  describe('findAll', () => {
    it('should return an array of cats', async () => {
      const result = ['test'];
      jest.spyOn(catsService, 'findAll').mockImplementation(() => result);

      expect(await catsController.findAll()).toBe(result);
    });
  });
});

Test 类对于提供一个应用程序执行上下文很有用,该上下文本质上模拟了完整的 Nest 运行时,但为你提供了钩子,使管理类实例变得容易,包括模拟和覆盖。Test 类有一个 createTestingModule() 方法,该方法接受一个模块元数据对象作为其参数(与你传递给 @Module() 装饰器的对象相同)。此方法返回一个 TestingModule 实例,该实例反过来提供了几个方法。对于单元测试,重要的是 compile() 方法。此方法引导一个模块及其依赖项(类似于在传统的 main.ts 文件中使用 NestFactory.create() 引导应用程序的方式),并返回一个准备好进行测试的模块。

提示

compile() 方法是异步的,因此必须等待。一旦模块编译完成,你可以使用 get() 方法检索它声明的任何静态实例(控制器和提供者)。

TestingModule 继承自模块引用类,因此具有动态解析作用域提供者(瞬态或请求作用域)的能力。使用 resolve() 方法执行此操作(get() 方法只能检索静态实例)。

typescript
const moduleRef = await Test.createTestingModule({
  controllers: [CatsController],
  providers: [CatsService],
}).compile();

catsService = await moduleRef.resolve(CatsService);

警告

resolve() 方法从其自己的 DI 容器子树返回提供者的唯一实例。每个子树都有一个唯一的上下文标识符。因此,如果你多次调用此方法并比较实例引用,你会发现它们不相等。

提示

这里了解有关模块引用功能的更多信息。

你可以使用自定义提供者覆盖任何提供者的生产版本以用于测试目的,而不是使用生产版本。例如,你可以模拟数据库服务,而不是连接到实时数据库。我们将在下一节中介绍覆盖,但它们也可用于单元测试。

自动模拟

Nest 还允许你定义一个模拟工厂,以应用于所有缺失的依赖项。这对于你在类中有大量依赖项并且模拟所有依赖项需要很长时间和大量设置的情况很有用。要使用此功能,createTestingModule() 需要与 useMocker() 方法链接,传递依赖项模拟的工厂。此工厂可以接受一个可选的令牌,它是一个实例令牌,任何对 Nest 提供者有效的令牌,并返回一个模拟实现。下面是使用 jest-mock 创建通用模拟器和使用 jest.fn()CatsService 创建特定模拟的示例。

typescript
// ...
import { ModuleMocker, MockMetadata } from 'jest-mock';

const moduleMocker = new ModuleMocker(global);

describe('CatsController', () => {
  let controller: CatsController;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      controllers: [CatsController],
    })
      .useMocker((token) => {
        const results = ['test1', 'test2'];
        if (token === CatsService) {
          return { findAll: jest.fn().mockResolvedValue(results) };
        }
        if (typeof token === 'function') {
          const mockMetadata = moduleMocker.getMetadata(
            token,
          ) as MockMetadata<any, any>;
          const Mock = moduleMocker.generateFromMetadata(
            mockMetadata,
          ) as ObjectConstructor;
          return new Mock();
        }
      })
      .compile();

    controller = moduleRef.get(CatsController);
  });
});

你也可以像通常使用自定义提供者一样从测试容器中检索这些模拟,moduleRef.get(CatsService)

提示

通用模拟工厂,如 @golevelup/ts-jest 中的 createMock,也可以直接传递。

提示

REQUESTINQUIRER 提供者无法自动模拟,因为它们已经在上下文中预定义。但是,它们可以使用自定义提供者语法或利用 .overrideProvider 方法来_覆盖_。

端到端测试

与专注于单个模块和类的单元测试不同,端到端(e2e)测试涵盖了更聚合级别的类和模块的交互——更接近最终用户与生产系统的交互类型。随着应用程序的增长,手动测试每个 API 端点的端到端行为变得困难。自动化的端到端测试帮助我们确保系统的整体行为是正确的并满足项目要求。要执行 e2e 测试,我们使用与刚才在单元测试中介绍的类似配置。此外,Nest 使使用 Supertest 库模拟 HTTP 请求变得容易。

typescript
import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { CatsModule } from '../../src/cats/cats.module';
import { CatsService } from '../../src/cats/cats.service';
import { INestApplication } from '@nestjs/common';

describe('Cats', () => {
  let app: INestApplication;
  let catsService = { findAll: () => ['test'] };

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [CatsModule],
    })
      .overrideProvider(CatsService)
      .useValue(catsService)
      .compile();

    app = moduleRef.createNestApplication();
    await app.init();
  });

  it(`/GET cats`, () => {
    return request(app.getHttpServer())
      .get('/cats')
      .expect(200)
      .expect({
        data: catsService.findAll(),
      });
  });

  afterAll(async () => {
    await app.close();
  });
});

提示

如果你使用 Fastify 作为 HTTP 适配器,它需要稍微不同的配置,并具有内置的测试功能:

ts
let app: NestFastifyApplication;

beforeAll(async () => {
  app = moduleRef.createNestApplication<NestFastifyApplication>(
    new FastifyAdapter(),
  );

  await app.init();
  await app.getHttpAdapter().getInstance().ready();
});

it(`/GET cats`, () => {
  return app
    .inject({
      method: 'GET',
      url: '/cats',
    })
    .then((result) => {
      expect(result.statusCode).toEqual(200);
      expect(result.payload).toEqual(/* expectedPayload */);
    });
});

afterAll(async () => {
  await app.close();
});

在此示例中,我们基于前面描述的一些概念。除了我们之前使用的 compile() 方法之外,我们现在使用 createNestApplication() 方法来实例化完整的 Nest 运行时环境。

需要考虑的一个注意事项是,当使用 compile() 方法编译应用程序时,HttpAdapterHost#httpAdapter 在那时将是未定义的。这是因为在此编译阶段期间还没有创建 HTTP 适配器或服务器。如果你的测试需要 httpAdapter,你应该使用 createNestApplication() 方法创建应用程序实例,或者重构你的项目以避免在初始化依赖关系图时出现此依赖关系。

好的,让我们分解这个示例:

我们在 app 变量中保存对正在运行的应用程序的引用,以便我们可以使用它来模拟 HTTP 请求。

我们使用 Supertest 的 request() 函数模拟 HTTP 测试。我们希望这些 HTTP 请求路由到我们正在运行的 Nest 应用程序,因此我们将 request() 函数传递给底层 Nest 的 HTTP 侦听器的引用(而 HTTP 侦听器又可能由 Express 平台提供)。因此构造 request(app.getHttpServer())。对 request() 的调用为我们提供了一个包装的 HTTP 服务器,现在连接到 Nest 应用程序,它公开了模拟实际 HTTP 请求的方法。例如,使用 request(...).get('/cats') 将向 Nest 应用程序发起一个请求,该请求与通过网络传入的实际 HTTP 请求(如 get '/cats')相同。

在此示例中,我们还提供了 CatsService 的替代(测试替身)实现,它只返回一个我们可以测试的硬编码值。使用 overrideProvider() 提供这样的替代实现。类似地,Nest 提供了使用 overrideModule()overrideGuard()overrideInterceptor()overrideFilter()overridePipe() 方法分别覆盖模块、守卫、拦截器、过滤器和管道的方法。

每个覆盖方法(除了 overrideModule())都返回一个具有 3 个不同方法的对象,这些方法镜像了为自定义提供者描述的方法:

  • useClass:你提供一个类,该类将被实例化以提供覆盖对象(提供者、守卫等)的实例。
  • useValue:你提供一个将覆盖对象的实例。
  • useFactory:你提供一个返回将覆盖对象的实例的函数。

另一方面,overrideModule() 返回一个具有 useModule() 方法的对象,你可以使用该方法提供一个将覆盖原始模块的模块,如下所示:

typescript
const moduleRef = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideModule(CatsModule)
  .useModule(AlternateCatsModule)
  .compile();

每种覆盖方法类型反过来都返回 TestingModule 实例,因此可以与流畅风格中的其他方法链接。你应该在这样的链的末尾使用 compile() 来使 Nest 实例化和初始化模块。

此外,有时你可能想要提供自定义记录器,例如在运行测试时(例如,在 CI 服务器上)。使用 setLogger() 方法并传递一个满足 LoggerService 接口的对象,以指示 TestModuleBuilder 在测试期间如何记录(默认情况下,只有"错误"日志将记录到控制台)。

编译的模块有几个有用的方法,如下表所述:

createNestApplication() 基于给定模块创建并返回一个 Nest 应用程序(INestApplication 实例)。 请注意,你必须使用 init() 方法手动初始化应用程序。
createNestMicroservice() 基于给定模块创建并返回一个 Nest 微服务(INestMicroservice 实例)。
get() 检索应用程序上下文中可用的控制器或提供者(包括守卫、过滤器等)的静态实例。继承自模块引用类。
resolve() 检索应用程序上下文中可用的控制器或提供者(包括守卫、过滤器等)的动态创建的作用域实例(请求或瞬态)。继承自模块引用类。
select() 浏览模块的依赖关系图;可用于从所选模块检索特定实例(与 get() 方法中的严格模式(strict: true)一起使用)。

提示

将 e2e 测试文件保存在 test 目录中。测试文件应该有 .e2e-spec 后缀。

覆盖全局注册的增强器

如果你有一个全局注册的守卫(或管道、拦截器或过滤器),你需要采取一些额外的步骤来覆盖该增强器。回顾一下原始注册如下所示:

typescript
providers: [
  {
    provide: APP_GUARD,
    useClass: JwtAuthGuard,
  },
],

这是通过 APP_* 令牌将守卫注册为"多"提供者。为了能够在此处替换 JwtAuthGuard,注册需要在此插槽中使用现有提供者:

typescript
providers: [
  {
    provide: APP_GUARD,
    useExisting: JwtAuthGuard,
    // ^^^^^^^^ 注意使用 'useExisting' 而不是 'useClass'
  },
  JwtAuthGuard,
],

提示

useClass 更改为 useExisting 以引用已注册的提供者,而不是让 Nest 在令牌后面实例化它。

现在 JwtAuthGuard 对 Nest 来说是一个常规提供者,可以在创建 TestingModule 时覆盖:

typescript
const moduleRef = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideProvider(JwtAuthGuard)
  .useClass(MockAuthGuard)
  .compile();

现在你的所有测试将在每个请求上使用 MockAuthGuard

测试请求作用域实例

请求作用域提供者为每个传入的请求唯一创建。实例在请求完成处理后被垃圾回收。这带来了一个问题,因为我们无法访问专门为测试请求生成的依赖注入子树。

我们知道(基于上面的部分)resolve() 方法可用于检索动态实例化的类。此外,如此处所述,我们知道我们可以传递一个唯一的上下文标识符来控制 DI 容器子树的生命周期。我们如何在测试上下文中利用这一点?

策略是预先生成一个上下文标识符,并强制 Nest 使用此特定 ID 为所有传入请求创建子树。通过这种方式,我们将能够检索为测试请求创建的实例。

要实现这一点,在 ContextIdFactory 上使用 jest.spyOn()

typescript
const contextId = ContextIdFactory.create();
jest
  .spyOn(ContextIdFactory, 'getByRequest')
  .mockImplementation(() => contextId);

现在我们可以使用 contextId 访问任何后续请求的单个生成的 DI 容器子树。

typescript
catsService = await moduleRef.resolve(CatsService, contextId);

基于 NestJS 官方文档翻译