17792550360
掃描二維碼
關(guān)注卓目鳥學(xué)苑公眾號
掃描二維碼
關(guān)注卓目鳥學(xué)苑公眾號
前言 在本文開始之前,先說一下筆者對于單元測試(或集成測試、e2e 測試)的感受?! ≡趪猓浖こ處焸儗τ谲浖|(zhì)量十分重視,大部分也都崇尚于使用 TDD 方式開發(fā),保證代碼質(zhì)量。而國內(nèi)往往不是十分重視自 ...
前言 在本文開始之前,先說一下筆者對于單元測試(或集成測試、e2e 測試)的感受。 在國外,軟件工程師們對于軟件質(zhì)量十分重視,大部分也都崇尚于使用 TDD 方式開發(fā),保證代碼質(zhì)量。而國內(nèi)往往不是十分重視自動化測試這方面。 究其根本來說,國內(nèi)確實(shí)存在不少原因?qū)е伦詣踊瘻y試不流行。這里就不贅述了。 但是其實(shí)在中大型軟件開發(fā)中,自動化測試其實(shí)十分重要。筆者認(rèn)為,在現(xiàn)代軟件開發(fā)中,完善的自動化測試和 Lint 工具、良好代碼設(shè)計,基本可以保證軟件長期保持穩(wěn)定的生命力。 因此,筆者相信,即使不是現(xiàn)在,在未來,自動化測試也是工程師必備的一項(xiàng)技能。 關(guān)于單元測試 先看維基百科:在計算機(jī)編程中,單元測試(英語:Unit Testing)又稱為模塊測試,是針對程序模塊(軟件設(shè)計的最小單位)來進(jìn)行正確性檢驗(yàn)的測試工作。程序單元是應(yīng)用的最小可測試部件。在過程化編程中,一個單元就是單個程序、函數(shù)、過程等;對于面向?qū)ο缶幊?,最小單元就是方法,包括基類(超類)、抽象類、或者派生類(子類)中的方法?/font> 在前端背景下,這個也可以很簡單理解為測試工具函數(shù)。通常來說,單元測試,關(guān)于驗(yàn)證我們應(yīng)用中每一個函數(shù)是否調(diào)用正確。如何判斷調(diào)用正確呢?考慮一般如下: 函數(shù)調(diào)用次數(shù)合理; 函數(shù)入?yún)⒎项A(yù)期; 函數(shù)出參,也即是返回值符合預(yù)期。 當(dāng)然函數(shù)本身可能又會調(diào)用其他函數(shù),或者也可以說函數(shù)會依賴其他模塊、第三方庫,同時函數(shù)也可能是同步或異步。所以當(dāng)被測試的函數(shù)是純函數(shù)時,就是測試函數(shù)本身的出入?yún)⑹欠穹项A(yù)期就行了,否則,我們需要做許多 mock 的工作,借此來排除不是我們目標(biāo)的測試代碼。 當(dāng)然我們?nèi)粘9ぷ髦?,如果要寫單元測試的話,一般都會使用業(yè)界成熟的測試庫,比如 jest、mocha、chai、ava、tape、QUnit 等等。其實(shí)大部分測試框架背后的原理基本類似。所以,讓我們通過實(shí)現(xiàn)一個最簡單的單元測試框架,來學(xué)習(xí)單元測試原理吧! 測試容器和斷言庫 測試框架基本可以拆分出兩個部分: 測試容器(Test Runner) 斷言庫(Assertion Library) 簡介 測試容器最基本的作用是,自動運(yùn)行所有測試,對測試結(jié)果進(jìn)行數(shù)據(jù)匯總等。我們常見的使用方式一般如下,編寫測試單元: // ./math.test.js const { sumAsync, subtractAsync } = require('./math'); test('sumAsync adds numbers asynchronously', async () => { const result = await sumAsync(3, 7); const expected = 10; expect(result).toBe(expected); }); test('subtractAsync subtracts numbers asynchronously', async () => { const result = await subtractAsync(7, 3); const expected = 4; expect(result).toBe(expected); }); 假設(shè)我們有 math 工具函數(shù)如下: // ./math.jsconst sum = (a, b) => a + b;const subtract = (a, b) => a - b;const sumAsync = (...args) => Promise.resolve(sum(...args));const subtractAsync = (...args) => Promise.resolve(subtract(...args)); module.exports = { sum, subtract, sumAsync, subtractAsync }; 我們用 jest 運(yùn)行測試,在終端反饋匯總后的測試結(jié)果: $ jest PASS ./math.test.js ? sumAsync adds numbers asynchronously (4ms) ? subtractAsync subtracts numbers asynchronously (1ms) Test Suites: 1 passed, 1 totalTests: 2 passed, 2 totalSnapshots: 0 totalTime: 1.145sRan all test suites. 斷言庫一般形式如下: expect(result).toBe(expected);expect(func).toHaveBeenCalled();expect(func).toHaveBeenCalledTimes(1);expect(func).toHaveBeenCalledWith(arg1, arg2 /* ...args */);// ... 斷言庫是不是看起來很語義化~ 測試容器實(shí)現(xiàn)示例 測試容器其實(shí)并不復(fù)雜,最簡單的實(shí)現(xiàn)不過如下: // ./test.jsasync function test(title, callback) { try { await callback(); console.log(`? ${title}`); } catch (error) { console.error(`? ${title}`); console.error(error); }} 需要留意的是,這里加上了 async/await 是為了等待測試用例中的異步邏輯。 斷言庫實(shí)現(xiàn)示例 斷言庫也沒有黑魔法,我們寫一個最簡單的 expect(x).toBe(y) 的語法如下: // ./expect.jsfunction expect(actual) { return { toBe(expected) { if (actual !== expected) { throw new Error(`${actual} is not equal to ${expected}`); } }, };} 遠(yuǎn)比想象中簡單,對不對~ 這里有個比較關(guān)鍵的地方是,斷言函數(shù)里如果斷言失敗時,我們的選擇是拋出一個錯誤,然后在測試容器中會 try/catch 捕獲,同時打印錯誤堆棧。(在簡單情況下,我們也可以使用 Node.js 自帶的 assert 庫進(jìn)行斷言)。 除此之外,還有很多更復(fù)雜的斷言語法,不過基本形式也就是這樣。當(dāng)然如何巧妙設(shè)計測試函數(shù)調(diào)用次數(shù)(toHaveBeenCalledTimes)、出入?yún)ⅲ╰oHaveBeenCalledWith)的斷言函數(shù),后文會提到。 自動注入 有些同學(xué)可能留意到了,在測試框架中,我們并不需要手動引入 test、expect 這些函數(shù),每個測試文件可以直接使用。這個其實(shí)也很簡單。參考代碼如下: // ./test-framework.js// 注入給全局對象,使得每個文件可以訪問global.test = require('./test');global.expect = require('./expect'); // 從命令行加載所有測試用例:process.argv.slice(2).forEach(file => { // 測試文件中 require(file);}); 然后在終端運(yùn)行: $ node test-framework.js ./math.test.js? sumAsync adds numbers asynchronously? subtractAsync subtracts numbers asynchronously 對不對! 就是這么簡單! 接下來我們只需要把這件事情做得更優(yōu)雅,比如: 把它封裝成 TestRunner 對象 把命令放在 ./bin 中 擴(kuò)展更多的斷言語法 使用 glob 匹配所有測試文件 支持配置(參考 jest.config.js) 測試匯總統(tǒng)計 支持優(yōu)雅的錯誤堆棧 甚至于你可以擴(kuò)展進(jìn)行支持 DOM 測試,因?yàn)?DOM 測試的核心邏輯也是使用 JSDOM 根據(jù) W3C 標(biāo)準(zhǔn)在內(nèi)存中模擬相似的 DOM 結(jié)構(gòu),從而支持?jǐn)嘌詼y試的。 函數(shù)測試 上文中我們基本搭建了一個最簡單的測試框架,文件結(jié)構(gòu)如下: .├── expect.js├── math.js├── math.test.js├── test-framework.js└── test.js 說起來,在某些場景下,其實(shí)我們需要能夠保證函數(shù)只被執(zhí)行一次,以及被調(diào)用時候的入?yún)⑹菧?zhǔn)確的。 因?yàn)楹瘮?shù)調(diào)用多次可能會引發(fā)內(nèi)存泄露,入?yún)㈠e誤則可能會導(dǎo)致應(yīng)用不可預(yù)期的行為。所以我們需要從斷言庫中更細(xì)粒度的去測試保障。 那么斷言庫是怎么做到的呢? 接下來我們將擴(kuò)展一下斷言庫,使其支持更豐富的函數(shù)測試。 入?yún)⑴c調(diào)用次數(shù)監(jiān)控的實(shí)現(xiàn)原理 假設(shè)我們擴(kuò)展支持這兩種斷言語法: expect(sum).toHaveBeenCalledTimes(1);expect(sum).toHaveBeenCalledWith(3, 7); 大家可以思考一下如何設(shè)計實(shí)現(xiàn)呢? 我們在測試框架中,集成下面這個函數(shù): // ./test-framework.jsglobal.jest = { fn: (impl = () => {}) => { const mockFn = (...args) => { mockFn.mock.calls.push(args); return impl(...args); }; mockFn.originImpl = impl; mockFn.mock = { calls: [] }; return mockFn; },}; 其中的 fn 函數(shù)是一個高階函數(shù),包裹傳入的待測試函數(shù) impl。掛載 mock 對象,在返回的 mockFn 中調(diào)用時,用以統(tǒng)計調(diào)用數(shù)據(jù)。 當(dāng)然對于編寫測試用例的調(diào)用方來說是無需感知的,只需要使用 jest.fn 進(jìn)行包裹即可: const sumMockFn = jest.fn(sum); 接下來只需要對返回的 sumMockFn 進(jìn)行測試即可,本質(zhì)上對 sumMockFn 的操作,都會透傳到 sum 中。 擴(kuò)展斷言函數(shù) 所以我們還差什么?... 嗯對了。還有斷言函數(shù): // ./expectconst { isEqual } = require('lodash'); module.exports = function expect(actual) { return { toBe(expected) { // ... }, toEqual(expected) { if (!isEqual(actual, expected)) { throw new Error(`${actual} is not equal to ${expected}`); } }, toHaveBeenCalledTimes(expected) { let actualCallTimes = 0; try { actualCallTimes = actual.mock.calls.length; expect(actualCallTimes).toEqual(expected); } catch (err) { throw new Error( `expect function: ${actual.originImpl.toString()} to call ${expected} times, but actually call ${actualCallTimes} times` ); } }, toHaveBeenCalledWith(...expectedArgs) { let actualCallArgs = []; try { actualCallArgs = actual.mock.calls; actualCallArgs.forEach(callArgs => { expect(callArgs).toEqual(expectedArgs); }); } catch (err) { throw new Error( `expect function: ${actual.originImpl.toString()} to be called with ${expectedArgs}, but actually it was called with ${actualCallArgs}` ); } }, };}; 別看代碼有點(diǎn)長,其實(shí)細(xì)看很簡單對不對。關(guān)鍵點(diǎn)就是對 jest.fn 包裹過后的函數(shù)掛載的對象 mock 長度、內(nèi)容進(jìn)行斷言。這里需要留意的是。我們捕獲了 expect(x).toEqual(y) 拋出的錯誤,拋出了一個對用戶更友好的錯誤。 終于,我們編寫測試用例如下: test('sum should have been called once', () => { const sumMockFn = jest.fn(sum); sumMockFn(3, 7); expect(sumMockFn).toHaveBeenCalledTimes(1);}); test('sum should have been called with `3` `7`', () => { const sumMockFn = jest.fn(sum); sumMockFn(3, 7); expect(sum).toHaveBeenCalledWith(3, 7);}); 成功運(yùn)行! $ node test-framework.js ./math.test.js? sum should have been called once? sum should have been called with `3` `7` 模塊 經(jīng)過我們的努力。我們已經(jīng)做了一個像模像樣的測試框架了。但是請等等!現(xiàn)實(shí)真的有這么簡單么? 突然需要測試一個新的函數(shù),這個函數(shù)好像有點(diǎn)不一樣... // ./user.jsconst { v4: uuidv4 } = require('uuid'); module.exports = { createUser({ name, age }) { return { id: uuidv4(), name, age, }; },}; 我們想要測試這個函數(shù)返回帶有一個 id 的用戶對象,同時也調(diào)用了 uuidv4。但是發(fā)現(xiàn)這個函數(shù)沒辦法編寫測試,因?yàn)槊看紊傻?id 不一樣,所以它每次返回對象都不一樣。沒辦法簡單的使用 expect(x).toEqual(y)。 但是我們不可能去測試 uuid 庫。因?yàn)闇y試它們是毫無意義的,也是不現(xiàn)實(shí)的。 那怎么辦呢?我們還是有辦法的。擴(kuò)展測試框架如下: // ./test-framework.jsglobal.jest = { fn: (impl = () => {}) => { // ... }, mock: (mockPath, mockExports = {}) => { const path = require.resolve(mockPath); require.cache[path] = { id: path, filename: path, loaded: true, exports: mockExports, }; },};// ... 我們發(fā)現(xiàn)上面的 mock 函數(shù)使用 require.resolve 獲取了模塊加載路徑,然后在 require.cache 準(zhǔn)備好構(gòu)造后的緩存導(dǎo)出對象。 編寫測試如下: // ./user.test.jsjest.mock('uuid', { v4: () => 'FAKE_ID',}); const { createUser } = require('./user'); test('create an user with id', () => { const userData = { name: 'Christina', age: 25, }; const expectUser = { ...userData, id: 'FAKE_ID', }; expect(createUser(userData)).toEqual(expectUser);}); 因?yàn)?require.cache 的關(guān)系,我們需要把 jest.mock 提到文件最前面調(diào)用。(jest 里同樣的操作不需要提前,那是因?yàn)闇y試框架在運(yùn)行測試用例時自動提前此類操作了)然后模擬導(dǎo)出的 v4 對象返回一個 FAKE_ID。 運(yùn)行測試如下: $ node test-framework.js ./user.test.js? create an user with id 完美解決~ 真實(shí)世界里的應(yīng)用函數(shù)往往不會是干凈可愛的純函數(shù),依賴大量第三方流行庫進(jìn)行開發(fā)是我們的日常,也是開源世界里一件幸福的事情。 如何排除第三方依賴庫進(jìn)行測試,基本原理也是如上。 讓它更優(yōu)雅 每次都要調(diào)用 node test-framework.js ./user.test.js 來運(yùn)行測試,看上去不是很好。我們讓這個測試框架變得更優(yōu)雅吧! 嗯,我們給這個測試框架起個名字,就叫 mjest 吧! 第一步,我們在項(xiàng)目新建 bin 目錄,將上文的測試框架的實(shí)現(xiàn)丟進(jìn) ./bin/mjest.js 中。 $ tree ./bin/./bin/└── mjest.js 第二步,在 mjest.js 文件頂部加入 Shebang。使用 node 作為默認(rèn)解釋器。 #!/usr/bin/env node // mjest code 第三步,在 package.json 中加入 bin 聲明: { "name": "mjest", "version": "1.0.0", "description": "mini jest implementation", "main": "index.js", "bin": { "mjest": "./bin/mjest.js" }} 第四步,在項(xiàng)目路徑終端下運(yùn)行 npm link。該命令會將項(xiàng)目的 bin 軟鏈接到系統(tǒng)中 bin 中: $ which mjest/Users/sulirc/.nvm/versions/node/v10.20.1/bin/mjest 第五步,使用熱乎乎剛出爐的 mjest 運(yùn)行測試用例吧: $ mjest ./user.test.js? create an user with id $ mjest ./math.test.js? sum should have been called once? sum should have been called with `3` `7`? sumAsync adds numbers asynchronously? subtractAsync subtracts numbers asynchronously 我們也可以用 glob 語法更優(yōu)雅的匹配文件: $ mjest *.test.js? sum should have been called once? sum should have been called with `3` `7`? create an user with id? sumAsync adds numbers asynchronously? subtractAsync subtracts numbers asynchronously 更多 到此為止,相信大家應(yīng)該對測試框架原理基本有一定了解了。在 jest 中,還有比如 beforeEach、beforeAll 等鉤子函數(shù),大家也可以想辦法自己實(shí)現(xiàn)。斷言庫里豐富的斷言函數(shù),也可以一個一個擊破。 不斷豐富特性,四舍五入,我們就實(shí)現(xiàn)了一個測試框架。 在單元測試的基礎(chǔ)上,其實(shí)集成測試框架原理也相差不遠(yuǎn)。因?yàn)榧蓽y試其實(shí)就是建立在單元測試上的。大家也可以進(jìn)行思考。 希望本文能夠幫助大家加深理解測試框架的原理,感謝閱讀~ |
分享本篇文章給更多人:
2020-05-27
2020-02-24
2020-05-27
2022-12-05
2020-05-27
請發(fā)表評論