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

Expect · 預計條件匹配

  • .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
物件代表一個即將完成、或失敗的非同步操作,以及他所產生的值。一個
Promise
物件可能處於以下幾種狀態:pending擱置, fulfilled實現, rejected拒絕。

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

  1. NodeJS (Default)
    • NodeJS APIs & modules are available
    • Can’t interact with browser & browser APIs
  2. JSDOM
    • 模擬瀏覽器讓測試代碼運行
  3. 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進行開發,那請一定要去看看上面推薦的第三方測試庫。