成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

如何為 Nest.js 編寫單元測試和 E2E 測試

開發 前端
單元測試是對軟件中的最小可測試單元進行檢查和驗證。比如一個函數、一個方法都可以是一個單元。在單元測試中,你會對這個函數的各種輸入給出預期的輸出,并驗證功能的正確性。單元測試的目標是快速發現函數內部的 bug,并且它們容易編寫、快速執行。

前言

最近在給一個 nestjs 項目寫單元測試(Unit Testing)和 e2e 測試(End-to-End Testing,端到端測試,簡稱 e2e 測試),這是我第一次給后端項目寫測試,發現和之前給前端項目寫測試還不太一樣,導致在一開始寫測試時感覺無從下手。后來在看了一些示例之后才想明白怎么寫測試,所以打算寫篇文章記錄并分享一下,以幫助和我有相同困惑的人。

同時我也寫了一個 demo 項目,相關的單元測試、e2e 測試都寫好了,有興趣可以看一下。代碼已上傳到 Github: nestjs-interview-demo[1]。

單元測試和 E2E 測試的區別

單元測試和 e2e 測試都是軟件測試的方法,但它們的目標和范圍有所不同。

單元測試是對軟件中的最小可測試單元進行檢查和驗證。比如一個函數、一個方法都可以是一個單元。在單元測試中,你會對這個函數的各種輸入給出預期的輸出,并驗證功能的正確性。單元測試的目標是快速發現函數內部的 bug,并且它們容易編寫、快速執行。

而 e2e 測試通常通過模擬真實用戶場景的方法來測試整個應用,例如前端通常使用瀏覽器或無頭瀏覽器來進行測試,后端則是通過模擬對 API 的調用來進行測試。

在 nestjs 項目中,單元測試可能會測試某個服務(service)、某個控制器(controller)的一個方法,例如測試 Users 模塊中的 update 方法是否能正確的更新一個用戶。而一個 e2e 測試可能會測試一個完整的用戶流程,如創建一個新用戶,然后更新他們的密碼,然后刪除該用戶。這涉及了多個服務和控制器。

編寫單元測試

為一個工具函數或者不涉及接口的方法編寫單元測試,是非常簡單的,你只需要考慮各種輸入并編寫相應的測試代碼就可以了。但是一旦涉及到接口,那情況就復雜了。用代碼來舉例:

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.根據 username 查看用戶是否存在,如果不存在則拋出 401 異常(也可以是 404 異常)2.查看用戶是否被鎖定,如果被鎖定則拋出 401 異常和相關的提示文字3.將 password 加密后和數據庫中的密碼進行對比,如果錯誤則拋出 401 異常(連續三次登錄失敗會被鎖定賬戶 5 分鐘)4.如果登錄成功,則將之前登錄失敗的計數記錄進行清空(如果有)并返回用戶 id 和 username 到下一階段

可以看到 validateUser 方法包含了 4 個處理邏輯,我們需要對這 4 點都編寫對應的單元測試代碼,以確定整個 validateUser 方法功能是正常的。

第一個測試用例

在開始編寫單元測試時,我們會遇到一個問題,findOne 方法需要和數據庫進行交互,它要通過 username 查找數據庫中是否存在對應的用戶。但如果每一個單元測試都得和數據庫進行交互,那測試起來會非常麻煩。所以可以通過 mock 假數據來實現這一點。

舉例,假如我們已經注冊了一個 woai3c 的用戶,那么當用戶登錄時,在 validateUser 方法中能夠通過 const entity = await this.usersService.findOne({ username }); 拿到用戶數據。所以只要確保這行代碼能夠返回想要的數據,即使不和數據庫交互也是沒有問題的。而這一點,我們能通過 mock 數據來實現?,F在來看一下 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...
  });
});

我們通過調用 usersService 的 fineOne 方法來拿到用戶數據,所以需要在測試代碼中 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() 返回一個函數來代替真實的 usersService.findOne()。如果這時調用 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 方法中調用 const entity = await this.usersService.findOne({ username }); 的 findOne 是 mock 的假函數,沒有返回值,所以 validateUser 方法中的第 2-4 行代碼就能執行到了:

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);
}

可以看到如果用戶數據里有鎖定時間 lockUntil 并且鎖定結束時間大于當前時間就可以判斷當前賬戶處于鎖定狀態。所以需要 mock 一個具有 lockUntil 字段的用戶數據:

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); 實現。然后 validateUser 方法執行時,里面的用戶數據就是 mock 出來的數據了,從而成功讓第二個測試用例通過。

單元測試覆蓋率

剩下的兩個測試用例就不寫了,原理都是一樣的。如果剩下的兩個測試不寫,那么這個 validateUser 方法的單元測試覆蓋率會是 50%,如果 4 個測試用例都寫完了,那么 validateUser 方法的單元測試覆蓋率將達到 100%。

