通過構建自己的JavaScript測試框架來了解JS測試

杜尼卜發表於2020-09-01

測試(單元或整合)是程式設計中非常重要的一部分。在當今的軟體開發中,單元/功能測試已成為軟體開發的組成部分。隨著Nodejs的出現,我們已經看到了許多超級JS測試框架的釋出:Jasmine,Jest等。

單元測試框架

這有時也稱為隔離測試,它是測試獨立的小段程式碼的實踐。如果你的測試使用某些外部資源(例如網路或資料庫),則不是單元測試。

單元測試框架試圖以人類可讀的格式描述測試,以便非技術人員可以理解所測試的內容。然而,即使你是技術人員,BDD格式的閱讀測試也會使你更容易理解所發生的事情。

例如,如果我們要測試此功能:

function helloWorld() {
  return 'Hello world!';
}

我們會像這樣寫一個jasmine測試規範:

describe('Hello world', () => { ①
  it('says hello', () => { ②
      expect(helloWorld())③.toEqual('Hello world!'); ④
  });
});

說明:

  • describe(string, function) 函式定義了我們所謂的測試套件,它是各個測試規範的集合。
  • it(string, function) 函式定義了一個單獨的測試規範,其中包含一個或多個測試期望。
  • ③ 預計(實際)表示式就是我們所說的一個期望。它與匹配器一起描述應用程式中預期的行為片段。
  • ④ matcher(預期)表示式就是我們所說的Matcher。如果傳入的期望值與傳遞給Expect函式的實際值不符,則將布林值與規範進行布林比較。

安裝和拆卸

有時候為了測試一個功能,我們需要進行一些設定,也許是建立一些測試物件。另外,完成測試後,我們可能需要執行一些清理活動,也許我們需要從硬碟驅動器中刪除一些檔案。

這些活動稱為“設定和拆卸”(用於清理),Jasmine有一些功能可用來簡化此工作:

  • beforeAll 這個函式在describe測試套件中的所有規範執行之前被呼叫一次。
  • afterAll 在測試套件中的所有規範完成後,該函式將被呼叫一次。
  • beforeEach 這個函式在每個測試規範之前被呼叫,it 函式已經執行。
  • afterEach 在執行每個測試規範之後呼叫此函式。

在Node中的使用

在Node專案中,我們在與 src 資料夾相同目錄的 test 資料夾中定義單元測試檔案:

node_prj
    src/
        one.js
        two.js
    test/
        one.spec.js
        two.spec.js
    package.json

該測試包含規格檔案,這些規格檔案是src資料夾中檔案的單元測試, package.jsonscript 部分進行了 test

{
  ...,
  "script": {
      "test": "jest" // or "jasmine"
    }
}

如果 npm run test 在命令列上執行,則jest測試框架將執行 test 資料夾中的所有規範檔案,並在命令列上顯示結果。

現在,我們知道了期望和構建的內容,我們繼續建立自己的測試框架。我們的這個框架將基於Node,也就是說,它將在Node上執行測試,稍後將新增對瀏覽器的支援。

我們的測試框架將包含一個CLI部分,該部分將從命令列執行。第二部分將是測試框架的原始碼,它將位於lib資料夾中,這是框架的核心。

首先,我們首先建立一個Node專案。

mkdir kwuo
cd kwuo
npm init -y

安裝chalk依賴項,我們將需要它來為測試結果上色:npm i chalk

建立一個lib資料夾,其中將存放我們的檔案。

mkdir lib

我們建立一個bin資料夾是因為我們的框架將用作Node CLI工具。

mkdir bin

首先建立CLI檔案。

在bin資料夾中建立kwuo檔案,並新增以下內容:

#!/usr/bin/env node

process.title = 'kwuo'
require('../lib/cli/cli')

我們將hashbang設定為指向 /usr/bin/env node,這樣就可以在不使用node命令的情況下執行該檔案。

我們將process的標題設定為“kwuo”,並要求檔案“lib/cli/cli”,這樣就會呼叫檔案cli.js,從而啟動整個測試過程。

現在,我們建立“lib/cli/cli.js”並填充它。

mkdir lib/cli
touch lib/cli/cli.js

該檔案將搜尋測試資料夾,在“test”資料夾中獲取所有測試檔案,然後執行測試檔案。

在實現“lib/cli/cli.js”之前,我們需要設定全域性變數。

測試檔案中使用了describe,beforeEach,beforeEach,afterAll,beforeAll函式:

describe('Hello world', () => { 
  it('says hello', () => { 
    expect(helloWorld()).toEqual('Hello world!');
  });
});

但是在測試檔案中都沒有定義。沒有ReferenceError的情況下檔案和函式如何執行?因為測試框架在執行測試檔案之前,會先實現這些函式,並將其設定為globals,所以測試檔案呼叫測試框架已經設定好的函式不會出錯。而且,這使測試框架能夠收集測試結果並顯示失敗或通過的結果。

