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