通過(guò)構(gòu)建自己的JavaScript測(cè)試框架來(lái)了解JS測(cè)試
測(cè)試(單元或集成)是編程中非常重要的一部分。在當(dāng)今的軟件開發(fā)中,單元/功能測(cè)試已成為軟件開發(fā)的組成部分。隨著Nodejs的出現(xiàn),我們已經(jīng)看到了許多超級(jí)JS測(cè)試框架的發(fā)布:Jasmine,Jest等。
單元測(cè)試框架
這有時(shí)也稱為隔離測(cè)試,它是測(cè)試獨(dú)立的小段代碼的實(shí)踐。如果你的測(cè)試使用某些外部資源(例如網(wǎng)絡(luò)或數(shù)據(jù)庫(kù)),則不是單元測(cè)試。
單元測(cè)試框架試圖以人類可讀的格式描述測(cè)試,以便非技術(shù)人員可以理解所測(cè)試的內(nèi)容。然而,即使你是技術(shù)人員,BDD格式的閱讀測(cè)試也會(huì)使你更容易理解所發(fā)生的事情。
例如,如果我們要測(cè)試此功能:
- function helloWorld() {
- return 'Hello world!';
- }
我們會(huì)像這樣寫一個(gè)jasmine測(cè)試規(guī)范:
- describe('Hello world', () => { ①
- it('says hello', () => { ②
- expect(helloWorld())③.toEqual('Hello world!'); ④
- });
- });
說(shuō)明:
- describe(string, function)
- it(string, function)
安裝和拆卸
有時(shí)候?yàn)榱藴y(cè)試一個(gè)功能,我們需要進(jìn)行一些設(shè)置,也許是創(chuàng)建一些測(cè)試對(duì)象。另外,完成測(cè)試后,我們可能需要執(zhí)行一些清理活動(dòng),也許我們需要從硬盤驅(qū)動(dòng)器中刪除一些文件。
這些活動(dòng)稱為“設(shè)置和拆卸”(用于清理),Jasmine有一些功能可用來(lái)簡(jiǎn)化此工作:
beforeAll
這個(gè)函數(shù)在describe測(cè)試套件中的所有規(guī)范運(yùn)行之前被調(diào)用一次。afterAll
在測(cè)試套件中的所有規(guī)范完成后,該函數(shù)將被調(diào)用一次。beforeEach
這個(gè)函數(shù)在每個(gè)測(cè)試規(guī)范之前被調(diào)用,it
函數(shù)已經(jīng)運(yùn)行。afterEach
在運(yùn)行每個(gè)測(cè)試規(guī)范之后調(diào)用此函數(shù)。
在Node中的使用
在Node項(xiàng)目中,我們?cè)谂c src
文件夾相同目錄的 test
文件夾中定義單元測(cè)試文件:
- node_prj
- src/
- one.js
- two.js
- test/
- one.spec.js
- two.spec.js
- package.json
該測(cè)試包含規(guī)格文件,這些規(guī)格文件是src文件夾中文件的單元測(cè)試, package.json
在 script
部分進(jìn)行了 test
。
- {
- ...,
- "script": {
- "test": "jest" // or "jasmine"
- }
- }
如果 npm run test
在命令行上運(yùn)行,則jest測(cè)試框架將運(yùn)行 test
文件夾中的所有規(guī)范文件,并在命令行上顯示結(jié)果。
現(xiàn)在,我們知道了期望和構(gòu)建的內(nèi)容,我們繼續(xù)創(chuàng)建自己的測(cè)試框架。我們的這個(gè)框架將基于Node,也就是說(shuō),它將在Node上運(yùn)行測(cè)試,稍后將添加對(duì)瀏覽器的支持。
我們的測(cè)試框架將包含一個(gè)CLI部分,該部分將從命令行運(yùn)行。第二部分將是測(cè)試框架的源代碼,它將位于lib文件夾中,這是框架的核心。
首先,我們首先創(chuàng)建一個(gè)Node項(xiàng)目。
- mkdir kwuo
- cd kwuo
- npm init -y
安裝chalk依賴項(xiàng),我們將需要它來(lái)為測(cè)試結(jié)果上色: npm i chalk
。
創(chuàng)建一個(gè)lib文件夾,其中將存放我們的文件。
- mkdir lib
我們創(chuàng)建一個(gè)bin文件夾是因?yàn)槲覀兊目蚣軐⒂米鱊ode CLI工具。
- mkdir bin
首先創(chuàng)建CLI文件。
在bin文件夾中創(chuàng)建kwuo文件,并添加以下內(nèi)容:
- #!/usr/bin/env node
- process.title = 'kwuo'
- require('../lib/cli/cli')
我們將hashbang設(shè)置為指向 /usr/bin/env node,這樣就可以在不使用node命令的情況下運(yùn)行該文件。
我們將process的標(biāo)題設(shè)置為“kwuo”,并要求文件“lib/cli/cli”,這樣就會(huì)調(diào)用文件cli.js,從而啟動(dòng)整個(gè)測(cè)試過(guò)程。
現(xiàn)在,我們創(chuàng)建“lib/cli/cli.js”并填充它。
- mkdir lib/cli
- touch lib/cli/cli.js
該文件將搜索測(cè)試文件夾,在“test”文件夾中獲取所有測(cè)試文件,然后運(yùn)行測(cè)試文件。
在實(shí)現(xiàn)“lib/cli/cli.js”之前,我們需要設(shè)置全局變量。
測(cè)試文件中使用了describe,beforeEach,beforeEach,afterAll,beforeAll函數(shù):
- describe('Hello world', () => {
- it('says hello', () => {
- expect(helloWorld()).toEqual('Hello world!');
- });
- });
但是在測(cè)試文件中都沒有定義。沒有ReferenceError的情況下文件和函數(shù)如何運(yùn)行?因?yàn)闇y(cè)試框架在運(yùn)行測(cè)試文件之前,會(huì)先實(shí)現(xiàn)這些函數(shù),并將其設(shè)置為globals,所以測(cè)試文件調(diào)用測(cè)試框架已經(jīng)設(shè)置好的函數(shù)不會(huì)出錯(cuò)。而且,這使測(cè)試框架能夠收集測(cè)試結(jié)果并顯示失敗或通過(guò)的結(jié)果。
讓我們?cè)趌ib文件夾中創(chuàng)建一個(gè) index.js
文件:
- touch lib/index.js
在這里,我們將設(shè)置全局變量并實(shí)現(xiàn) describe
, it
, expectEach
, beforeEach
, afterAll
, beforeAll
函數(shù)。
- // lib/index.js
- const chalk = require('chalk')
- const log = console.log
- var beforeEachs = []
- var afterEachs = []
- var afterAlls = []
- var beforeAlls = []
- var Totaltests = 0
- var passedTests = 0
- var failedTests = 0
- var stats = []
- var currDesc = {
- it: []
- }
- var currIt = {}
- function beforeEach(fn) {
- beforeEachs.push(fn)
- }
- function afterEach(fn) {
- afterEachs.push(fn)
- }
- function beforeAll(fn) {
- beforeAlls.push(fn)
- }
- function afterAll(fn) {
- afterAlls.push(fn)
- }
- function expect(value) {
- return {
- // Match or Asserts that expected and actual objects are same.
- toBe: function(expected) {
- if (value === expected) {
- currIt.expects.push({ name: `expect ${value} toBe ${expected}`, status: true })
- passedTests++
- } else {
- currIt.expects.push({ name: `expect ${value} toBe ${expected}`, status: false })
- failedTests++
- }
- },
- // Match the expected and actual result of the test.
- toEqual: function(expected) {
- if (value == expected) {
- currIt.expects.push({ name: `expect ${value} toEqual ${expected}`, status: true })
- passedTests++
- } else {
- currIt.expects.push({ name: `expect ${value} toEqual ${expected}`, status: false })
- failedTests++
- }
- }
- }
- }
- function it(desc, fn) {
- Totaltests++
- if (beforeEachs) {
- for (var index = 0; index < beforeEachs.length; index++) {
- beforeEachs[index].apply(this)
- }
- }
- //var f = stats[stats.length - 1]
- currIt = {
- name: desc,
- expects: []
- }
- //f.push(desc)
- fn.apply(this)
- for (var index = 0; index < afterEachs.length; index++) {
- afterEachs[index].apply(this)
- }
- currDesc.it.push(currIt)
- }
- function describe(desc, fn) {
- currDesc = {
- it: []
- }
- for (var index = 0; index < beforeAlls.length; index++) {
- beforeAlls[index].apply(this)
- }
- currDesc.name = desc
- fn.apply(this)
- for (var index = 0; index < afterAlls.length; index++) {
- afterAlls[index].apply(this)
- }
- stats.push(currDesc)
- }
- exports.showTestsResults = function showTestsResults() {
- console.log(`Total Test: ${Totaltests}
- Test Suites: passed, total
- Tests: ${passedTests} passed, ${Totaltests} total
- `)
- const logTitle = failedTests > 0 ? chalk.bgRed : chalk.bgGreen
- log(logTitle('Test Suites'))
- for (var index = 0; index < stats.length; index++) {
- var e = stats[index];
- const descName = e.name
- const its = e.it
- log(descName)
- for (var i = 0; i < its.length; i++) {
- var _e = its[i];
- log(` ${_e.name}`)
- for (var ii = 0; ii < _e.expects.length; ii++) {
- const expect = _e.expects[ii]
- log(` ${expect.status === true ? chalk.green('√') : chalk.red('X') } ${expect.name}`)
- }
- }
- log()
- }
- }
- global.describe = describe
- global.it = it
- global.expect = expect
- global.afterEach = afterEach
- global.beforeEach = beforeEach
- global.beforeAll = beforeAll
- global.afterAll = afterAll
在開始的時(shí)候,我們需要使用chalk庫(kù),因?yàn)槲覀円盟鼇?lái)把失敗的測(cè)試寫成紅色,把通過(guò)的測(cè)試寫成綠色。我們將 console.log 縮短為 log。
接下來(lái),我們?cè)O(shè)置beforeEachs,afterEachs,afterAlls,beforeAlls的數(shù)組。beforeEachs將保存在它所附加的 it
函數(shù)開始時(shí)調(diào)用的函數(shù);afterEachs將在它所附加的 it
函數(shù)的末尾調(diào)用;beforeEachs和afterEachs分別在 describe
函數(shù)的開始和結(jié)尾處調(diào)用。
我們?cè)O(shè)置了 Totaltests
來(lái)保存運(yùn)行的測(cè)試數(shù)量, passTests
保存已通過(guò)的測(cè)試數(shù), failedTests
保存失敗的測(cè)試數(shù)。
stats
收集每個(gè)describe函數(shù)的stats, curDesc
指定當(dāng)前運(yùn)行的describe函數(shù)來(lái)幫助收集測(cè)試數(shù)據(jù), currIt
保留當(dāng)前正在執(zhí)行的 it
函數(shù),以幫助收集測(cè)試數(shù)據(jù)。
我們?cè)O(shè)置了beforeEach、afterEach、beforeAll和afterAll函數(shù),它們將函數(shù)參數(shù)推入相應(yīng)的數(shù)組,afterAll推入afterAlls數(shù)組,beforeEach推入beforeEachs數(shù)組,等等。
接下來(lái)是expect函數(shù),此函數(shù)進(jìn)行測(cè)試:
- expect(56).toBe(56) // 經(jīng)過(guò)測(cè)試56預(yù)期會(huì)是56
- expect(func()).toEqual("nnamdi") // 該函數(shù)將返回一個(gè)等于“nnamdi”的字符串
expect
函數(shù)接受一個(gè)要測(cè)試的參數(shù),并返回一個(gè)包含匹配器函數(shù)的對(duì)象。在這里,它返回一個(gè)具有 toBe
和 toEqual
函數(shù)的對(duì)象,它們具有期望參數(shù),用于與expect函數(shù)提供的value參數(shù)匹配。 toBe
使用 ===
將value參數(shù)與期望參數(shù)匹配, toEqual
使用 ==
測(cè)試期望值。如果測(cè)試通過(guò)或失敗,則這些函數(shù)將遞增 passedTests
和 failedTests
變量,并且還將統(tǒng)計(jì)信息記錄在currIt變量中。
我們目前只有兩個(gè)matcher函數(shù),還有很多:
- toThrow
- toBeNull
- toBeFalsy
- etc
你可以搜索它們并實(shí)現(xiàn)它們。
接下來(lái),我們有 it
函數(shù), desc
參數(shù)保存測(cè)試的描述名稱,而 fn
保存函數(shù)。它先對(duì)beforeEachs進(jìn)行fun,設(shè)置統(tǒng)計(jì),調(diào)用 fn
函數(shù),再調(diào)用afterEachs。
describe
函數(shù)的作用和 it
一樣,但在開始和結(jié)束時(shí)調(diào)用 beforeAlls
和 afterAlls
。
showTestsResults
函數(shù)通過(guò) stats
數(shù)組進(jìn)行解析,并在終端上打印通過(guò)和失敗的測(cè)試。
我們實(shí)現(xiàn)了這里的所有函數(shù),并將它們都設(shè)置為全局對(duì)象,這樣才使得測(cè)試文件調(diào)用它們時(shí)不會(huì)出錯(cuò)。
回到“lib/cli/cli.js”:
- // lib/cli/cli.js
- const path = require('path')
- const fs = require('fs')
- const { showTestsResults } = require('./../')
首先,它從“lib/index”導(dǎo)入函數(shù) showTestsResult
,該函數(shù)將在終端顯示運(yùn)行測(cè)試文件的結(jié)果。另外,導(dǎo)入此文件將設(shè)置全局變量。
讓我們繼續(xù):
run
函數(shù)是這里的主要函數(shù),這里調(diào)用它,可以引導(dǎo)整個(gè)過(guò)程。它搜索 test
文件夾 searchTestFolder
,然后在數(shù)組 getTestFiles
中獲取測(cè)試文件,它循環(huán)遍歷測(cè)試文件數(shù)組并運(yùn)行它們 runTestFiles
。
searchTestFolder
:使用fs#existSync
方法檢查項(xiàng)目中是否存在“test/”文件夾。getTestFiles
:此函數(shù)使用fs#readdirSync
方法讀取“test”文件夾的內(nèi)容并返回它們。runTestFiles
:它接受數(shù)組中的文件,使用forEach
方法循環(huán)遍歷它們,并使用require
方法運(yùn)行每個(gè)文件。
kwuo文件夾結(jié)構(gòu)如下所示:
測(cè)試我們的框架
我們已經(jīng)完成了我們的測(cè)試框架,讓我們通過(guò)一個(gè)真實(shí)的Node項(xiàng)目對(duì)其進(jìn)行測(cè)試。
我們創(chuàng)建一個(gè)Node項(xiàng)目:
- mkdir examples
- mkdir examples/math
- cd examples/math
- npm init -y
創(chuàng)建一個(gè)src文件夾并添加add.js和sub.js
- mkdir src
- touch src/add.js src/sub.js
add.js和sub.js將包含以下內(nèi)容:
- // src/add.js
- function add(a, b) {
- return a+b
- }
- module.exports = add
- // src/sub.js
- function sub(a, b) {
- return a-b
- }
- module.exports = sub
我們創(chuàng)建一個(gè)測(cè)試文件夾和測(cè)試文件:
- mkdir test
- touch test/add.spec.js test/sub.spec.js
規(guī)范文件將分別測(cè)試add.js和sub.js中的add和sub函數(shù)
- // test/sub.spec.js
- const sub = require('../src/sub')
- describe("Subtract numbers", () => {
- it("should subtract 1 from 2", () => {
- expect(sub(2, 1)).toEqual(1)
- })
- it("should subtract 2 from 3", () => {
- expect(sub(3, 2)).toEqual(1)
- })
- })
- // test/add.spec.js
- const add = require('../src/add')
- describe("Add numbers", () => {
- it("should add 1 to 2", () => {
- expect(add(1, 2)).toEqual(3)
- })
- it("should add 2 to 3", () => {
- expect(add(2, 3)).toEqual(5)
- })
- })
- describe('Concat Strings', () => {
- let expected;
- beforeEach(() => {
- expected = "Hello";
- });
- afterEach(() => {
- expected = "";
- });
- it('add Hello + World', () => {
- expect(add("Hello", "World"))
- .toEqual(expected);
- });
- });
現(xiàn)在,我們將在package.json的“script”部分中運(yùn)行“test”以運(yùn)行我們的測(cè)試框架:
- {
- "name": "math",
- "version": "1.0.0",
- "description": "",
- "main": "index.js",
- "scripts": {
- "test": "kwuo"
- },
- "keywords": [],
- "author": "Chidume Nnamdi <kurtwanger40@gmail.com>",
- "license": "ISC"
- }
我們?cè)诿钚猩线\(yùn)行 npm run test
,結(jié)果將是這樣的:
看,它給我們展示了統(tǒng)計(jì)數(shù)據(jù),通過(guò)測(cè)試的總數(shù),以及帶有“失敗”或“通過(guò)”標(biāo)記的測(cè)試套件列表。看到通過(guò)的測(cè)試期望“add Hello + World”,它將返回“HelloWorld”,但我們期望返回“Hello”。如果我們糾正它并重新運(yùn)行測(cè)試,所有測(cè)試都將通過(guò)。
- // test/add.spec.js
- ...
- describe('Concat Strings', () => {
- let expected;
- beforeEach(() => {
- expected = "Hello";
- });
- afterEach(() => {
- expected = "";
- });
- it('add Hello + World', () => {
- expect(add("Hello", ""))
- .toEqual(expected);
- });
- });
看,我們的測(cè)試框架像Jest和Jasmine一樣工作。它僅在Node上運(yùn)行,在下一篇文章中,我們將使其在瀏覽器上運(yùn)行。
代碼在Github上
Github倉(cāng)庫(kù)地址: philipszdavido/kwuoKwuo
你可以使用來(lái)自NPM的框架:
- cd IN_YOUR_NODE_PROJECT
- npm install kwuo -D
將package.json中的“test”更改為此:
- {
- ...
- "scripts": {
- "test": "kwuo"
- ...
- }
- }
總結(jié)
我們建立了我們的測(cè)試框架,在這個(gè)過(guò)程中,我們學(xué)會(huì)了如何使用全局來(lái)設(shè)置函數(shù)和屬性在運(yùn)行時(shí)任何地方可見。
我們看到了如何在項(xiàng)目中使用 describe
、 it
、 expect
和各種匹配函數(shù)來(lái)運(yùn)行測(cè)試。下一次,你使用Jest或Jasmine,你會(huì)更有信心,因?yàn)楝F(xiàn)在你知道它們是如何工作的。