單元測試覆蓋率(Code Coverage)是一個度量,用于描述應用程序代碼有多少被單元測試覆蓋或測試過。它通常表示為百分比,表示在所有可能的代碼路徑中,有多少被測試用例覆蓋。

單元測試覆蓋率通常包括以下幾種類型:

?行覆蓋率(Lines):測試覆蓋了多少代碼行。?函數覆蓋率(Funcs):測試覆蓋了多少函數或方法。?分支覆蓋率(Branch):測試覆蓋了多少代碼分支(例如,if/else 語句)。?語句覆蓋率(Stmts):測試覆蓋了多少代碼語句。

單元測試覆蓋率是衡量單元測試質量的一個重要指標,但并不是唯一的指標。高的覆蓋率可以幫助檢測代碼中的錯誤,但并不能保證代碼的質量。覆蓋率低可能意味著有未被測試的代碼,可能存在未被發現的錯誤。

下圖是 demo 項目的單元測試覆蓋率結果:

圖片圖片

像 service 和 controller 之類的文件,單元測試覆蓋率一般盡量高點比較好,而像 module 這種文件就沒有必要寫單元測試了,也沒法寫,沒有意義。上面的圖片表示的是整個單元測試覆蓋率的總體指標,如果你想查看某個函數的測試覆蓋率,可以打開項目根目錄下的 coverage/lcov-report/index.html 文件進行查看。例如我想查看 validateUser 方法具體的測試情況:

圖片圖片

可以看到原來 validateUser 方法的單元測試覆蓋率并不是 100%,還是有兩行代碼沒有執行到,不過也無所謂了,不影響 4 個關鍵的處理節點,不要片面的追求高測試覆蓋率。

編寫E2E 測試

在單元測試中我們展示了如何為 validateUser() 的每一個功能點編寫單元測試,并且使用了 mock 數據的方法來確保每個功能點都能夠被測試到。而在 e2e 測試中,我們需要模擬真實的用戶場景,所以要連接數據庫來進行測試。因此,這次測試的 auth.service.ts 模塊里的方法都會和數據庫進行交互。

auth 模塊主要有以下幾個功能:

?注冊?登錄?刷新 token?讀取用戶信息?修改密碼?刪除用戶。

e2e 測試需要將這六個功能都測試一遍,從注冊開始,到刪除用戶結束。在測試時,我們可以建一個專門的測試用戶來進行測試,測試完成后再刪除這個測試用戶,這樣就不會在測試數據庫中留下無用的信息了。

beforeAll(async () => {
  const moduleFixture: TestingModule = await Test.createTestingModule({
    imports: [AppModule],
  }).compile()


  app = moduleFixture.createNestApplication()
  await app.init()


  // 執行登錄以獲取令牌
  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 鉤子函數將在所有測試開始之前執行,所以我們可以在這里注冊一個測試賬號 TEST_USER_NAME。afterAll 鉤子函數將在所有測試結束之后執行,所以在這刪除測試賬號 TEST_USER_NAME 是比較合適的,還能順便對注冊和刪除兩個功能進行測試。

在上一節的單元測試中,我們編寫了關于 validateUser 方法的相關單元測試。其實這個方法是在登錄時執行的,用于驗證用戶賬號密碼是否正確。所以這一次的 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 異常。

現在我們開始編寫 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 的測試代碼寫起來比較簡單,直接調用接口,然后驗證結果就可以了。比如登錄成功測試,我們只要驗證返回結果是否是 200 即可。

前面四個測試都比較簡單,現在我們看一個稍微復雜點的 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()
})

當用戶連續三次登錄失敗的時候,賬戶就會被鎖定。所以在這個測試里,我們不能使用測試賬號 TEST_USER_NAME,因為測試成功的話這個賬戶就會被鎖定,無法繼續進行下面的測試了。我們需要再注冊一個新用戶 TEST_USER_NAME2,專門用來測試賬戶鎖定,測試成功后再刪除這個用戶。所以你可以看到這個 e2e 測試的代碼非常多,需要做大量的前置、后置工作,其實真正的測試代碼就這幾行:

// 連續三次登錄
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 數據,不需要考慮測試覆蓋率,只要整個系統流程的運轉情況符合預期就可以了。

應不應該寫測試

如果有條件的話,我是比較建議大家寫測試的。因為寫測試可以提高系統的健壯性、可維護性和開發效率。

提高系統健壯性

我們一般編寫代碼時,會關注于正常輸入下的程序流程,確保核心功能正常運作。但是一些邊緣情況,比如異常的輸入,這些我們可能會經常忽略掉。但當我們開始編寫測試時,情況就不一樣了,這會逼迫你去考慮如何處理并提供相應的反饋,從而避免程序崩潰。可以說寫測試實際上是在間接地提高系統健壯性。

提高可維護性

