NestJS 的單元測試初體驗

前言

撰寫單元測試-老生常談的後端工程師一定要具備的技能, 可是在台灣的web中,又有多少是真的有單元測試的呢?

以前production要是遇到bug,只能祈求自己有留Logger、 在development時,就只能祈求console.log還在, 不然復現又是一場戰爭。

上線前三天總是睡不著覺,後三天總是時刻緊張著。 但若有單元測試一切都不一樣了。

每個業務邏輯都能被測試,不會再出現莫名其妙的「小問題」, 更動底層業務邏輯,也不會在因為忘記改父層邏輯而報500。

這是「建立信心」的行為

NestJS 測試環境概覽

NestJS中本身就具有@nestjs/testing套件,以Jest做為默認的測試框架。

但由於我已習慣專案全面擁抱ESM,還需額外的Jest/global及引入方法來處理

在NestJs中,我們習慣以*.spec.ts做為測試檔案的命名慣例,

而我的初體驗會把每個module的測試文件都放在期__test__中

-admin
-- __test__
--- admin.service.spec.ts
--- admin.controller.spec.ts
-- admin.service.ts
-- admin.controller.ts

建立第一個測試:Service 單元測試

在建立單元測試前,我們已完成基礎的Admin管理員module,具備基礎的CRUD,並且有CryptoUtil的引用。

使用 Test.createTestingModule() 建立隔離模組

我們正常的Module中會import外部的模組,provider內部的service,以及使用controllers

而單元測試也一樣需要Module來當作我們的「大腦」

//運用Test.createTestingModule即可創建
const module: TestingModule = await Test.createTestingModule({
      providers: [
        AdminService,
        {
          provide: getRepositoryToken(Admin),
          useFactory: mockAdminRepository,
        },
        {
          provide: ConfigService,
          useFactory: mockConfigService
        }
      ],
    }).compile();

一樣需要把AdminService中需要注入的服務提供在provider中。

注入依賴 vs. Mock 依賴

在正常的NestJS中,在建構子中若帶上具有Token的class,NestJS則會自動注入。

但我們單元測試中,會自行撰寫一個mock class,以TypeORM常見標準repository為例

const mockAdminRepository = () => ({
    findOneBy: jest.fn(),
    create: jest.fn(),
    save: jest.fn(),
    update: jest.fn(),
    softDelete: jest.fn(),
    softRemove: jest.fn(),
    find: jest.fn(),
  });
撰寫第一個 describe / it 區塊

以createAdmin當作第一個挑戰

describe('createAdmin', () => {
    it('應該在郵箱重複時拋出 BadRequestException', async () => {
      const dto = {
        email: 'test@test.com',
        password: 'password',
        name: 'Test',
        role: Role.Editor,
      };

      repo.findOneBy.mockResolvedValue({ id: 'some-id', ...dto });

      await expect(service.createAdmin(dto)).rejects.toThrow(
        BadRequestException,
      );
    });

    it('應該成功建立管理員並回傳', async () => {
      const dto = {
        email: 'new@test.com',
        password: 'password',
        name: 'New Admin',
        role: Role.Editor,
      };

      repo.findOneBy.mockResolvedValue(null);
      repo.create.mockReturnValue(dto);
      repo.save.mockResolvedValue({ id: 'uuid', ...dto });

      jest.spyOn(CryptoUtil, 'hash').mockResolvedValue('hashed_pass');

      const result = await service.createAdmin(dto);

      expect(repo.findOneBy).toHaveBeenCalledWith({ email: dto.email });
      expect(result).toHaveProperty('id');
      expect(result.email).toBe(dto.email);
    });
  });

當中,有幾個第一次看見的調用

  • mockResolvedValue:用來控制該函數的返回值
  • spyOn:監聽
  • toHaveBeenCalledWith:是否有被呼叫並且傳入參數XX
  • toHaveProperty:是否有該欄位

Mock 的藝術

寫到這裡,你可能會想:為什麼不直接連真實資料庫跑?

因為單元測試的核心精神是「隔離」,我只想測試這段邏輯本身,而不是整個系統。

為何要 Mock?(隔離、速度、可控)

理由很簡單,三個字:隔、快、控

:AdminService 的邏輯對不對,跟資料庫怎麼存沒有關係。把依賴換成 Mock,就能只驗證這一層。

:不需要啟動資料庫、不需要網路,整個測試套件幾百毫秒跑完。

:想讓 findOneBy 回傳 null?想讓 save 拋錯?Mock 讓你任意控制情境,包含那些在正式環境很難復現的邊界條件。

jest.fn()jest.spyOn() 的差異與使用時機

這兩個是初學最常搞混的。

jest.fn() 是創造一個全新的空函數,沒有任何原本的實作:

findOneBy: jest.fn() // 回傳 undefined,除非你用 mockResolvedValue 控制

jest.spyOn() 則是監聽一個已存在的物件方法,預設仍會呼叫原本的實作,但你可以選擇覆蓋:

jest.spyOn(CryptoUtil, 'hash').mockResolvedValue('hashed_pass')
// CryptoUtil 是真實的 class,spyOn 讓我們偷換掉 hash 這個靜態方法

簡單判斷原則:

  • 注入的依賴(Repository、ConfigService)→ 用 jest.fn() 組成 mock 物件
  • 真實存在的 class 上的方法 → 用 jest.spyOn()
覆蓋率報告解讀

執行 jest --coverage,跑完後會看到這樣的表格:

----------|---------|----------|---------|---------|
File      | % Stmts | % Branch | % Funcs | % Lines |
----------|---------|----------|---------|---------|
admin.service.ts | 91.3 | 75 | 100 | 91.3 |

四個欄位代表不同維度:

  • Stmts(語句):有多少行程式碼被執行到
  • Branch(分支):if/else、三元運算子等分支,兩側都跑到了嗎
  • Funcs(函式):有多少函式被呼叫過
  • Lines(行數):和 Stmts 類似,但以物理行計算

Branch 通常是最難拉高的,因為每個 if 都有兩條路,每條都要有測試案例覆蓋。

追求 100% 不是目標,但 Branch coverage 低代表你的邏輯有死角沒被測到,值得重點關注。

總結

單元測試不是一道很高的門檻,NestJS 把環境都準備好了,剩下的只是思維的轉換:

從「這樣寫能不能跑」,變成「這樣寫能不能被驗證」。

第一次寫的時候會很不習慣,Mock 要怎麼設、斷言要怎麼下、覆蓋率低要怎麼補。但寫過幾個 Service 之後,節奏就會慢慢建立起來。

上線不再靠祈禱,而是靠測試通過的那條綠線。