JavaScript 單元測試筆記
基本介紹
測試的類型
- 單元測試 Unit Testing -> 測試積木
- 集成測試 Integration Testing -> 測試積木的組合
- 端到端測試 End-to-End (E2E) Testing -> 實際測試最重要的行為與流程會觸發的API
- 可訪問性測試 Accessibility Testing
- …
Test-Driven Development (TDD) 測試驅動開發
- 它是種撰寫測試的框架、哲學
- 每當想添加新行為或新單位時,先寫一個失敗的測試來定義預期行為
- 後續實現邏輯,之後再優化邏輯、迭代重構
測試所需的環境設定
- Test Runner 測試運行器:如Jest, Karma
- Assertion Library 斷言庫:定義預期的結果,什麼被視為成功、什麼被視為失敗。如Jest, Chai
可以發現,Jest涵蓋了運行器與斷言庫,並且若你是使用Creat React App或Angular CLI創建項目,其中已預先安裝了Jest,你可以隨時使用Jest開始撰寫測試。 Jest的使用很簡單,卻也有其缺點:如果你有一個使用ES module的項目,並使用那些導入導出的語句,Jest的設置將會變得很煩人。它確實支持ECMAScript,但通常還需要安裝額外的工具、設置額外的工作流程,以利於幕後測試代碼的運作...這違背了測試的本意。(不進行額外設置的話,連import css module都會讓測試卡住!)
因此,在之後的文章中我們將使用Vitest,一個搞定一切且非常流行的新工具。它速度更快、可以編寫與Jest語法兼容的測試、可以與ES module一起使用,還有很多額外的便利功能。就像Jest一樣,它既是測試運行器、也是斷言庫,因此我們不需要其他額外的設置,可以直接使用Vitest。
安裝
npm install --save-dev vitest
{"test": vitest --globals} —globals只是確保你可以使用其所有的特殊功能。
就這樣,沒有其他額外的設置了!
其他可能的設置選項:
{ "test": "vitest --run --reporter verbose", // 列出詳盡的輸出 "test:watch": "vitest", // 持續運行測試 }
測試基礎
建立單元測試
很多時候,你的測試單位是指一個函數或一個類別。找到那些沒有調用其他任何函數的函數,它們應該很小、並且不做太多的事情。 接著,在你的文件擴展名之前,加上.test或.spec然後創建一個檔案。這個檔案會是個測試文件,一旦我們執行Vitest,它將執行我們在此文件中所編寫的任何測試。 要使用測試,我們必須使用特定的函數
import {test} from 'vitest';,我們可以在script中添加—globals來省略import。也可以使用it,它和test在Jest與Vitest中都是同一個意思。
import {it, expect} from 'vitest'; import {要測試的函數} from ''; // 如果你用的是ES module,並且沒有像Webpack這樣的內置工具,那在主文件中就必須把文件擴展名.js一起寫進from裡,但在vitest中,我們「不用寫文件擴展名」 it('應該完成的事',()=>{ const value = [1,3] const result = 要測試的函數(value); expect(result).to期望得到的結果(); }) it('should throw an error if no value is passed into the function', () => { // try { // const result = add(); // } catch (e) { // expect(e).toBeFined(); // } const resultFn = ()=>{ // 想在斷言階段才執行的函數可以先包裝起來 add(); }; expect(resultFn).toThrow(); });
- .not
- .toBe()
- .toBeDefined()
- .toBeNaN()
- .toBeTypeOf()
- number
- .toContain(item)
- .toEqual()
- .toThrow()
- 縮小可能引發的錯誤範圍:可通過.not獲得完整的錯誤訊息,然後做為參數傳入toThrow()中
- 包裝函數可命名為 validationFn()
AAA:讓你的測試井井有條
* Arange:定義測試中要使用的環境和值 * Act:執行應該測試的功能 * Assert:評估結果,設定期望
該測試什麼?如何將測試組織化?
我們的測試要包含應該發生的事、不該發生的事、傳入無效值等,但應保持每個測試單位盡可能簡潔明瞭、避免不必要的複雜。 我們不應該只為一個單元編寫一個測試,且編寫好的測試是一個迭代過程,你可能在開發其他組件時,偶然發現需要寫一個新的測試來處理新的案例,這時我們就會增加更多測試。 當我們在一個測試檔案裡有許多則測試時,可以將這些測試用可以嵌套的describe組織起來。 通常寫為:
describe('你測試的函數名稱()',()=>{})
如何撰寫好的測試?
應該和不應該測試什麼樣的代碼
不要測試:
- 第三方代碼、框架或功能
- browser API、fetch()
- DOM操作
- server-side code
- 如果想測試自己的後端代碼,請另外創建一份獨立於前端的測試文件
- NodeJS Packages
- 你無法改變的東西 要測試:
- 你的代碼
- 對API預期數據的反應、缺少數據或可能的錯誤
好的測試應該簡單明瞭
* Arrange - Act - Assert 保持測試代碼有條理,結構清晰且易於理解 * 只測試**一件事**、一個單元、一種行為或一種預期結果 * 專注於本質的測試,可以幫助我們了解他們未通過的原因 * 盡量降低excape的數量
改善測試與代碼是個迭代的過程
當一個函數做得事情很多、不容易編寫測試時,我們應該根據Clean Code的規則拆分它,分成獨立的邏輯部分。如此,我們的函數將更具可讀性與可維護性,我們的測試也將變得更易於管理。
關於代碼覆蓋率
有一些工具可以幫我們測量代碼覆蓋率,事實上Vitest中就有內置功能。不過實際上,我們也不需要追求100%的覆蓋率:總有些代碼不需要任何測試(例如只是調用已測試過的函數)。此外,完整的代碼覆蓋率也不能保證我們編寫了良好的測試,或者我們可能因此錯過了應該要測試的重要的行為, 所以別將滿分的代碼覆蓋率視為目標!
集成測試 Integration Tests
什麼是集成測試
在測試中調用多個函數、結合多個函數的結果做測試;或測試一個已經調用其他函數的函數。
如何測試有依賴關係的單元
消除外部依賴後再進行測試。
平衡單元測試與集成測試
你應該嘗試盡可能多的獨立功能,但你不應該過度拆分代碼建立不必要的獨立函數。
關於API測試
測試異步代碼
由於Jest與Vitest不會自動等待異步函數執行,因此若要測試異步代碼,就必須在
it('',(done)=>{})的匿名函數中,新增done這個參數,然後將它放在異步函數中的斷言之後,這樣他們才會主動等待。
it('',(done)=>{ const testUserEmail = 'test@test.com'); generateToken(testUserEmail,(err,token)=>{ expect(token).toBeDefined(); done(); }); });
但如果在這裡出錯了,我們該怎麼知道是斷言的條件出錯、還是異步代碼的執行錯誤呢?這種情況就很適合在我們的測試代碼中增加try catch,讓它在出錯時能順便告訴我們到底發生了什麼。
it('',(done)=>{ const testUserEmail = 'test@test.com'); generateToken(testUserEmail,(err,token)=>{ try{ expect(token).toBeDefined(); done(); }catch(err){ done(err); } }); });
或者,你也可以簡單地
it('', async()=>{}),然後在你的測試函數中使用await。如果有很多步驟要執行,而非只要調用一個函數的話,這樣做也不錯。
測試Promise
物件代表一個即將完成、或失敗的非同步操作,以及他所產生的值。一個Promise物件可能處於以下幾種狀態:pending擱置, fulfilled實現, rejected拒絕。Promise
expect()支持直接包裝、處理Promise:
expect(somePromise(testEmail)).resolves.toBeDefined()
- .rejects -> 想評估被拒絕之後的錯誤
- .resolves -> 期望承諾能夠被解決
- 當然也可以
.toBeTypeOf('string') - 如果你的測試不成功,可以在expect前面加一個return再試試看,這保證了Vitest 和Jest一定會等待promise被解決後再繼續。使用async/await的話則不需要這樣做。
其他測試功能介紹
設置&清理 Hooks
在測試class時,不難想像我們會不斷new建立物件、設置屬性並調用一些方法,然後用.toHaveProperty()檢查對象是否具有某種特定名稱的屬性。 若是不想重複設定一樣的內容,可以將部分代碼轉移至全域常量中,然而若我們在全域中new了一個對象,在後面的測試代碼中修改了這個全域對象(例如刪除某個屬性),那麼下一個需要此屬性的測試就有可能發生問題,因為這個全域對象已經被修改過了。為了處理這種情況,我們可以使用由Jest & Vitest提供的Hooks功能來註冊物件。它們和一般測試函數一樣,可以寫在describe內、也可以寫在全域範圍中。
這裡的Hooks只是runner在特定時間點會自動執行的函數,和React Hooks無關。
import
- beforeAll 在所有測試前執行
- 用來初始化一些常用的值
- 可以在全域中創建、beforeAll中賦值,這樣可以避免在全域中賦值可能帶來的一些問題
- beforeEach 在每次測試前執行
- afterEach 在每次測試後執行
- afterAll 在所有測試後執行
- 測試完成後想刪除一些測試數據
併發測試
如果你有很多測試要跑,希望它們加快一點速度的話,可以選擇在describe()和it()函數中加上
.concurrent:
it.concurrent('should be a bit faster',()=>{})describe.concurrent()
即使不添加
.concurrent屬性,儲存在不同文件中的測試也會同時執行,這是Jest和Vitest的默認做法,確保我們的測試在短時間內運行。
在單個文件中強制併發執行的一個缺點是,在做全域狀態操作時,測試之間可能會互相干擾。
試著查看官方文檔中還有哪些可以添加的標籤吧!
Spies & Mocks: 處理副作用
副作用與外部依賴
外部依賴是指我們的函數在代碼之外,與硬盤、數據庫或其他事物進行交互。 而副作用則是指,我們的函數與外部依賴進行交互時會改動數據,但我們在測試時不希望它們真的這麼做。 接下來,我要介紹兩種避免觸發副作用、不與外部依賴交互的方法。
Spies
間諜是功能的包裝器,我們可以包裝原始功能,並使用間諜對象將原始功能替換掉。它是個有跟蹤器的空函數。
import {vi} from 'vitest'
使用vi.fn()創建一個空函數,取代原來要被傳入其他函數中執行的函數,再用.toBeCalled() 或.toHaveBeenCalled()跟蹤它是否被調用。
it('should execute logFn if provided',()=>{ const logger = vi.fn(); // const logger = vi.fn(()=>{}) generateReportData(logger); except(logger).toBeCalled(); })
其他還有
是否被調用特定次數.toBeCalledTimes()
檢查傳遞了哪些參數.toBeCallWith()
大多數時候,我們只是想確定這個與外部依賴交互的函數是否有被調用,在這樣的前提下,Spies堪稱完美。
Mocks
Spies可以替換作為參數的函數,但若我們不打算執行的函數,是在待測函數中直接被調用的該怎麼辦? 我們可以使用
vi.mock()直接替換掉整個內置或第三方模組。
vi.mock('fs'); it();
如果我們不傳遞其他額外的配置到
vi.mock()中,fs模塊將直接被一堆空函數取代。
再來,我們可能想知道被替換掉的模組是否有正確被調用。在測試文件中導入要確認的模組後,就能直接在斷言中用
.toBeCalled() 或.toHaveBeenCalled()跟蹤目標被調用的情況。
import path from 'path'; import {promises as fs} from 'fs'; export default fumction writeData(data, filename){ const storagePath = path.join(process.cwd(), 'data', filename); return fs.writeFile(storagePath, data); }
import {promises as fs} from 'fs'; vi.mock('fs'); it('should execute the writeFile method',()=>{ const testData = 'Test'; const testFilename = 'test.txt'; writeData(testData, testFilename); expect(fs.writeFile).toBeCalled(); });
另外,我們也可以在mock中傳遞其他參數,來定義被模擬的模塊:
vi.mock('path',()=>{ return { default: { join: (...args)=>{ return args[args.length - 1]; // 取得文件名 } }}; }) it('should execute the writeFile method',()=>{ const testData = 'Test'; const testFilename = 'test.txt'; writeData(testData, testFilename); expect(fs.writeFile).toBeCalledWith(testData, testFilename); });
如果有很多測試檔都需要像這樣的初始設定,我們也可以另外設定模塊或函數的模擬。首先,創建一個名為
__mocks__的資料夾,然後直接創建對應模組名稱的js檔。
Jest & Vitest 會優先搜尋以此規則命名的資料夾進行設定。
__mocks__/fs.js
import { vi } from 'vitest'; export const promises = { writeFlie: vi.fn((path, data)=>{ return new Promise((resolve, reject)=>{ resolve(); }); }) }
有時候,部分
vi.fn()設定我們只想在某個it中使用,這時我們可以使用
對空函數不滿意,模擬實現其他規則vi.fn().mockImplementation(()=>{})
執行一次過後即恢復空函數vi.fn().mockImplementationOnce(()=>{})
其他注意事項
1. 所有的間諜或模擬只在測試文件中生效,完全不會影響到原代碼。 2. 用Jest的話,模擬要寫在導入語句之前才會正常生效;Vitest會自動識別。 3. Mock只對當前測試文檔生效。
更深度的模擬
模擬全域對象與函數
假設我現在要置換掉fetch這個全域函數,可以像下面這樣做:
import { it, vi, expect } from 'vitest'; import { sendDataRequest } from './http'; // 創建等等用得到的反應,簡化主要函數的代碼 const testResponseData = {testKey:'trstData'}; // 先創建一個間諜函數 const testFetch = vi.fn((url, options)=>{ return new Promise((resolve, reject)=>{ // 檢查傳入的數據是否正確轉換為Json格式 if( typeof options.body !== 'string'){ return reject('Not a string.'); } const testResponse = { ok:true, json() { return new Promise((resolve, reject)=>{ resolve(testResponseData); }) } }; resolve(testResponse); }); }); // 以stubGlobal設定fetch,然後用上面的間諜取代原函數 vi.stubGlobal('fetch', testFetch); it('should return any available response data',()=>{ const testData = {key:'test'}; return expect(sendDataRequest(testData)).resolves.toEequal(testResponseData); })
如果是用axios這個庫,則一樣使用
vi.mock('axios') __mocks__/axios.js進行設定。
模擬前端功能
真正的fetch不接受未經轉換成Json的資料,如果想讓測試也符合這個行為,可以這樣設計:
it('should convert the provided data to JSON before sendind the request',async ()=>{ const testData = { key: 'test' }; let errorMessage; try{ await sendDataRequest(testData); } catch (e) { errorMessage = error; } return expect(errorMessage).not.toBe('Not a string.'); })
或許,我們想模擬收到404或500的情況:
it('should throw an HttpError in case of non-ok responses',()=>{ // 我們想嘗試 ok:false,所以使用這個一次性的模擬函數 testFetch.mockImplementationOnce((url, options)=>{ return new Promise((resolve, reject)=>{ const testResponse = { ok: false, json() { return new Promise((resolve, reject)=>{ resolve(testResponseData); }) } }; resolve(testResponse); }); }); const testData = { key: 'test' }; return except(sendDataRequest(testData)).rejects.toBeInstanceOf(HttpError); })
測試DOM
- NodeJS (Default)
- NodeJS APIs & modules are available
- Can’t interact with browser & browser APIs
- JSDOM
- 模擬瀏覽器讓測試代碼運行
- Happy-DOM (Vitest Only)
- 模擬瀏覽器讓測試代碼運行
設定方式
{'test':"vitest --run --environment happy-dom"} // or {'test':"jest --run --environment jsdom"}
將Html加載到虛擬dom中,並在dom進行測試。
import fs from 'fs'; import path from 'path'; import { it } from 'vitest'; import { Window } from 'happy-dom'; const htmlDocPath = path.join(process.cwd(), 'index.html'); const htmlDocumentContent = fs.readFileSync(htmlDocpath).toString(); const window = new Window(); const document = window.document; vi.stubGlobal('document',document); // 在全域範圍內替換掉document beforeEach(()=>{ document.body.innerHTML = ''; document.write(htmlDocumentContent); }); it('should add an error paragraph to the id="errors" element,()=>{ showError('Test); const errorsEl = document.getElementById('errors'); const errorParagraph = errorsEl.firstElementChild; expect(errorParagraph).not.toBeNull(); }) it('should not contain an error paragraph initially'()=>{ const errorsEl = document.getElementById('errors'); const errorParagraph = errorsEl.firstElementChild; expect(errorParagraph).toBeNull(); }) it('should output the provided message in the error paragraph',()=>{ const testErrorMessage = 'Test'; show(testErrorMessage); const errorsEl = document.getElementById('errors'); const errorParagraph = errorsEl.firstElementChild; expect(errorParagraph.textContent).toBe(testErrorMessage); })
其他
如果我們構建的前端應用較為複雜,單純用JSDOM或HAPPY DOM可能比較不方便、需要自己編寫過多代碼時,可以考慮使用一個很棒的第三方測試庫 Testing Library | Testing Library。 我們可以將它結合Vitest或Jest一起使用,其中有許多專為DOM編寫的測試功能,使得元素選取更加容易;其中也有模擬用戶事件的方法,非常值得研究。
結語
現在,測試應該已經沒有那麼嚇人了! 我主要使用的是Vitest,因為它替我省了不少麻煩,但Jest也有它好用的地方,如果你喜歡它,非常建議你去研究它的官方文檔。當然,不管你使用的是什麼測試庫,深入研究文檔都能更加了解關於配置選項、利基特性或該庫所支持的API等重要資訊。 如果你和我一樣,主要使用React、Vue或Augular進行開發,那請一定要去看看上面推薦的第三方測試庫。