如何為 Nest.js 編寫單元測試和 E2E 測試
前言
最近在給一個(gè) nestjs 項(xiàng)目寫單元測試(Unit Testing)和 e2e 測試(End-to-End Testing,端到端測試,簡稱 e2e 測試),這是我第一次給后端項(xiàng)目寫測試,發(fā)現(xiàn)和之前給前端項(xiàng)目寫測試還不太一樣,導(dǎo)致在一開始寫測試時(shí)感覺無從下手。后來在看了一些示例之后才想明白怎么寫測試,所以打算寫篇文章記錄并分享一下,以幫助和我有相同困惑的人。
同時(shí)我也寫了一個(gè) demo 項(xiàng)目,相關(guān)的單元測試、e2e 測試都寫好了,有興趣可以看一下。代碼已上傳到 Github: nestjs-interview-demo[1]。
單元測試和 E2E 測試的區(qū)別
單元測試和 e2e 測試都是軟件測試的方法,但它們的目標(biāo)和范圍有所不同。
單元測試是對軟件中的最小可測試單元進(jìn)行檢查和驗(yàn)證。比如一個(gè)函數(shù)、一個(gè)方法都可以是一個(gè)單元。在單元測試中,你會(huì)對這個(gè)函數(shù)的各種輸入給出預(yù)期的輸出,并驗(yàn)證功能的正確性。單元測試的目標(biāo)是快速發(fā)現(xiàn)函數(shù)內(nèi)部的 bug,并且它們?nèi)菀拙帉?、快速?zhí)行。
而 e2e 測試通常通過模擬真實(shí)用戶場景的方法來測試整個(gè)應(yīng)用,例如前端通常使用瀏覽器或無頭瀏覽器來進(jìn)行測試,后端則是通過模擬對 API 的調(diào)用來進(jìn)行測試。
在 nestjs 項(xiàng)目中,單元測試可能會(huì)測試某個(gè)服務(wù)(service)、某個(gè)控制器(controller)的一個(gè)方法,例如測試 Users 模塊中的 update 方法是否能正確的更新一個(gè)用戶。而一個(gè) e2e 測試可能會(huì)測試一個(gè)完整的用戶流程,如創(chuàng)建一個(gè)新用戶,然后更新他們的密碼,然后刪除該用戶。這涉及了多個(gè)服務(wù)和控制器。
編寫單元測試
為一個(gè)工具函數(shù)或者不涉及接口的方法編寫單元測試,是非常簡單的,你只需要考慮各種輸入并編寫相應(yīng)的測試代碼就可以了。但是一旦涉及到接口,那情況就復(fù)雜了。用代碼來舉例:
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 文件里的一個(gè)方法 validateUser,主要用于驗(yàn)證登錄時(shí)用戶輸入的賬號(hào)密碼是否正確。它包含的邏輯如下:
1.根據(jù) username 查看用戶是否存在,如果不存在則拋出 401 異常(也可以是 404 異常)2.查看用戶是否被鎖定,如果被鎖定則拋出 401 異常和相關(guān)的提示文字3.將 password 加密后和數(shù)據(jù)庫中的密碼進(jìn)行對比,如果錯(cuò)誤則拋出 401 異常(連續(xù)三次登錄失敗會(huì)被鎖定賬戶 5 分鐘)4.如果登錄成功,則將之前登錄失敗的計(jì)數(shù)記錄進(jìn)行清空(如果有)并返回用戶 id 和 username 到下一階段
可以看到 validateUser 方法包含了 4 個(gè)處理邏輯,我們需要對這 4 點(diǎn)都編寫對應(yīng)的單元測試代碼,以確定整個(gè) validateUser 方法功能是正常的。
第一個(gè)測試用例
在開始編寫單元測試時(shí),我們會(huì)遇到一個(gè)問題,findOne 方法需要和數(shù)據(jù)庫進(jìn)行交互,它要通過 username 查找數(shù)據(jù)庫中是否存在對應(yīng)的用戶。但如果每一個(gè)單元測試都得和數(shù)據(jù)庫進(jìn)行交互,那測試起來會(huì)非常麻煩。所以可以通過 mock 假數(shù)據(jù)來實(shí)現(xiàn)這一點(diǎn)。
舉例,假如我們已經(jīng)注冊了一個(gè) woai3c 的用戶,那么當(dāng)用戶登錄時(shí),在 validateUser 方法中能夠通過 const entity = await this.usersService.findOne({ username }); 拿到用戶數(shù)據(jù)。所以只要確保這行代碼能夠返回想要的數(shù)據(jù),即使不和數(shù)據(jù)庫交互也是沒有問題的。而這一點(diǎn),我們能通過 mock 數(shù)據(jù)來實(shí)現(xiàn)?,F(xiàn)在來看一下 validateUser 方法的相關(guān)測試代碼:
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, // 真實(shí)的 AuthService,因?yàn)槲覀円獙λ姆椒ㄟM(jìn)行測試
{
provide: UsersService, // 用 mock 的 usersService 代替真實(shí)的 usersService
useValue: usersService,
},
],
}).compile();
authService = module.get<AuthService>(AuthService);
});
通過使用 jest.fn() 返回一個(gè)函數(shù)來代替真實(shí)的 usersService.findOne()。如果這時(shí)調(diào)用 usersService.findOne() 將不會(huì)有任何返回值,所以第一個(gè)單元測試用例就能通過了:
it('should throw an UnauthorizedException if user is not found', async () => {
await expect(
authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD),
).rejects.toThrow(UnauthorizedException);
});
因?yàn)樵?nbsp;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 錯(cuò)誤,符合預(yù)期。
第二個(gè)測試用例
validateUser 方法中的第二個(gè)處理邏輯是判斷用戶是否鎖定,對應(yīng)的代碼如下:
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ù)里有鎖定時(shí)間 lockUntil 并且鎖定結(jié)束時(shí)間大于當(dāng)前時(shí)間就可以判斷當(dāng)前賬戶處于鎖定狀態(tài)。所以需要 mock 一個(gè)具有 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);
});
在上面的測試代碼里,先定義了一個(gè)對象 lockedUser,這個(gè)對象里有我們想要的 lockUntil 字段,然后將它作為 findOne 的返回值,這通過 usersService.findOne.mockResolvedValueOnce(lockedUser); 實(shí)現(xiàn)。然后 validateUser 方法執(zhí)行時(shí),里面的用戶數(shù)據(jù)就是 mock 出來的數(shù)據(jù)了,從而成功讓第二個(gè)測試用例通過。
單元測試覆蓋率
剩下的兩個(gè)測試用例就不寫了,原理都是一樣的。如果剩下的兩個(gè)測試不寫,那么這個(gè) validateUser 方法的單元測試覆蓋率會(huì)是 50%,如果 4 個(gè)測試用例都寫完了,那么 validateUser 方法的單元測試覆蓋率將達(dá)到 100%。
單元測試覆蓋率(Code Coverage)是一個(gè)度量,用于描述應(yīng)用程序代碼有多少被單元測試覆蓋或測試過。它通常表示為百分比,表示在所有可能的代碼路徑中,有多少被測試用例覆蓋。
單元測試覆蓋率通常包括以下幾種類型:
?行覆蓋率(Lines):測試覆蓋了多少代碼行。?函數(shù)覆蓋率(Funcs):測試覆蓋了多少函數(shù)或方法。?分支覆蓋率(Branch):測試覆蓋了多少代碼分支(例如,if/else 語句)。?語句覆蓋率(Stmts):測試覆蓋了多少代碼語句。
單元測試覆蓋率是衡量單元測試質(zhì)量的一個(gè)重要指標(biāo),但并不是唯一的指標(biāo)。高的覆蓋率可以幫助檢測代碼中的錯(cuò)誤,但并不能保證代碼的質(zhì)量。覆蓋率低可能意味著有未被測試的代碼,可能存在未被發(fā)現(xiàn)的錯(cuò)誤。
下圖是 demo 項(xiàng)目的單元測試覆蓋率結(jié)果:
圖片
像 service 和 controller 之類的文件,單元測試覆蓋率一般盡量高點(diǎn)比較好,而像 module 這種文件就沒有必要寫單元測試了,也沒法寫,沒有意義。上面的圖片表示的是整個(gè)單元測試覆蓋率的總體指標(biāo),如果你想查看某個(gè)函數(shù)的測試覆蓋率,可以打開項(xiàng)目根目錄下的 coverage/lcov-report/index.html 文件進(jìn)行查看。例如我想查看 validateUser 方法具體的測試情況:
圖片
可以看到原來 validateUser 方法的單元測試覆蓋率并不是 100%,還是有兩行代碼沒有執(zhí)行到,不過也無所謂了,不影響 4 個(gè)關(guān)鍵的處理節(jié)點(diǎn),不要片面的追求高測試覆蓋率。
編寫E2E 測試
在單元測試中我們展示了如何為 validateUser() 的每一個(gè)功能點(diǎn)編寫單元測試,并且使用了 mock 數(shù)據(jù)的方法來確保每個(gè)功能點(diǎn)都能夠被測試到。而在 e2e 測試中,我們需要模擬真實(shí)的用戶場景,所以要連接數(shù)據(jù)庫來進(jìn)行測試。因此,這次測試的 auth.service.ts 模塊里的方法都會(huì)和數(shù)據(jù)庫進(jìn)行交互。
auth 模塊主要有以下幾個(gè)功能:
?注冊?登錄?刷新 token?讀取用戶信息?修改密碼?刪除用戶。
e2e 測試需要將這六個(gè)功能都測試一遍,從注冊開始,到刪除用戶結(jié)束。在測試時(shí),我們可以建一個(gè)專門的測試用戶來進(jìn)行測試,測試完成后再刪除這個(gè)測試用戶,這樣就不會(huì)在測試數(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í)行,所以我們可以在這里注冊一個(gè)測試賬號(hào) TEST_USER_NAME。afterAll 鉤子函數(shù)將在所有測試結(jié)束之后執(zhí)行,所以在這刪除測試賬號(hào) TEST_USER_NAME 是比較合適的,還能順便對注冊和刪除兩個(gè)功能進(jìn)行測試。
在上一節(jié)的單元測試中,我們編寫了關(guān)于 validateUser 方法的相關(guān)單元測試。其實(shí)這個(gè)方法是在登錄時(shí)執(zhí)行的,用于驗(yàn)證用戶賬號(hào)密碼是否正確。所以這一次的 e2e 測試也將使用登錄流程來展示如何編寫 e2e 測試用例。
整個(gè)登錄測試流程總共包含了五個(gè)小測試:
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 () => {
// ...
})
})
這五個(gè)測試分別是:
1.登錄成功,返回 2002.如果用戶不存在,拋出 401 異常3.如果不提供密碼或用戶名,拋出 400 異常4.使用錯(cuò)誤密碼登錄,拋出 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)
})
// 如果用戶不存在,應(yīng)該拋出 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)用接口,然后驗(yàn)證結(jié)果就可以了。比如登錄成功測試,我們只要驗(yàn)證返回結(jié)果是否是 200 即可。
前面四個(gè)測試都比較簡單,現(xiàn)在我們看一個(gè)稍微復(fù)雜點(diǎn)的 e2e 測試,即驗(yàn)證賬戶是否被鎖定。
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()
})
當(dāng)用戶連續(xù)三次登錄失敗的時(shí)候,賬戶就會(huì)被鎖定。所以在這個(gè)測試?yán)?,我們不能使用測試賬號(hào) TEST_USER_NAME,因?yàn)闇y試成功的話這個(gè)賬戶就會(huì)被鎖定,無法繼續(xù)進(jìn)行下面的測試了。我們需要再注冊一個(gè)新用戶 TEST_USER_NAME2,專門用來測試賬戶鎖定,測試成功后再刪除這個(gè)用戶。所以你可以看到這個(gè) e2e 測試的代碼非常多,需要做大量的前置、后置工作,其實(shí)真正的測試代碼就這幾行:
// 連續(xù)三次登錄
for (let i = 0; i < maxLoginAttempts; i++) {
await request(app.getHttpServer())
.post('/auth/login')
.send({ username: TEST_USER_NAME2, password: 'InvalidPassword' })
}
// 測試賬號(hào)是否被鎖定
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ù),不需要考慮測試覆蓋率,只要整個(gè)系統(tǒng)流程的運(yùn)轉(zhuǎn)情況符合預(yù)期就可以了。
應(yīng)不應(yīng)該寫測試
如果有條件的話,我是比較建議大家寫測試的。因?yàn)閷憸y試可以提高系統(tǒng)的健壯性、可維護(hù)性和開發(fā)效率。
提高系統(tǒng)健壯性
我們一般編寫代碼時(shí),會(huì)關(guān)注于正常輸入下的程序流程,確保核心功能正常運(yùn)作。但是一些邊緣情況,比如異常的輸入,這些我們可能會(huì)經(jīng)常忽略掉。但當(dāng)我們開始編寫測試時(shí),情況就不一樣了,這會(huì)逼迫你去考慮如何處理并提供相應(yīng)的反饋,從而避免程序崩潰??梢哉f寫測試實(shí)際上是在間接地提高系統(tǒng)健壯性。
提高可維護(hù)性
當(dāng)你接手一個(gè)新項(xiàng)目時(shí),如果項(xiàng)目包含完善的測試,那將會(huì)是一件很幸福的事情。它們就像是項(xiàng)目的指南,幫你快速把握各個(gè)功能點(diǎn)。只看測試代碼就能夠輕松地了解每個(gè)功能的預(yù)期行為和邊界條件,而不用你逐行的去查看每個(gè)功能的代碼。
提高開發(fā)效率
想象一下,一個(gè)長時(shí)間未更新的項(xiàng)目突然接到了新需求。改了代碼后,你可能會(huì)擔(dān)心引入 bug,如果沒有測試,那就需要重新手動(dòng)測試整個(gè)項(xiàng)目——浪費(fèi)時(shí)間,效率低下。而有了完整的測試,一條命令就能得知代碼更改有沒有影響現(xiàn)有功能。即使出錯(cuò)了,也能夠快速定位,找到問題點(diǎn)。
什么時(shí)候不建議寫測試?
短期項(xiàng)目、需求迭代非??斓捻?xiàng)目不建議寫測試。比如某些活動(dòng)項(xiàng)目,活動(dòng)結(jié)束就沒用了,這種項(xiàng)目就不需要寫測試。另外,需求迭代非??斓捻?xiàng)目也不要寫測試,我剛才說寫測試能提高開發(fā)效率是有前提條件的,就是功能迭代比較慢的情況下,寫測試才能提高開發(fā)效率。如果你的功能今天剛寫完,隔一兩天就需求變更了要改功能,那相關(guān)的測試代碼都得重寫。所以干脆就別寫了,靠團(tuán)隊(duì)里的測試人員測試就行了,因?yàn)閷憸y試是非常耗時(shí)間的,沒必要自討苦吃。
根據(jù)我的經(jīng)驗(yàn)來看,國內(nèi)的絕大多數(shù)項(xiàng)目(尤其是政企類項(xiàng)目,這種項(xiàng)目你說要寫測試我都想笑)都是沒有必要寫測試的,因?yàn)樾枨蟮?,還老是推翻之前的需求,代碼都得加班寫,那有閑情逸致寫測試。
總結(jié)
在細(xì)致地講解了如何為 Nestjs 項(xiàng)目編寫單元測試及 e2e 測試之后,我還是想重申一下測試的重要性,它能夠提高系統(tǒng)的健壯性、可維護(hù)性和開發(fā)效率。如果沒有機(jī)會(huì)寫測試,我建議大家可以自己搞個(gè)練習(xí)項(xiàng)目來寫,或者說參加一些開源項(xiàng)目,給這些項(xiàng)目貢獻(xiàn)代碼,因?yàn)殚_源項(xiàng)目對于代碼要求一般都比較嚴(yán)格。貢獻(xiàn)代碼可能需要編寫新的測試用例或修改現(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.
References
[1] nestjs-interview-demo: https://github.com/woai3c/nestjs-interview-demo
[2] 帶你入門前端工程: https://woai3c.github.io/introduction-to-front-end-engineering/
[3] 從零開始實(shí)現(xiàn)一個(gè)玩具版瀏覽器渲染引擎: https://github.com/woai3c/Front-end-articles/issues/44
[4] 手把手教你寫一個(gè)簡易的微前端框架: https://github.com/woai3c/Front-end-articles/issues/31
[5] 前端監(jiān)控 SDK 的一些技術(shù)要點(diǎn)原理分析: https://github.com/woai3c/Front-end-articles/issues/26
[6] 可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析: https://github.com/woai3c/Front-end-articles/issues/19
[7] 可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析(二): https://github.com/woai3c/Front-end-articles/issues/20
[8] 可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析(三): https://github.com/woai3c/Front-end-articles/issues/21
[9] 可視化拖拽組件庫一些技術(shù)要點(diǎn)原理分析(四): https://github.com/woai3c/Front-end-articles/issues/33
[10] 低代碼與大語言模型的探索實(shí)踐: 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] 手把手教你寫一個(gè)腳手架: https://github.com/woai3c/Front-end-articles/issues/22
[13] 手把手教你寫一個(gè)腳手架(二): 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