最近,我一直在為一個NestJS專案編寫單元測試和E2E測試。這是我第一次為後端專案編寫測試,我發現這個過程與我在前端測試的經驗不同,使得一開始充滿挑戰。在看了一些例子之後,我對如何進行測試有了更清晰的了解,所以我打算寫一篇文章來記錄和分享我的學習,以幫助其他可能面臨類似困惑的人。
此外,我還整理了一個示範項目,其中包含相關單元並已完成端到端測試,您可能會感興趣。程式碼已上傳至Github:https://github.com/woai3c/nestjs-demo。
單元測試和端對端測試都是軟體測試的方法,但它們有不同的目標和範圍。
單元測試涉及檢查和驗證軟體中的最小可測試單元。例如,函數或方法可以被視為一個單元。在單元測試中,您為函數的各種輸入提供預期輸出並驗證其操作的正確性。單元測試的目標是快速識別功能內的錯誤,並且它們易於編寫和快速執行。
另一方面,E2E測試通常會模擬真實的使用者場景來測試整個應用程式。例如,前端通常使用瀏覽器或無頭瀏覽器進行測試,而後端則透過模擬 API 呼叫來進行測試。
在 NestJS 專案中,單元測試可能會評估特定服務或控制器的方法,例如驗證 Users 模組中的更新方法是否正確更新使用者。然而,端到端測試可能會檢查完整的用戶旅程,從建立新用戶到更新密碼再到最終刪除用戶,這涉及多個服務和控制器。
為不涉及介面的實用函數或方法編寫單元測試相對簡單。您只需要考慮各種輸入並編寫相應的測試程式碼。然而,一旦介面發揮作用,情況就會變得更加複雜。我們以程式碼為例:
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
上面的程式碼是auth.service.ts檔案中的validateUser方法,主要用於驗證使用者登入時輸入的使用者名稱和密碼是否正確。它包含以下邏輯:
可以看出,validateUser方法包含四個處理邏輯,我們需要針對這四個點編寫對應的單元測試程式碼,以確保整個validateUser函數運作正確。
當我們開始編寫單元測試時,遇到一個問題:findOne方法需要與資料庫交互,它透過username在資料庫中尋找對應的使用者。但是,如果每個單元測試都必須與資料庫進行交互,那麼測試就會變得非常繁瑣。因此,我們可以模擬假數據來實現這一點。
例如,假設我們註冊了一個名為 woai3c 的使用者。然後,在登入過程中,可以在 validateUser 方法中透過 constentity = wait this.usersService.findOne({ username }); 擷取使用者資料。只要這行程式碼能夠傳回想要的數據,就沒有問題,即使沒有資料庫互動。我們可以透過模擬數據來實現這一點。現在,我們來看看 validateUser 方法的相關測試程式碼:
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
我們透過呼叫usersService的findOne方法來取得使用者數據,所以我們需要在測試程式碼中模擬usersService的findOne方法:
import { Test } from '@nestjs/testing'; import { AuthService } from '@/modules/auth/auth.service'; import { UsersService } from '@/modules/users/users.service'; import { UnauthorizedException } from '@nestjs/common'; import { TEST_USER_NAME, TEST_USER_PASSWORD } from '@tests/constants'; describe('AuthService', () => { let authService: AuthService; // Use the actual AuthService type let usersService: Partial<Record<keyof UsersService, jest.Mock>>; beforeEach(async () => { usersService = { findOne: jest.fn(), }; const module = await Test.createTestingModule({ providers: [ AuthService, { provide: UsersService, useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); }); describe('validateUser', () => { it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); }); // other tests... }); });
我們使用 jest.fn() 傳回一個函數來取代真正的 usersService.findOne()。如果現在呼叫 usersService.findOne() ,將不會有回傳值,因此第一個單元測試案例將通過:
beforeEach(async () => { usersService = { findOne: jest.fn(), // mock findOne method }; const module = await Test.createTestingModule({ providers: [ AuthService, // real AuthService, because we are testing its methods { provide: UsersService, // use mock usersService instead of real usersService useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); });
由於constentity 中的findOne = wait this.usersService.findOne({ username }); validateUser 方法是一個模擬的假函數,沒有傳回值,validateUser 方法中的第2 到第4 行程式碼可以執行:
it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); });
拋出 401 錯誤,符合預期。
validateUser方法中的第二個邏輯是判斷使用者是否被鎖定,對應程式碼如下:
if (!entity) { throw new UnauthorizedException('User not found'); }
可以看到,如果使用者資料中有鎖定時間lockUntil,且鎖定結束時間大於目前時間,我們就可以判斷目前帳戶被鎖定。因此,我們需要使用 lockUntil 欄位來模擬使用者資料:
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
上面的測試程式碼中,首先定義了一個物件lockedUser,其中包含我們需要的lockUntil欄位。然後,它被用作findOne的回傳值,透過usersService.findOne.mockResolvedValueOnce(lockedUser);實作。這樣,當執行 validateUser 方法時,其中的使用者資料就是模擬數據,成功地讓第二個測試案例通過。
單元測試覆蓋率(程式碼覆蓋率)是用來描述單元測試覆蓋或測試了多少應用程式程式碼的指標。它通常以百分比表示,表示所有可能的程式碼路徑中有多少已被測試案例覆蓋。
單元測試覆蓋率通常包括以下類型:
單元測試覆蓋率是衡量單元測試品質的重要指標,但不是唯一指標。高覆蓋率可以幫助偵測程式碼中的錯誤,但並不能保證程式碼的品質。覆蓋率低可能意味著存在未經測試的程式碼,可能存在未偵測到的錯誤。
下圖顯示了示範專案的單元測試覆蓋率結果:
對於services、controller之類的文件,一般單元測試覆蓋率越高越好,而對於module之類的文件則不需要寫單元測試,也不可能寫,因為沒有意義。上圖表示整個單元測試覆蓋率的整體指標。如果要查看特定功能的測試覆蓋率,可以開啟專案根目錄下的coverage/lcov-report/index.html檔案。例如我想看validateUser方法的具體測試狀況:
可以看到,validateUser方法原來的單元測試覆蓋率並不是100%,還有兩行程式碼沒有執行。不過沒關係,不會影響四個關鍵處理節點,也不宜一維追求高測試覆蓋率。
在單元測試中,我們示範如何為 validateUser() 函數的每個功能編寫單元測試,使用模擬資料來確保每個功能都可以被測試。在端到端測試中,我們需要模擬真實的使用者場景,因此連接資料庫進行測試是必要的。因此,我們將測試的 auth.service.ts 模組中的方法都與資料庫互動。
auth模組主要包含以下功能:
端對端測試需要對這六個功能進行一一測試,從註冊開始,到刪除使用者結束。在測試過程中,我們可以建立一個專門的測試用戶來進行測試,完成後刪除這個測試用戶,以免在測試資料庫中留下任何不必要的資訊。
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
beforeAll鉤子函數在所有測試開始之前運行,因此我們可以在這裡註冊一個測試帳戶TEST_USER_NAME。 afterAll鉤子函數在所有測試結束後運行,所以這裡適合刪除測試帳號TEST_USER_NAME,也方便測試註冊和刪除功能。
在上一節的單元測試中,我們圍繞 validateUser 方法編寫了相關的單元測試。實際上,該方法是在登入時執行,以驗證使用者的帳號和密碼是否正確。因此,本次e2E測試也將使用登入流程來示範如何撰寫e2E測試案例。
整個登入測驗過程包括五個小測驗:
import { Test } from '@nestjs/testing'; import { AuthService } from '@/modules/auth/auth.service'; import { UsersService } from '@/modules/users/users.service'; import { UnauthorizedException } from '@nestjs/common'; import { TEST_USER_NAME, TEST_USER_PASSWORD } from '@tests/constants'; describe('AuthService', () => { let authService: AuthService; // Use the actual AuthService type let usersService: Partial<Record<keyof UsersService, jest.Mock>>; beforeEach(async () => { usersService = { findOne: jest.fn(), }; const module = await Test.createTestingModule({ providers: [ AuthService, { provide: UsersService, useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); }); describe('validateUser', () => { it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); }); // other tests... }); });
這五個測試如下:
現在讓我們開始寫 e2E 測驗:
beforeEach(async () => { usersService = { findOne: jest.fn(), // mock findOne method }; const module = await Test.createTestingModule({ providers: [ AuthService, // real AuthService, because we are testing its methods { provide: UsersService, // use mock usersService instead of real usersService useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); });
編寫 e2E 測試程式碼相對簡單:只需呼叫接口,然後驗證結果即可。例如登入測試成功,我們只需要驗證回傳結果是否為200。
前四個測試非常簡單。現在我們來看一個稍微複雜一點的端對端測試,就是驗證帳戶是否被鎖定。
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
當使用者連續3次登入失敗時,帳戶將被鎖定。因此,在本次測試中,我們不能使用測試帳戶TEST_USER_NAME,因為如果測試成功,該帳戶將被鎖定,無法繼續進行後續測試。我們需要註冊另一個新用戶TEST_USER_NAME2專門用於測試帳戶鎖定,測試成功後刪除該用戶。所以,正如你所看到的,這個 e2E 測試的程式碼相當龐大,需要大量的設定和拆卸工作,但實際的測試程式碼只有這幾行:
import { Test } from '@nestjs/testing'; import { AuthService } from '@/modules/auth/auth.service'; import { UsersService } from '@/modules/users/users.service'; import { UnauthorizedException } from '@nestjs/common'; import { TEST_USER_NAME, TEST_USER_PASSWORD } from '@tests/constants'; describe('AuthService', () => { let authService: AuthService; // Use the actual AuthService type let usersService: Partial<Record<keyof UsersService, jest.Mock>>; beforeEach(async () => { usersService = { findOne: jest.fn(), }; const module = await Test.createTestingModule({ providers: [ AuthService, { provide: UsersService, useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); }); describe('validateUser', () => { it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); }); // other tests... }); });
編寫 e2E 測試程式碼相對簡單。您不需要考慮模擬數據或測試覆蓋率。只要整個系統進程如預期運作就足夠了。
如果可能的話,我通常建議寫測驗。這樣做可以增強系統的健壯性、可維護性和開發效率。
在編寫程式碼時,我們通常會專注於正常輸入下的程式流程,以確保核心功能正常運作。然而,我們可能經常忽略一些邊緣情況,例如異常輸入。編寫測試改變了這一點;它迫使您考慮如何處理這些情況並做出適當的回應,從而防止崩潰。可以說,編寫測試間接提高了系統的健全性。
接手一個包含全面測試的新專案是非常令人愉快的。它們充當指南,幫助您快速了解各種功能。只需查看測試程式碼,您就可以輕鬆掌握每個函數的預期行為和邊界條件,而無需逐行查看函數程式碼。
想像一下,一個有一段時間沒有更新的項目突然收到了新的需求。進行更改後,您可能會擔心引入錯誤。如果沒有測試,您將需要再次手動測試整個專案——浪費時間且效率低下。透過完整的測試,單一命令可以告訴您程式碼變更是否影響了現有功能。即使出現錯誤,也可以快速定位並解決。
對於短期項目以及需求迭代非常快的項目,不建議編寫測試。例如,某些用於活動的項目在活動結束後將毫無用處,不需要測試。另外,對於需求迭代非常快的項目,我說寫測試可以提高開發效率,但前提是函數迭代很慢。如果你剛完成的功能在一兩天內發生變化,相關的測試程式碼就必須重寫。所以,最好根本不寫測試,而是依賴測試團隊,因為寫入測試非常耗時,不值得付出努力。
在詳細解釋如何為NestJS專案編寫單元測試和e2E測試之後,我仍然想重申一下測試的重要性。可增強系統的健壯性、可維護性和開發效率。如果你沒有機會編寫測試,我建議你自己啟動一個實踐專案或參與一些開源專案並為其貢獻程式碼。開源專案通常有更嚴格的程式碼要求。貢獻程式碼可能需要您編寫新的測試案例或修改現有的測試案例。
以上是如何為 NestJS 應用程式編寫單元測試和 Etest的詳細內容。更多資訊請關注PHP中文網其他相關文章!