Skip to content

仲灏小栈

仲灏约 1 分钟

前言

如果已经学习到了这一章,相信你已经至少将之前的项目做了一个大概的雏形出来了。

无论是参考示例还是全部靠自己做出来的,总之恭喜你已经度过了在一个项目开发周期中的最开心的时刻,因为之前每一项功能的完成,带来的都是一个个的成就感,让人能坚持下来并且乐此不彼的是在旅途中能不断的完成一些阶段性的目标,但接下来要做的我猜是大部分的开发都有点头疼的是事情,因为本章开始我们需要写自动化测试用例了。

NestJS 自动化测试

一个项目的质量需要靠什么来保证,肯定不是看开发人员的经验,只要是人一定会犯错,没有完美的人也没有完美的程序。但从概率学上来说机器一定是比较靠谱的,毕竟只有逻辑而没有感情,所以自动化测试能够给予项目一定的质量和性能保证,同时一个项目的自动测测试用例覆盖越全面,对于测试同学的负担也就越少。

自动化测试有非常多的类型有单元测试,端到端(e2e)测试,集成测试等等,自动测试的框架也非常多,所幸NestJS 提供了内置开箱即用的 JestSuperTest 集成,以及在测试环境中可以模拟 NestJS 的依赖注入体系,更方便的测试模块,这样使得我们可以降低一定的选择困难症,直接使用 NestJS 集成的即可。

当然你仍然可以选择自己熟悉的自动化测试框架(例如:mocha)来使用,NestJS 框架并未对你做过多的限制,只是处于 NestJS 的体系当中,除非有特殊需求,否则还是建议使用自带的测试功能。

Unit TEST

首先安装 NestJS 测试工具的依赖 @nestjs/testing,如果是 CLI 创建的话就不需要再安装依赖了。

shell
$ yarn add @nestjs/testing

还记得之前在使用 CLI 快速创建的 *.spec.ts 文件吗?接下来我们就要使用上它了。

第一步:在 intercepter.controller.ts 中新增一个测试方法:

diff
import {
  Controller,
  Get,
  Req,
  Res,
} from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { URL } from 'url';
import { IntercepterService } from './intercepter.service';

@Controller()
export class IntercepterController {
  constructor(private readonly intercepterService: IntercepterService) { }

  @Get('*')
  async getApp(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
    const urlObj = new URL(req.url, `http://${req.headers.host}`);
    console.log(urlObj)
    if (urlObj.pathname === '/favicon.ico') return res.send('ico');

    const html = await this.intercepterService.readHtml(urlObj);

    if (!html) return res.send('404');

    res.headers({
      'Content-Type': 'text/html',
    });
    res.send(html);
  }

+  @Get('test')
+  getTest() {
+    return 'test'
+  }
}

第二步:新建 intercepter.controller.spec.ts,一般单元测试用例与测试模块保持在同一个目录下。

ts
import { IntercepterController } from './intercepter.controller';
import { IntercepterService } from './intercepter.service'
import { ConfigService } from '@nestjs/config';
import { getConfig } from '@app/common';
import { FastifyRequest } from 'fastify';

describe('IntercepterController', () => {

  let intercepterController: IntercepterController;
  let intercepterService: IntercepterService;
  let configService: ConfigService;

  beforeEach(() => {
    configService = new ConfigService({
      isGlobal: true,
      load: [getConfig]
    })

    intercepterService = new IntercepterService(configService);
    intercepterController = new IntercepterController(intercepterService);
  });

  describe('getTest', () => {
    it('should return an html', async () => {
 //     const result = 'devops';
      const result = 'test';
      expect(await intercepterController.getTest()).toBe(result);
    });
  });
});

第三步:运行测试命令 yarn test,即可获得如下结果,当 result 分别是 devopstest 的测试结果如下:

image.png

image.png

如上一个非常简单测试用例就完成了,接下来我们挑战一下高难的测试用例开发,来测试我们之前的网关代理接口。

首先看下 IntercepterControllergetApp 这个方法,它的入参分别为 @Req@Res,在单元测试中是没有正常的请求体的,所以需要手动将这两个入参数据模拟出来,我们可以借助 mock-req-res 这个库来生成模拟参数:

shell
$ yarn add mock-req-res
$ yarn add sinon

intercepter.controller.spec.ts 中新增测试方法:

  describe('getApp', () => {
    it('should return devops', async () => {
      const req = mockRequest({
        headers: {
          host: 'www.cookieboty.com'
        },
        url: '/devops'
      })
      const res = mockResponse()
      const result = 'devops';
      expect(await intercepterController.getApp(req, res)).toBe(result);
    });
  });

继续执行之前的测试脚本:yarn test 即可得到如下结果:

image.png

啊嘞,报错很正常,从报错信息能很明显看出是模拟的 @Res 参数有问题,另外在 getApp 中是直接使用了 res.send 返回数据,这样在单元测试中是无法拿到正常的返回值,所以也需要同时修改 getApp 的返回方法:

diff
 @Get('*')
  async getApp(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
    const urlObj = new URL(req.url, `http://${req.headers.host}`);
    if (urlObj.pathname === '/favicon.ico') return res.send('ico');
    const html = await this.intercepterService.readHtml(urlObj);

    if (!html) return res.send('404');

    res.headers({
      'Content-Type': 'text/html',
    });
-   res.send(html);
+   return res.send(html);
  }

