单元测试 Testing

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

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

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

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

安装

要开始使用,请先安装所需的软件包:

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

单元测试

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

ts
TS
ts
// cats.controller.spec
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 类重写前面的示例:

ts
TS
ts
// cats.controller.spec
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>(CatsService)
    catsController = moduleRef.get<CatsController>(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() 方法。此方法使用其依赖项引导模块(类似于使用 NestFactory.create() 在常规 main.ts 文件中引导应用程序的方式),并返回一个已准备好进行测试的模块。

提示

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

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

ts
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 创建特定模拟的示例。

ts
import { MockFunctionMetadata, ModuleMocker } from 'jest-mock'

const moduleMocker = new ModuleMocker(globalThis)

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 MockFunctionMetadata<any, any>
          const Mock = moduleMocker.generateFromMetadata(mockMetadata)
          return new Mock()
        }
      })
      .compile()

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

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

提示

也可以直接传递通用模拟工厂,例如来自 @golevelup/ts-jestcreateMock

提示

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

端到端测试

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

ts
TS
ts
// cats.e2e-spec
import * as request from 'supertest'
import { Test } from '@nestjs/testing'
import { INestApplication } from '@nestjs/common'
import { CatsModule } from '../../src/cats/cats.module'
import { CatsService } from '../../src/cats/cats.service'

describe('Cats', () => {
  let app: INestApplication
  const 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 运行时环境。我们在 app 变量中保存了对正在运行的应用程序的引用,以便我们可以使用它来模拟 HTTP 请求。

我们使用 Supertest 中的 request() 函数模拟 HTTP 测试。我们希望这些 HTTP 请求路由到我们正在运行的 Nest 应用程序,因此我们向 request() 函数传递了对 Nest 底层 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() 方法的对象,您可以使用该方法提供一个将覆盖原始模块的模块,如下所示:

ts
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 后缀。

覆盖全局注册的增强器

如果您有一个全局注册的保护程序(或管道、拦截器或过滤器),则需要采取更多步骤来覆盖该增强器。回顾一下,原始注册如下所示:

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

这是通过 APP_* 令牌将防护器注册为提供商。为了能够在此处替换 JwtAuthGuard,注册需要使用此插槽中的现有提供商:

ts
providers: [
  {
    provide: APP_GUARD,
    useExisting: JwtAuthGuard,
    // ^^^^^^^^ notice the use of 'useExisting' instead of 'useClass'
  },
  JwtAuthGuard,
]
提示

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

现在,JwtAuthGuard 对 Nest 可见,作为常规提供程序,可以在创建 TestingModule 时覆盖:

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

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

测试请求范围的实例

请求范围 提供程序是为每个传入的 请求 唯一创建的。请求处理完成后,实例将被垃圾收集。这带来了一个问题,因为我们无法访问专门为测试请求生成的依赖项注入子树。

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

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

为此,请在 ContextIdFactory 上使用 jest.spyOn()

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

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

ts
catsService = await moduleRef.resolve(CatsService, contextId)