最近在給一個 nestjs 項目寫單元測試(Unit Testing)和 e2e 測試(End-to-End Testing,端到端測試,簡稱 e2e 測試),這是我第一次給后端項目寫測試,發(fā)現(xiàn)和之前給前端項目寫測試還不太一樣,導致在一開始寫測試時感覺無從下手。后來在看了一些示例之后才想明白怎么寫測試,所以打算寫篇文章記錄并分享一下,以幫助和我有相同困惑的人。
同時我也寫了一個 demo 項目,相關的單元測試、e2e 測試都寫好了,有興趣可以看一下。代碼已上傳到 Github: nestjs-interview-demo[1]。
單元測試和 e2e 測試都是軟件測試的方法,但它們的目標和范圍有所不同。
單元測試是對軟件中的最小可測試單元進行檢查和驗證。比如一個函數(shù)、一個方法都可以是一個單元。在單元測試中,你會對這個函數(shù)的各種輸入給出預期的輸出,并驗證功能的正確性。單元測試的目標是快速發(fā)現(xiàn)函數(shù)內(nèi)部的 bug,并且它們?nèi)菀拙帉憽⒖焖賵?zhí)行。
而 e2e 測試通常通過模擬真實用戶場景的方法來測試整個應用,例如前端通常使用瀏覽器或無頭瀏覽器來進行測試,后端則是通過模擬對 API 的調(diào)用來進行測試。
在 nestjs 項目中,單元測試可能會測試某個服務(service)、某個控制器(controller)的一個方法,例如測試 Users 模塊中的 update 方法是否能正確的更新一個用戶。而一個 e2e 測試可能會測試一個完整的用戶流程,如創(chuàng)建一個新用戶,然后更新他們的密碼,然后刪除該用戶。這涉及了多個服務和控制器。
為一個工具函數(shù)或者不涉及接口的方法編寫單元測試,是非常簡單的,你只需要考慮各種輸入并編寫相應的測試代碼就可以了。但是一旦涉及到接口,那情況就復雜了。用代碼來舉例:
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,主要用于驗證登錄時用戶輸入的賬號密碼是否正確。它包含的邏輯如下:
1.根據(jù) username 查看用戶是否存在,如果不存在則拋出 401 異常(也可以是 404 異常)2.查看用戶是否被鎖定,如果被鎖定則拋出 401 異常和相關的提示文字3.將 password 加密后和數(shù)據(jù)庫中的密碼進行對比,如果錯誤則拋出 401 異常(連續(xù)三次登錄失敗會被鎖定賬戶 5 分鐘)4.如果登錄成功,則將之前登錄失敗的計數(shù)記錄進行清空(如果有)并返回用戶 id 和 username 到下一階段
可以看到 validateUser 方法包含了 4 個處理邏輯,我們需要對這 4 點都編寫對應的單元測試代碼,以確定整個 validateUser 方法功能是正常的。
在開始編寫單元測試時,我們會遇到一個問題,findOne 方法需要和數(shù)據(jù)庫進行交互,它要通過 username 查找數(shù)據(jù)庫中是否存在對應的用戶。但如果每一個單元測試都得和數(shù)據(jù)庫進行交互,那測試起來會非常麻煩。所以可以通過 mock 假數(shù)據(jù)來實現(xiàn)這一點。
舉例,假如我們已經(jīng)注冊了一個 woai3c 的用戶,那么當用戶登錄時,在 validateUser 方法中能夠通過 const entity = await this.usersService.findOne({ username }); 拿到用戶數(shù)據(jù)。所以只要確保這行代碼能夠返回想要的數(shù)據(jù),即使不和數(shù)據(jù)庫交互也是沒有問題的。而這一點,我們能通過 mock 數(shù)據(jù)來實現(xiàn)。現(xiàn)在來看一下 validateUser 方法的相關測試代碼:
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... });});
我們通過調(diào)用 usersService 的 fineOne 方法來拿到用戶數(shù)據(jù),所以需要在測試代碼中 mock usersService 的 fineOne 方法:
beforeEach(async () => { usersService = { findOne: jest.fn(), // 在這里 mock findOne 方法 }; const module = await Test.createTestingModule({ providers: [ AuthService, // 真實的 AuthService,因為我們要對它的方法進行測試 { provide: UsersService, // 用 mock 的 usersService 代替真實的 usersService useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); });
通過使用 jest.fn() 返回一個函數(shù)來代替真實的 usersService.findOne()。如果這時調(diào)用 usersService.findOne() 將不會有任何返回值,所以第一個單元測試用例就能通過了:
it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException);});
因為在 validateUser 方法中調(diào)用 const entity = await this.usersService.findOne({ username }); 的 findOne 是 mock 的假函數(shù),沒有返回值,所以 validateUser 方法中的第 2-4 行代碼就能執(zhí)行到了:
if (!entity) { throw new UnauthorizedException('User not found');}
拋出 401 錯誤,符合預期。
validateUser 方法中的第二個處理邏輯是判斷用戶是否鎖定,對應的代碼如下:
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);}
可以看到如果用戶數(shù)據(jù)里有鎖定時間 lockUntil 并且鎖定結(jié)束時間大于當前時間就可以判斷當前賬戶處于鎖定狀態(tài)。所以需要 mock 一個具有 lockUntil 字段的用戶數(shù)據(jù):
it('should throw an UnauthorizedException if the account is locked', async () => { const lockedUser = { _id: TEST_USER_ID, username: TEST_USER_NAME, password: TEST_USER_PASSWORD, lockUntil: Date.now() + 1000 * 60 * 5, // The account is locked for 5 minutes }; usersService.findOne.mockResolvedValueOnce(lockedUser); await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException);});
在上面的測試代碼里,先定義了一個對象 lockedUser,這個對象里有我們想要的 lockUntil 字段,然后將它作為 findOne 的返回值,這通過 usersService.findOne.mockResolvedValueOnce(lockedUser); 實現(xiàn)。然后 validateUser 方法執(zhí)行時,里面的用戶數(shù)據(jù)就是 mock 出來的數(shù)據(jù)了,從而成功讓第二個測試用例通過。
剩下的兩個測試用例就不寫了,原理都是一樣的。如果剩下的兩個測試不寫,那么這個 validateUser 方法的單元測試覆蓋率會是 50%,如果 4 個測試用例都寫完了,那么 validateUser 方法的單元測試覆蓋率將達到 100%。
單元測試覆蓋率(Code Coverage)是一個度量,用于描述應用程序代碼有多少被單元測試覆蓋或測試過。它通常表示為百分比,表示在所有可能的代碼路徑中,有多少被測試用例覆蓋。
單元測試覆蓋率通常包括以下幾種類型:
?行覆蓋率(Lines):測試覆蓋了多少代碼行。?函數(shù)覆蓋率(Funcs):測試覆蓋了多少函數(shù)或方法。?分支覆蓋率(Branch):測試覆蓋了多少代碼分支(例如,if/else 語句)。?語句覆蓋率(Stmts):測試覆蓋了多少代碼語句。
單元測試覆蓋率是衡量單元測試質(zhì)量的一個重要指標,但并不是唯一的指標。高的覆蓋率可以幫助檢測代碼中的錯誤,但并不能保證代碼的質(zhì)量。覆蓋率低可能意味著有未被測試的代碼,可能存在未被發(fā)現(xiàn)的錯誤。
下圖是 demo 項目的單元測試覆蓋率結(jié)果:
圖片
像 service 和 controller 之類的文件,單元測試覆蓋率一般盡量高點比較好,而像 module 這種文件就沒有必要寫單元測試了,也沒法寫,沒有意義。上面的圖片表示的是整個單元測試覆蓋率的總體指標,如果你想查看某個函數(shù)的測試覆蓋率,可以打開項目根目錄下的 coverage/lcov-report/index.html 文件進行查看。例如我想查看 validateUser 方法具體的測試情況:
圖片
可以看到原來 validateUser 方法的單元測試覆蓋率并不是 100%,還是有兩行代碼沒有執(zhí)行到,不過也無所謂了,不影響 4 個關鍵的處理節(jié)點,不要片面的追求高測試覆蓋率。
在單元測試中我們展示了如何為 validateUser() 的每一個功能點編寫單元測試,并且使用了 mock 數(shù)據(jù)的方法來確保每個功能點都能夠被測試到。而在 e2e 測試中,我們需要模擬真實的用戶場景,所以要連接數(shù)據(jù)庫來進行測試。因此,這次測試的 auth.service.ts 模塊里的方法都會和數(shù)據(jù)庫進行交互。
auth 模塊主要有以下幾個功能:
?注冊?登錄?刷新 token?讀取用戶信息?修改密碼?刪除用戶。
e2e 測試需要將這六個功能都測試一遍,從注冊開始,到刪除用戶結(jié)束。在測試時,我們可以建一個專門的測試用戶來進行測試,測試完成后再刪除這個測試用戶,這樣就不會在測試數(shù)據(jù)庫中留下無用的信息了。
beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile() app = moduleFixture.createNestApplication() await app.init() // 執(zhí)行登錄以獲取令牌 const response = await request(app.getHttpServer()) .post('/auth/register') .send({ username: TEST_USER_NAME, password: TEST_USER_PASSWORD }) .expect(201) accessToken = response.body.access_token refreshToken = response.body.refresh_token})afterAll(async () => { await request(app.getHttpServer()) .delete('/auth/delete-user') .set('Authorization', `Bearer ${accessToken}`) .expect(200) await app.close()})
beforeAll 鉤子函數(shù)將在所有測試開始之前執(zhí)行,所以我們可以在這里注冊一個測試賬號 TEST_USER_NAME。afterAll 鉤子函數(shù)將在所有測試結(jié)束之后執(zhí)行,所以在這刪除測試賬號 TEST_USER_NAME 是比較合適的,還能順便對注冊和刪除兩個功能進行測試。
在上一節(jié)的單元測試中,我們編寫了關于 validateUser 方法的相關單元測試。其實這個方法是在登錄時執(zhí)行的,用于驗證用戶賬號密碼是否正確。所以這一次的 e2e 測試也將使用登錄流程來展示如何編寫 e2e 測試用例。
整個登錄測試流程總共包含了五個小測試:
describe('login', () => { it('/auth/login (POST)', () => { // ... }) it('/auth/login (POST) with user not found', () => { // ... }) it('/auth/login (POST) without username or password', async () => { // ... }) it('/auth/login (POST) with invalid password', () => { // ... }) it('/auth/login (POST) account lock after multiple failed attempts', async () => { // ... }) })
這五個測試分別是:
1.登錄成功,返回 2002.如果用戶不存在,拋出 401 異常3.如果不提供密碼或用戶名,拋出 400 異常4.使用錯誤密碼登錄,拋出 401 異常5.如果賬戶被鎖定,拋出 401 異常。
現(xiàn)在我們開始編寫 e2e 測試:
// 登錄成功it('/auth/login (POST)', () => { return request(app.getHttpServer()) .post('/auth/login') .send({ username: TEST_USER_NAME, password: TEST_USER_PASSWORD }) .expect(200)})// 如果用戶不存在,應該拋出 401 異常it('/auth/login (POST) with user not found', () => { return request(app.getHttpServer()) .post('/auth/login') .send({ username: TEST_USER_NAME2, password: TEST_USER_PASSWORD }) .expect(401) // Expect an unauthorized error})
e2e 的測試代碼寫起來比較簡單,直接調(diào)用接口,然后驗證結(jié)果就可以了。比如登錄成功測試,我們只要驗證返回結(jié)果是否是 200 即可。
前面四個測試都比較簡單,現(xiàn)在我們看一個稍微復雜點的 e2e 測試,即驗證賬戶是否被鎖定。
it('/auth/login (POST) account lock after multiple failed attempts', async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile() const app = moduleFixture.createNestApplication() await app.init() const registerResponse = await request(app.getHttpServer()) .post('/auth/register') .send({ username: TEST_USER_NAME2, password: TEST_USER_PASSWORD }) const accessToken = registerResponse.body.access_token const maxLoginAttempts = 3 // lock user when the third try is failed for (let i = 0; i < maxLoginAttempts; i++) { await request(app.getHttpServer()) .post('/auth/login') .send({ username: TEST_USER_NAME2, password: 'InvalidPassword' }) } // The account is locked after the third failed login attempt await request(app.getHttpServer()) .post('/auth/login') .send({ username: TEST_USER_NAME2, password: TEST_USER_PASSWORD }) .then((res) => { expect(res.body.message).toContain( 'The account is locked. Please try again in 5 minutes.', ) }) await request(app.getHttpServer()) .delete('/auth/delete-user') .set('Authorization', `Bearer ${accessToken}`) await app.close()})
當用戶連續(xù)三次登錄失敗的時候,賬戶就會被鎖定。所以在這個測試里,我們不能使用測試賬號 TEST_USER_NAME,因為測試成功的話這個賬戶就會被鎖定,無法繼續(xù)進行下面的測試了。我們需要再注冊一個新用戶 TEST_USER_NAME2,專門用來測試賬戶鎖定,測試成功后再刪除這個用戶。所以你可以看到這個 e2e 測試的代碼非常多,需要做大量的前置、后置工作,其實真正的測試代碼就這幾行:
// 連續(xù)三次登錄for (let i = 0; i < maxLoginAttempts; i++) { await request(app.getHttpServer()) .post('/auth/login') .send({ username: TEST_USER_NAME2, password: 'InvalidPassword' })}// 測試賬號是否被鎖定await request(app.getHttpServer()) .post('/auth/login') .send({ username: TEST_USER_NAME2, password: TEST_USER_PASSWORD }) .then((res) => { expect(res.body.message).toContain( 'The account is locked. Please try again in 5 minutes.', ) })
可以看到編寫 e2e 測試代碼還是相對比較簡單的,不需要考慮 mock 數(shù)據(jù),不需要考慮測試覆蓋率,只要整個系統(tǒng)流程的運轉(zhuǎn)情況符合預期就可以了。
如果有條件的話,我是比較建議大家寫測試的。因為寫測試可以提高系統(tǒng)的健壯性、可維護性和開發(fā)效率。
我們一般編寫代碼時,會關注于正常輸入下的程序流程,確保核心功能正常運作。但是一些邊緣情況,比如異常的輸入,這些我們可能會經(jīng)常忽略掉。但當我們開始編寫測試時,情況就不一樣了,這會逼迫你去考慮如何處理并提供相應的反饋,從而避免程序崩潰。可以說寫測試實際上是在間接地提高系統(tǒng)健壯性。
當你接手一個新項目時,如果項目包含完善的測試,那將會是一件很幸福的事情。它們就像是項目的指南,幫你快速把握各個功能點。只看測試代碼就能夠輕松地了解每個功能的預期行為和邊界條件,而不用你逐行的去查看每個功能的代碼。
想象一下,一個長時間未更新的項目突然接到了新需求。改了代碼后,你可能會擔心引入 bug,如果沒有測試,那就需要重新手動測試整個項目——浪費時間,效率低下。而有了完整的測試,一條命令就能得知代碼更改有沒有影響現(xiàn)有功能。即使出錯了,也能夠快速定位,找到問題點。
短期項目、需求迭代非常快的項目不建議寫測試。比如某些活動項目,活動結(jié)束就沒用了,這種項目就不需要寫測試。另外,需求迭代非常快的項目也不要寫測試,我剛才說寫測試能提高開發(fā)效率是有前提條件的,就是功能迭代比較慢的情況下,寫測試才能提高開發(fā)效率。如果你的功能今天剛寫完,隔一兩天就需求變更了要改功能,那相關的測試代碼都得重寫。所以干脆就別寫了,靠團隊里的測試人員測試就行了,因為寫測試是非常耗時間的,沒必要自討苦吃。
根據(jù)我的經(jīng)驗來看,國內(nèi)的絕大多數(shù)項目(尤其是政企類項目,這種項目你說要寫測試我都想笑)都是沒有必要寫測試的,因為需求迭代太快,還老是推翻之前的需求,代碼都得加班寫,那有閑情逸致寫測試。
在細致地講解了如何為 Nestjs 項目編寫單元測試及 e2e 測試之后,我還是想重申一下測試的重要性,它能夠提高系統(tǒng)的健壯性、可維護性和開發(fā)效率。如果沒有機會寫測試,我建議大家可以自己搞個練習項目來寫,或者說參加一些開源項目,給這些項目貢獻代碼,因為開源項目對于代碼要求一般都比較嚴格。貢獻代碼可能需要編寫新的測試用例或修改現(xiàn)有的測試用例。
NestJS[14]: A framework for building efficient, scalable Node.js server-side applications.
MongoDB[15]: A NoSQL database used for data storage.
Jest[16]: A testing framework for JavaScript and TypeScript.
Supertest[17]: A library for testing HTTP servers.
[1] nestjs-interview-demo: https://github.com/woai3c/nestjs-interview-demo
[2] 帶你入門前端工程: https://woai3c.github.io/introduction-to-front-end-engineering/
[3] 從零開始實現(xiàn)一個玩具版瀏覽器渲染引擎: https://github.com/woai3c/Front-end-articles/issues/44
[4] 手把手教你寫一個簡易的微前端框架: https://github.com/woai3c/Front-end-articles/issues/31
[5] 前端監(jiān)控 SDK 的一些技術要點原理分析: https://github.com/woai3c/Front-end-articles/issues/26
[6] 可視化拖拽組件庫一些技術要點原理分析: https://github.com/woai3c/Front-end-articles/issues/19
[7] 可視化拖拽組件庫一些技術要點原理分析(二): https://github.com/woai3c/Front-end-articles/issues/20
[8] 可視化拖拽組件庫一些技術要點原理分析(三): https://github.com/woai3c/Front-end-articles/issues/21
[9] 可視化拖拽組件庫一些技術要點原理分析(四): https://github.com/woai3c/Front-end-articles/issues/33
[10] 低代碼與大語言模型的探索實踐: https://github.com/woai3c/Front-end-articles/issues/45
[11] 前端性能優(yōu)化 24 條建議(2020): https://github.com/woai3c/Front-end-articles/blob/master/performance.md
[12] 手把手教你寫一個腳手架: https://github.com/woai3c/Front-end-articles/issues/22
[13] 手把手教你寫一個腳手架(二): https://github.com/woai3c/Front-end-articles/issues/23
[14] NestJS: https://nestjs.com/
[15] MongoDB: https://www.mongodb.com/
[16] Jest: https://jestjs.io/
[17] Supertest: https://github.com/visionmedia/supertest
本文鏈接:http://www.tebozhan.com/showinfo-26-89714-0.html如何為 Nest.js 編寫單元測試和 E2E 測試
聲明:本網(wǎng)頁內(nèi)容旨在傳播知識,若有侵權等問題請及時與本網(wǎng)聯(lián)系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
上一篇: 分享能提高開發(fā)效率,提高代碼質(zhì)量的八個前端裝飾器函數(shù)
下一篇: 如此絲滑的API設計,用起來真香