踩坑之旅 — NestJS 单元测试

背景

我们团队近期开始对项目增加单元测试,期望能够增强服务的质量和性能,但实践过程中走了一些弯路。

开始

根据文档示例,我编写了测试代码,如下:

// panel.controller.ts

@Controller('redirectPanel')
@UseInterceptors(ResultInterceptor, SentryInterceptor)
export class RedirectPanelController {
  constructor(private readonly redirectService: RedirectPanelService) {}

  @Get('findOne')
  @ApiOperation({ summary: '查询单条重定向数据' })
  @ApiOkResponse({ type: FindOneResponseDto })
  async findOne(@Query() baseDto: FindOneRedirectPanelDto) {
    const res = await this.redirectService.findOne(baseDto);
    return res || {};
  }
复制代码
// panel.controller.spec.ts

describe("RedirectPanelController", () => {
  let redirectPanelController: RedirectPanelController
  let redirectPanelService: RedirectPanelService

  beforeEach(async () => {
    // Test 类
    const moduleRef = await Test.createTestingModule({
      controllers: [RedirectPanelController],
      providers: [RedirectPanelService],
    }).compile()

    redirectPanelService =
      moduleRef.get<RedirectPanelService>(RedirectPanelService)
    redirectPanelController = moduleRef.get<RedirectPanelController>(
      RedirectPanelController
    )
  })

  describe("findOne", () => {
    it("should return an array of redirect list", async () => {
      const result = { rid: "string" }

      jest
        .spyOn(redirectPanelService, "findOne")
        .mockImplementation(async () => result)

      expect(await redirectPanelController.findOne({ rid: "string" })).toBe(
        result
      )
    })
  })
})
复制代码

编译时,依赖解析报错,如下图:

pika-2022-03-14T06_49_46.028Z.png

原因是 NestJS 测试对象,会建立依赖树的关系,即运行时的上下文。而文件之间的依赖关系在开发中会比较复杂,如下图。这里就有了第一个问题:Controller 实际依赖了很多模块,测试时应该引用这些模块吗?

pika-2022-03-14T07_05_25.984Z.png

于是,我对测试文件进行了修改,引入了依赖的上下文,包括缓存、Mongoose 等,现在测试也正常运行通过了。

// panel.controller.spec.ts

import { Test } from "@nestjs/testing"
import { HashIdService } from "@/modules/hashid/hashid.service"
import { PanelController } from "./panel.controller"
import { PanelService } from "./panel.service"
import { RedisCacheModule } from "@/modules/rediscache/rediscache.module"
import { getModelToken } from "@nestjs/mongoose"
import { RedisModule } from "@/modules/redis/redis.module"
import { Redirect } from "./redirect.schema"
import { FindRedirectPanelDto } from "./dto/find.dto"

const mockModel = {}

describe("PanelController", () => {
  let panelController: PanelController
  let panelService: PanelService

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [RedisCacheModule, RedisModule],
      controllers: [PanelController],
      providers: [
        PanelService,
        HashIdService,
        {
          provide: getModelToken("Redirect"),
          useValue: mockModel,
        },
      ],
    }).compile()

    panelService = moduleRef.get<PanelService>(PanelService)
    panelController = moduleRef.get<PanelController>(PanelController)
  })

  describe("findOne", () => {
    it("should return an array of redirect list", async () => {
      const result = { rid: "string" }

      jest.spyOn(panelService, "findOne").mockImplementation(
        () =>
          new Promise((resolve) => {
            resolve(result)
          })
      )

      expect(await panelController.findOne({ rid: "string" })).toBe(result)
    })
  })
})
复制代码

但是

我总觉得很奇怪,因为这样测试的体验太差了,莫名奇妙的报错,让人无法下手。

接着,我在 NestJS 的 issue 中看到关于 如何测试 Controller 的回答,建议是:使用 Mock 数据而不是直接注入依赖,原文如下:

As for the your question, it’s very hard to load all dependencies tree in the tests almost in all cases. Also when you load all dependencies, you need to be sure that all of them works without any errors. Because when your test fails, you need to know where something went wrong – in the tested class or in hes dependency.

That’s why it is better to replace all dependencies for tested class class with mock implementations.

In some cases you might need to keep the real dependencies for tested class, but I don’t know why may be required to keep dependencies of dependencies 🙂

于是,我再次改造后:

import { Test } from "@nestjs/testing"
import { RedirectPanelController } from "./redirectPanel.controller"
import { RedirectPanelService } from "./redirectPanel.service"
import { Redirect } from "./redirect.schema"

class ServiceMock {
  async findOne(): Promise<any> {
    return {}
  }
}

describe("RedirectPanelController", () => {
  let redirectPanelController: RedirectPanelController
  let redirectPanelService: RedirectPanelService

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      controllers: [RedirectPanelController],
      providers: [
        {
          provide: RedirectPanelService,
          useClass: ServiceMock,
        },
      ],
    }).compile()

    redirectPanelService =
      moduleRef.get<RedirectPanelService>(RedirectPanelService)
    redirectPanelController = moduleRef.get<RedirectPanelController>(
      RedirectPanelController
    )
  })

  describe("findOne", () => {
    it("should return an array of redirect list", async () => {
      const result = { rid: "123" } as Redirect

      jest.spyOn(redirectPanelService, "findOne").mockResolvedValue(result)
      const resp = await redirectPanelController.findOne({ rid: "string" })

      expect(redirectPanelService.findOne).toBeCalledTimes(1)
      expect(resp).toBe(result)
      expect(resp).toHaveProperty("rid", expect.any(String))
    })
  })
})
复制代码

一点困惑

使用 Jest 时产生了第二个问题,如图:

截屏2022-03-18 上午10.44.18.png

后来我大概理解了, jest.spyon用于监听某个对象方法是否被调用,如果 servive 调用,就会返回{ user: {} } 对象(通过 mockResolvedValue Mock 的 );expect(response 会执行调用链 controller → server,server 将触发 jest.spyon ;至于具体返回的值是什么,属于 server 的单测领域,不需要 controller 关心。

总结

单元测试与开发中「关注整体」的思维有很大不同,更需要关注模块的独立性。同时,从容易测试的角度来说,能让开发人员更加容易编写出低耦合的代码。TDD(Test Drive Development)是一种非常经典的开发理念,如果在日常开发中执行的很好,对代码质量会有很大的帮助。

但是我观察到的情况是,在业务的压迫下,大家一般都是先编写代码,再补测试用例。理想和现实情况还是有些差距。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享