當你接手一個新項目時,如果項目包含完善的測試,那將會是一件很幸福的事情。它們就像是項目的指南,幫你快速把握各個功能點。只看測試代碼就能夠輕松地了解每個功能的預期行為和邊界條件,而不用你逐行的去查看每個功能的代碼。

提高開發效率

想象一下,一個長時間未更新的項目突然接到了新需求。改了代碼后,你可能會擔心引入 bug,如果沒有測試,那就需要重新手動測試整個項目——浪費時間,效率低下。而有了完整的測試,一條命令就能得知代碼更改有沒有影響現有功能。即使出錯了,也能夠快速定位,找到問題點。

什么時候不建議寫測試?

短期項目、需求迭代非??斓捻椖坎唤ㄗh寫測試。比如某些活動項目,活動結束就沒用了,這種項目就不需要寫測試。另外,需求迭代非常快的項目也不要寫測試,我剛才說寫測試能提高開發效率是有前提條件的,就是功能迭代比較慢的情況下,寫測試才能提高開發效率。如果你的功能今天剛寫完,隔一兩天就需求變更了要改功能,那相關的測試代碼都得重寫。所以干脆就別寫了,靠團隊里的測試人員測試就行了,因為寫測試是非常耗時間的,沒必要自討苦吃。

根據我的經驗來看,國內的絕大多數項目(尤其是政企類項目,這種項目你說要寫測試我都想笑)都是沒有必要寫測試的,因為需求迭代太快,還老是推翻之前的需求,代碼都得加班寫,那有閑情逸致寫測試。

總結

在細致地講解了如何為 Nestjs 項目編寫單元測試及 e2e 測試之后,我還是想重申一下測試的重要性,它能夠提高系統的健壯性、可維護性和開發效率。如果沒有機會寫測試,我建議大家可以自己搞個練習項目來寫,或者說參加一些開源項目,給這些項目貢獻代碼,因為開源項目對于代碼要求一般都比較嚴格。貢獻代碼可能需要編寫新的測試用例或修改現有的測試用例。

參考資料

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] 從零開始實現一個玩具版瀏覽器渲染引擎: https://github.com/woai3c/Front-end-articles/issues/44

[4] 手把手教你寫一個簡易的微前端框架: https://github.com/woai3c/Front-end-articles/issues/31

[5] 前端監控 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] 前端性能優化 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

責任編輯:武曉燕 來源: 前端編程技術分享
相關推薦

2021-08-02 12:04:39

測試測試框架Cypress

2017-01-14 23:42:49

單元測試框架軟件測試

2018-06-07 13:17:12

契約測試單元測試API測試

2021-06-18 06:48:54

前端Nest.js技術熱點

2017-03-22 11:32:17

Node.js單元測試

2017-02-23 15:59:53

測試MockSetup

2013-06-14 09:41:07

網絡規劃工程外包華為

2020-08-18 08:10:02

單元測試Java

2011-04-18 13:20:40

單元測試軟件測試

2020-09-30 08:08:15

單元測試應用

2017-03-28 12:25:36

2017-09-10 17:41:39

React全家桶單元測試前端測試

2020-12-09 14:13:37

人工智能機器學習技術

2011-08-11 13:02:43

Struts2Junit

2017-01-16 12:12:29

單元測試JUnit

2017-01-14 23:26:17

單元測試JUnit測試

2022-03-18 21:51:10

Nest.jsAOP 架構后端

2011-06-20 17:25:02

單元測試

2025-06-25 09:51:53

2021-03-28 23:03:50

Python程序員編碼
點贊
收藏

51CTO技術棧公眾號

主站蜘蛛池模板: 中文字幕一区二区三区在线观看 | 在线a视频 | 男人的天堂久久 | 欧美极品在线视频 | 91社区在线观看高清 | www国产成人免费观看视频,深夜成人网 | 国产成人在线播放 | 一级片视频免费观看 | 婷婷色网 | 亚洲国产激情 | 久久久久黄色 | 欧美黑人一区 | 在线看av的网址 | 五月激情综合 | 99小视频 | 亚洲免费在线观看 | 国产精品毛片一区二区在线看 | 久久精品这里精品 | 国产精品久久久久久中文字 | 五月天婷婷综合 | 国产高清视频一区 | 欧美亚洲视频在线观看 | 亚洲欧美日韩精品久久亚洲区 | 国产不卡在线 | 日韩在线观看中文字幕 | 91久久精品一区二区二区 | 日本午夜在线视频 | 精品一区二区三区日本 | 精品一区久久 | av中文网 | 亚洲精色 | www视频在线观看 | 日本在线免费看最新的电影 | 四虎成人av| 久久久亚洲成人 | 欧美日韩久久久久 | va在线| 人人爽日日躁夜夜躁尤物 | 亚洲电影一区 | 久久曰视频 | 91精品久久久 |