Fundamentals

单元测试

了解如何在 Nest 应用程序中进行单元测试,包括测试工具、模拟依赖项、测试模块和端到端测试的最佳实践

单元测试

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

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

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

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

安装

首先,我们需要安装所需的包:

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

单元测试

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

@@filename(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);
    });
  });
});
@@switch
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

describe('CatsController', () => {
  let catsController;
  let 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 类重写前面的示例:

@@filename(cats.controller.spec)
import { Test, TestingModule } from '@nestjs/testing';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

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

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

    catsService = module.get<CatsService>(CatsService);
    catsController = module.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);
    });
  });
});
@@switch
import { Test } from '@nestjs/testing';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

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

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

    catsService = module.get(CatsService);
    catsController = module.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() 方法只能检索静态实例)。

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

catsService = await moduleRef.resolve(CatsService);

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

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

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

自动模拟

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

// ...
import { createMock } from '@golevelup/ts-jest';

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

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [CatsController],
    })
      .useMocker(createMock)
      .compile();

    controller = module.get(CatsController);
    service = module.get(CatsService);
  });
  // ...
});

您还可以从工厂检索这些模拟,如下所示:

const module: TestingModule = await Test.createTestingModule({
  controllers: [CatsController],
})
  .useMocker((token) => {
    if (token === CatsService) {
      return { findAll: jest.fn().mockReturnValue(cats) }
    }
    if (typeof token === 'function') {
      const mockMetadata = moduleMocker.getMetadata(token);
      const Mock = moduleMocker.generateFromMetadata(mockMetadata);
      return new Mock();
    }
  })
  .compile();

您还可以从模块引用中检索模拟,类似于任何其他提供者:

const catsService = module.get(CatsService); // CatsService 将是一个模拟对象

端到端测试

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

@@filename(cats.e2e-spec)
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

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

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('Hello World!');
  });
});
@@switch
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
  let app;

  beforeEach(async () => {
    const moduleFixture = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

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

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('Hello World!');
  });
});

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

let app: NestFastifyApplication;

beforeEach(async () => {
  app = moduleFixture.createNestApplication<NestFastifyApplication>(
    new FastifyAdapter(),
  );

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

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

在此示例中,我们基于之前描述的一些概念构建。除了我们之前使用的 compile() 方法之外,我们现在使用 createNestApplication() 方法来实例化一个完整的 Nest 运行时环境。我们将对正在运行的应用程序的引用保存在 app 变量中,以便我们可以使用它来模拟 HTTP 请求。

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

在此示例中,我们还断言响应符合我们的期望(状态代码和响应正文)。

提示 默认情况下,e2e 测试文件位于 test 目录中。您可以使用不同的目录,但请确保更新 jest 配置文件中的 testRegex 属性(或者如果您使用的是 CLI,则在 package.json 文件中)。

覆盖全局注册的增强器

如果您有全局注册的守卫、拦截器、过滤器或管道,您需要采取一些额外的步骤来覆盖这些增强器。原始注册看起来像这样:

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

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

providers: [
  {
    provide: APP_GUARD,
    useClass: MockAuthGuard,
  },
],

现在测试将使用 MockAuthGuard 而不是 JwtAuthGuard

覆盖全局注册的模块

如果您有全局注册的模块,并且您需要为测试覆盖其中一个提供者,您可以使用 overrideModule() 方法:

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

测试请求作用域实例

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

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

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

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

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

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

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