讓我們在lib資料夾中建立一個 index.js 檔案:

touch lib/index.js

在這裡,我們將設定全域性變數並實現describeitexpectEachbeforeEachafterAllbeforeAll 函式。

// 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。

接下來,我們設定beforeEachs,afterEachs,afterAlls,beforeAlls的陣列。beforeEachs將儲存在它所附加的 it 函式開始時呼叫的函式;afterEachs將在它所附加的 it 函式的末尾呼叫;beforeEachs和afterEachs分別在 describe 函式的開始和結尾處呼叫。

我們設定了 Totaltests 來儲存執行的測試數量, passTests 儲存已通過的測試數, failedTests 儲存失敗的測試數。

stats 收集每個describe函式的stats,curDesc 指定當前執行的describe函式來幫助收集測試資料,currIt 保留當前正在執行的 it 函式,以幫助收集測試資料。

我們設定了beforeEach、afterEach、beforeAll和afterAll函式,它們將函式引數推入相應的陣列,afterAll推入afterAlls陣列,beforeEach推入beforeEachs陣列,等等。

接下來是expect函式,此函式進行測試:

expect(56).toBe(56) // 經過測試56預期會是56
expect(func()).toEqual("nnamdi") // 該函式將返回一個等於“nnamdi”的字串

expect 函式接受一個要測試的引數,並返回一個包含匹配器函式的物件。在這裡,它返回一個具有 toBetoEqual 函式的物件,它們具有期望引數,用於與expect函式提供的value引數匹配。toBe 使用 === 將value引數與期望引數匹配,toEqual 使用 == 測試期望值。如果測試通過或失敗,則這些函式將遞增 passedTestsfailedTests 變數,並且還將統計資訊記錄在currIt變數中。

我們目前只有兩個matcher函式,還有很多:

  • toThrow
  • toBeNull
  • toBeFalsy
  • etc

你可以搜尋它們並實現它們。

接下來,我們有 it 函式,desc 引數儲存測試的描述名稱,而 fn 儲存函式。它先對beforeEachs進行fun,設定統計,呼叫 fn 函式,再呼叫afterEachs。

describe 函式的作用和 it 一樣,但在開始和結束時呼叫 beforeAllsafterAlls

showTestsResults 函式通過 stats 陣列進行解析,並在終端上列印通過和失敗的測試。

我們實現了這裡的所有函式,並將它們都設定為全域性物件,這樣才使得測試檔案呼叫它們時不會出錯。

回到“lib/cli/cli.js”:

// lib/cli/cli.js
const path = require('path')
const fs = require('fs')
const { showTestsResults } = require('./../')

首先,它從“lib/index”匯入函式 showTestsResult,該函式將在終端顯示執行測試檔案的結果。另外,匯入此檔案將設定全域性變數。

讓我們繼續:

run 函式是這裡的主要函式,這裡呼叫它,可以引導整個過程。它搜尋 test 資料夾 searchTestFolder,然後在陣列getTestFiles 中獲取測試檔案,它迴圈遍歷測試檔案陣列並執行它們 runTestFiles

  • searchTestFolder:使用 fs#existSync 方法檢查專案中是否存在“test/”資料夾。
  • getTestFiles:此函式使用 fs#readdirSync 方法讀取“test”資料夾的內容並返回它們。
  • runTestFiles:它接受陣列中的檔案,使用 forEach 方法迴圈遍歷它們,並使用 require 方法執行每個檔案。

kwuo資料夾結構如下所示:

測試我們的框架

我們已經完成了我們的測試框架,讓我們通過一個真實的Node專案對其進行測試。

我們建立一個Node專案:

mkdir examples
mkdir examples/math
cd examples/math
npm init -y

建立一個src資料夾並新增add.js和sub.js

mkdir src
touch src/add.js src/sub.js

add.js和sub.js將包含以下內容:

// 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

我們建立一個測試資料夾和測試檔案:

mkdir test
touch test/add.spec.js test/sub.spec.js

規範檔案將分別測試add.js和sub.js中的add和sub函式

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

現在,我們將在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,結果將是這樣的:

看,它給我們展示了統計資料,通過測試的總數,以及帶有“失敗”或“通過”標記的測試套件列表。看到通過的測試期望“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"
    ...
  }
}

總結

我們建立了我們的測試框架,在這個過程中,我們學會了如何使用全域性來設定函式和屬性在執行時任何地方可見。

我們看到了如何在專案中使用 describeitexpect 和各種匹配函式來執行測試。下一次,你使用Jest或Jasmine,你會更有信心,因為現在你知道它們是如何工作的。


來源:https://blog.bitsrc.io
作者:Chidume Nnamdi
翻譯:公眾號《前端全棧開發者》

相關文章