然后在修改 intercepter.controller.spec.tsgetApp 方法:

diff
describe('getApp', () => {
    it('should return devops', async () => {
      const req = mockRequest({
        headers: {
          host: 'www.cookieboty.com'
        },
        url: '/devops'
      })
-      const res = mockResponse()
+      const res = mockResponse({
+        headers: () => { },
+        send: d => d
+      })
      const result = 'devops';
      expect(await intercepterController.getApp(req, res)).toBe(result);
    });
  });

再次运行测试脚本得到如下结果代表测试成功:

image.png

上述的测试脚本,其实还没有使用到 NestJS 给我们提供的测试工具,大家可以发现,我们是将 ConfigServiceIntercepterService 实例直接传递的,当依赖的测试模块多起来的时候并不是非常方便,接下来我们使用 NestJS 提供的测试工具来修改我们的脚本。

diff
// intercepter.controller.spec.ts
  beforeEach(async () => {
  
-    configService = new ConfigService({
-      isGlobal: true,
-      load: [getConfig]
-    })
-    intercepterService = new IntercepterService(configService);
-    intercepterController = new IntercepterController(intercepterService);
    
+    const moduleRef = await Test.createTestingModule({
+      imports: [IntercepterModule,
+        ConfigModule.forRoot({
+          ignoreEnvFile: true,
+          isGlobal: true,
+          load: [getConfig]
+        }),],
+    }).compile();

+    intercepterService = moduleRef.get<IntercepterService>(IntercepterService);
+    intercepterController = moduleRef.get<IntercepterController>(IntercepterController);
  });

从以上代码对比大家可以发现,使用了 NestJS 自带的 Test.createTestingModule 方法后,除了不再需要主动实例化类之外,其他所有相关的依赖,我们只需要借助 NestJS 本身的依赖注入就可以完成,同时使用 createTestingModule ,会模拟 NestJS 的运行时,可以获取到上下文,所以拓展性会变得更高,有兴趣的同学可以试试更多的功能。

E2E TEST

单元测试主要是某个方法或者模块的逻辑测试,而 E2E 测试在更聚合的层面覆盖了类和模块的交互,尽可能的模拟用户在生产环境的操作。

当需要测试的链路非常长与复杂的情况下,单元测试是无法很好的保证链路可靠性,或者说它会变得更加复杂,所以这个时候也就需要 E2E 测试来保证链接的稳定与正确性。

接下来我们来一起学习 E2E 的测试用例开发。

首先在项目的 test 文件夹下创建 apps/fast-gateway/test/intercepter.e2e-spec.ts 文件:

ts
import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { IntercepterModule } from '../src/core/intercepter.module'
import { ConfigService, ConfigModule } from '@nestjs/config';
import { getConfig } from '../src/utils/index';

describe('Cats', () => {
  let app: NestFastifyApplication;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [IntercepterModule,
        ConfigModule.forRoot({
          ignoreEnvFile: true,
          isGlobal: true,
          load: [getConfig]
        }),],
    }).compile();

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

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

  it(`/GET devops`, () => {
    return request(app.getHttpServer())
      .get('/devops')
      .set('host', 'www.cookieboty.com')
      .expect(200)
      .expect('devops');
  });

  it(`/GET jenkins`, () => {
    return request(app.getHttpServer())
      .get('/jenkins')
      .set('host', 'www.cookieboty.com')
      .expect(200)
      .expect('jenkins');
  });

  it(`/GET 404`, () => {
    return request(app.getHttpServer())
      .get('/jenk')
      .set('host', 'www.cookieboty.com')
      .expect(200)
      .expect('404');
  });

  it(`/GET nginx`, () => {
    return request(app.getHttpServer())
      .get('/nginx')
      .set('host', 'www.cookieboty.com')
      .expect(200)
      .expect('nginx2');
  });

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

e2e 的测试文件一定要放在对应项目的 test 文件夹中,否则不会生效。

接下来运行 e2e 测试脚本:yarn test:e2e,下图分别是测试用例正常与异常的示例:

image.png

image.png

写在最后

本章的示例代码在 feat/microservices,后续会进行持续的迭代,有需要的同学自取。

大家对比一下可以我们的开发代码与测试代码即可发现,测试用例的代码量远超开发的代码,由于要涵盖的逻辑非常多,所以为了保证测试用例的质量会有大量的用例判断。

虽然测试用例能很好的保证代码的质量,但是会消耗非常多的时间来开发,这也是为什么我在开发项目最开始的时候跟大家提过,如果项目紧急的情况下,可以先把测试用例开发放在最后,测试用例覆盖最主要的核心功能即可。

由于网关系统代理的测试用例很特殊,需要针对域名做处理,所以本章的例子主要围绕着模拟请求体与修改 Host 来展现,其他简单的 CURD 的测试用例大家可以尽可能的多写写,熟能生巧。

Jest 的功能还是非常强大的,还有非常多有趣以及有用的 Api 大家可以自行研究参考下,有问题的话欢迎在群里提出交流,

如果你有什么疑问,欢迎在评论区提出或者加群沟通。 👏

上次更新: