前端 · 單元測試 · 初窺

從軟件測試開始

首先看看百科的定義:

在規定的條件下對程序進行操作,以發現程序錯誤,衡量軟件質量,並對其是否能滿足設計要求進行評估的過程。

軟件測試是使用人工或自動的手段來運行或測定某個軟件系統的過程,其目的在於檢驗它是否滿足規定的需求或弄清預期結果與實際結果之間的差別。

關鍵詞:發現(錯誤)、衡量(質量)、評估(差別)

測試方法分類

按照不同的角度,可以劃分出多種分類方式,下面列舉了常見的幾種:

實際的測試過程或者測試工具會交叉融合多種分類方式,比如某個測試工具通過自動化的黑盒測試動態服務於系統單元。

測試的通用原理

實現一個測試工具,最基本也是最核心的要求

模擬輸入,運行測試,對比輸出。

前端領域中的測試

常見的測試方式

此處的測試方式並非術語

實際開發中,進行測試的梯次大概是:function => class * => component => module => system。

class 不一定存在,或者可能與 function 或 component 並列

常用的測試框架

從測試角度來看,單元測試以上級別的框架其實都是爲了實現更多更高級的功能而對單測框架進行的改造和擴展

單元測試

單元測試作爲測試框架的基石,有着舉足輕重的地位

對比目前比較流行的幾個框架,Jest、Mocha、Ava、Jasmine、Tape。

從歷史來看,Jest、Mocha 和 Ava 擁有很高的點贊量,從最近下載來看,Ava 已經落下神壇。當前的主流應該是 Jest 和 Mocha。(Jest 綁定到了 create-react-app 上面,下載量有水分)。

TcxqqK

可以根據需要按照上表的功能支持選擇合適的工具,但是目前的主流趨勢有兩種:

端到端測試

對 web 前端來說,主要的測試包括表單、動畫、頁面跳轉、dom 渲染、Ajax 等是否符合預期,從這方面講,已經包括了集成測試和單元測試。

同樣對比了當前的一些流行框架,同時也和 Mocha 的下載量對比了下。

組件測試

爲了用於 Web 端應用而設計出的測試框架,就是在單測和集成測試的基礎上增加了一些適用於瀏覽器、前端的功能。

比較出名的是 Testing Library 家族,提供了諸多流行框架的組件測試能力。但是很難統一起來組織,所以單獨爲各個框架提供了工具。

其他輔助工具

單元測試

單元測試是基礎、核心的測試方式,更加值得探究;

關鍵功能

上文說過,測試就是接收輸入 => 執行測試 => 對比輸出的過程,因此下面的關鍵功能除了斷言都非必須

斷言

測試中的必要環節,測試的核心就是斷言的過程,即判斷輸入經過程序處理是否能輸出期望的輸出。最簡單的斷言工具莫過於 node 自帶的 assert 功能,可以自行體驗。

斷言和異常已經錯誤的一個重要區別就是斷言是不可恢復的錯誤,因此一般被用在測試環節,而非實際場景,實際場景需要爲異常和錯誤進行各種補救來確保程序的魯棒性。

const chai = require('chai');
const assert = chai.assert;
const expect = chai.expect;
const should = chai.should();
const  str = 'hello chai'

assert.typeOf(str, 'string'); 
assert.typeOf(str, 'string''foo is a string'); 

expect(str).to.be.a('string');
expect(str).to.equal('hello chai');

str.should.be.a('string');
str.should.equal('hello chai');

以上是 chai 的三種不同的斷言寫法,斷言中的重要過程是匹配,上述程序中的 be、equal 都是匹配器的一種,用法即表面意思。

很容易發現,expect 語法和 should 語法其實沒有太大的區別,都是鏈式結構,而 assert 語法則迥然不同,前兩者屬於 BDD 風格,後者是 TDD 風格,關於它們的描述,暫且放在題外話部分。

模擬

Mock 是一個工程師不陌生的詞彙,在開發過程中需要用到 mock 的時候基本都是測試階段了,這是毋庸置疑的,但是 mock 並不是測試必要的部分。

一般來說,mock 對象一定不會是測試的對象,那樣是沒有意義的。現在前端開發過程中,由於前後端的並行關係,一般都會 mock 後端數據用於自測,這是很有代表的一個場景,充分地表達了 mock 的意義,即模擬需要測試對象的依賴對象,輔助對測試對象的測試,即模擬後端從而實現對前端的測試。

Mock 對象對測試對象有以下幾個積極意義:

Mock 可以按照級別分爲: 方法級別、類級別、接口級別、服務級別。

常用的 mock 一般是接口級別的,即通過 mock 來模擬一個接口的行爲,接收輸入,給出輸出,應用 mock 的過程,一般分爲打樁、注入和調樁,樁指的是樁函數,即依賴對象的模擬函數。打樁即生成模擬函數,注入即將模擬函數替換真實函數,調樁即請求調用模擬函數。其中注入環節尤爲重要,也存在多種實現方式,比如修改代碼、修改配置、修改請求地址等等,各有利弊。

覆蓋率

覆蓋率是測試過程中的另一個指標,其實和單元測試關係不大,但是很多測試工具都配套了覆蓋率的功能。測試直接的目的是爲了測試程序是否符合預期,從這個角度出發,覆蓋率不在測試範圍內,因爲他與符合預期沒有什麼關係。覆蓋率其實和狹義的測試有共同的目的,那就是提高代碼質量,一個完備的測試場景應當覆蓋每一條語句,否則就存在冗餘代碼,所以覆蓋率也被納入了測試體系,當然,這裏指的是代碼覆蓋率。百科給的定義說明了這個問題:覆蓋率是一種判斷測試嚴謹程度的方式,即測試的測試。

覆蓋率也有很多種,除了代碼覆蓋率之外,還有類覆蓋率,方法覆蓋率等,計算方式也比較簡單,直接將已經運行的語句、類、方法等和全部的語句、類、方法做比值即可。

快照

快照測試是是對比文件差異,類似於 GitTree,而非圖片

快照測試是單元測試的一個特例,用來檢測對 UI 組件的意外更改,所以是一種針對 UI 組件的測試方式,原理比較簡單,即對比測試發生前後的快照文件的差異來確定測試成功與否。但是測試的成功與否不能說明渲染邏輯的正確性,只表示代碼的改動是否導致渲染結果發生改變,並給出發生改變的位置信息。

簡單實現

如何實現一個單元測試框架?簡單來說,需要按照給定的輸入運行被測函數給出輸出,再和預期的輸出做比較得到測試結果,即模擬輸入,運行測試,對比輸出。

根據上面的三句話,我們已經可以寫出一個簡單的測試函數了:

module.exports.test0 = function (title, input, expected, fn) {
  if (fn(...input) === expected) {
    console.log(`✅ ${title} 通過測試!`);
  } else {
    console.log(`❎ ${title} 未通過測試!`);
  }
};

這個測試函數雖然簡單,但是可以運行被測函數,並進行斷言,已經具備了測試工具的兩個必要功能。但是明顯有很多不足,比如不能捕獲一些語言錯誤,並且只能對斷言相等的情況。

捕獲錯誤可以通過 try-catch 進行實現,但是這種機制只能在運行時捕獲錯誤,比如語法錯誤就不能捕獲,需要另外進行記錄。

module.exports.test1 = function (title, input, expected, fn) {
  try {
    if (fn(...input) === expected) {
      console.log(`✅ ${title} 通過測試!`);
    } else {
      console.log(`❎ ${title} 未通過測試!`);
    }
  } catch (e) {
    console.log(`${title} 出現錯誤,無法正常運行!`);
    console.error(e);
  }
};

本文一直在強調,斷言是測試的核心功能,並且有專業的斷言庫如 chai.js 等的存在。上文說到,斷言的核心功能是匹配器,它指導瞭如何進行斷言,匹配器多種多樣以滿足各種需要,比如基本類型的相等、引用類型的淺相等 / 深相等、是否包含指定元素等。

涉及到不同的匹配器,上文的函數就顯得捉襟見肘,因爲它指定了 === 作爲匹配器,基於 BDD 模式的匹配器一般是鏈式的,不容易通過參數傳入,因此匹配器相關的邏輯留給用戶自行定義。

module.exports.test2 = function (title, callback) {
  try {
    callback();
    console.log(`✅ ${title} 通過測試!`);
  } catch (error) {
    console.log(`❎ ${title} 未通過測試!`);
    console.error(error);
  }
};

匹配器和輸入以及預期輸出是強相關的,因此一併抽去,這樣一來需要用戶自己編寫測試函數,這也是目前測試框架的基本用法。

相信讀者很快會發現一個致命的問題,那就是這個測試函數只能用來測試 callback 函數是否正常運行,顯然 callback 的正常運行和測試通過不能劃等號,原因很明顯,缺少了斷言過程,如果用戶自己不實現,那就喪失了測試工具的能力,但是顯然這不該由用戶實現,因此需要實現一個或者一些通用的斷言函數,簡單的實現如下:

module.exports.expect = function (result) {
  return {
    toBe(expected) {
      if (result !== expected) {
        throw new Error(`${result} 不等於 ${expected}`);
      }
    },
    toBeType(expected) {
      if (typeof result !== expected) {
        throw new Error(`${result}的類型不等於 ${expected}`);
      }
    },
  };
};

通過定義各種匹配器可以實現諸多斷言功能,只要在 callback 函數中正確使用即能達到測試效果。

至此,一個極簡的測試工具已經實現,當然和流行工具相比,還存在很多問題和諸多需要改善的點,比如:

  1. 被測函數一般被視爲黑盒,測試函數單獨作爲文件引入函數,因此一些文件操作必不可少。

  2. 測試工具一般需要有終端運行的能力,並可以解析參數,定義輸出流等。

  3. 測試容器(test 函數)的運行需要考慮到安全問題,超時處理等等,一般是在是在 vm 虛擬機中局部進行的,相關的資源分配和管理是一個不容忽視的問題。

  4. 生成一份優雅的測試報告,尤其是可以很好的記錄錯誤信息,錯誤信息的收集處理、測試函數運行時間記錄等。

  5. 在單測過程中加入一些鉤子函數,在特定的生命週期執行,比如記錄測試時間之類。

  6. 更多的擴展功能,包括代碼覆蓋率、mock、快照等。

以上的很多功能都有較好的工具庫支撐,下面實現了部分擴展:

理想的測試工具可以通過 API 和終端兩種方式運行:

const fs = require('fs');
const path = require('path');

function run(filePath) {
  const actualPath = filePath.startsWith('/') ? `${filePath}` : path.join(process.cwd(), filePath);
  if (!fs.existsSync(actualPath)) {
    console.error(`${filePath} 不存在!`);
    return;
  }
  require(actualPath);
}

function runCli() {
  let filePath = process.argv.slice(2)[0];
  if (!filePath) {
    filePath = 'test/test.js';
  }
  run(filePath);
}
module.exports = {
  run,
  runCli,
};

如果需要通過命令行進行其他配置,一些第三方庫可能會更加高效,比如 yargs 等。

進一步,通過 v8 的 vm 進行環境隔離來執行測試用例,將 test 和 expect 注入,這樣測試文件中可以不引入它們。

const vm = require('node:vm');
const { expect } = require('../src/expect');
const { test2 } = require('../src/test');

module.exports.runInVm = function (code) {
  const context = {
    test: test2,
    expect,
  };
  vm.createContext(context);
  vm.runInContext(code, context);
};

run 函數和測試用例同步修改:

function run(filePath) {
  const actualPath = filePath.startsWith('/') ? `${filePath}` : path.join(process.cwd(), filePath);
  if (!fs.existsSync(actualPath)) {
    console.error(`${filePath} 不存在!`);
    return;
  }
  const code = fs.readFileSync(actualPath);
  runInVm(code);
}
function sum(a, b) {
  return a + b;
}
test('1+2=3'() ={
  expect(sum(1, 2)).toBe(3);
});
test('typeof 1 === number'() ={
  expect('10').toBeType('number');
});

接下來就是生成一份比較優雅的測試結果,可查看多文件所有測試用例的集成結果和細節:

module.exports.Result = class Result {
  numTotalTestFiles = 0;
  numPassTestFiles = 0;
  numFailTestFiles = 0;
  numTotalTestCases = 0;
  numPassTestCases = 0;
  numFailTestCases = 0;
  startTime = 0;
  endTime = 0;
  testFilesResult = [];
};
function run(filePath) {
  const actualPath = filePath.startsWith('/') ? `${filePath}` : path.join(process.cwd(), filePath);
  if (!fs.existsSync(actualPath)) {
    console.error(`${filePath} 不存在!`);
    return;
  }

  const result = new Result();
  result.startTime = Date.now();

  if (fs.lstatSync(actualPath).isDirectory()) {
    const files = fs.readdirSync(actualPath).filter(name => name.includes('test'));
    result.numTotalTestFiles = files.length;
    files.forEach(f ={
      const code = fs.readFileSync(`${actualPath}/${f}`);
      runInVm(code, result);
    });
  } else {
    const code = fs.readFileSync(actualPath);

    result.numTotalTestFiles = 1;
    runInVm(code, result);
  }
  result.numTotalTestCases = result.numPassTestCases + result.numFailTestCases;
  result.endTime = Date.now();
  result.filePath = actualPath;
  return result;
}

run 函數中加入了集成結果統計,但是具體 case 的情況無法直接獲取,因此引入發佈訂閱來解決:

module.exports.Emitter = class EventEmitter {
  static Events = {};
  static on(name, event) {
    if (typeof event !== 'function') {
      throw new TypeError('event is not function');
    }
    this.Events[name] = event;
  }
  static emit(eventName, ...args) {
    this.Events[eventName](...args);
  }
};

因爲目前是按照文件運行,每次運行都會註冊一次,直接替換賦值,執行過程中觸發收集:

module.exports.runInVm = function (code, result) {
  const context = {
    test: test2,
    expect,
  };
  const fileResult = {
    startTime: Date.now(),
    numPassCases: 0,
    numFailCases: 0,
    details: [],
  };
  Emitter.on('updateResult'res ={
    if (res.status === 'pass') {
      fileResult.numPassCases++;
      result.numPassTestCases++;
    } else {
      fileResult.numFailCases++;
      result.numFailTestCases++;
    }
    fileResult.details.push(res);
  });
  vm.createContext(context);
  vm.runInContext(code, context);
  fileResult.endTime = Date.now();
  fileResult.numTotalCases = fileResult.numPassCases + fileResult.numFailCases;
  fileResult.status = fileResult.numFailCases === 0 ? 'pass' : 'fail';
  if (fileResult.status === 'fail') {
    result.numFailTestFiles++;
  } else {
    result.numPassTestFiles++;
  }
  result.testFilesResult.push(fileResult);
};

在 runInVm 內加入文件的 case 結果彙總,但是要先註冊 case 內容部的結果收集函數,最終可以得到完整的測試結果:

結果包括測試函數運行過程中的輸出和最終的結果彙總,觀察細緻的讀者可以看到上圖中已經存在設置配置,這是本次擴展實現的最後一部分了,就是加入運行配置,目前僅加入了結果顯示和保存:

function runCli() {
  const args = process.argv.slice(2);
  const filePath = args[0] ? args[0] : 'test/test.js';
  const result = run(filePath);
  if (args.includes('--showJson')) {
    console.log(JSON.stringify(result, null, 2));
  } else if (args.includes('--showResult')) {
    console.log(result);
  }
  if (args.includes('--saveJson')) {
    const _path = args[args.indexOf('--saveJson') + 1];
    if (!_path) {
      if (fs.lstatSync(result.filePath).isDirectory()) {
        fs.writeFileSync(`${result.filePath}/result.json`, JSON.stringify(result, null, 2));
      } else {
        const arr = result.filePath.split('/');
        arr.length--;
        fs.writeFileSync(`${arr.join('/')}/result.json`, JSON.stringify(result, null, 2));
      }
      return;
    }
    const savePath = _path.startsWith('/') ? `${_path}` : path.join(process.cwd(), _path);
    if (!fs.existsSync(savePath)) {
      if (savePath.endsWith('.json')) {
        fs.writeFileSync(savePath, JSON.stringify(result, null, 2));
      } else {
        fs.writeFileSync(`${savePath}.json`, JSON.stringify(result, null, 2));
      }
      return;
    }
    if (fs.lstatSync(savePath).isDirectory()) {
      fs.writeFileSync(`${savePath}/result.json`, JSON.stringify(result, null, 2));
    } else {
      fs.writeFileSync(savePath, JSON.stringify(result, null, 2));
    }
  }
}

module.exports.Config = class Config {
  showResult;
  showJson;
  saveJson;
  savePath;
  constructor(options) {
    this.showJson = options.showJson || false;
    this.showResult = options.showResult || false;
    this.saveJson = options.saveJson || false;
    this.savePath = options.savePath;
  }
};

最終的項目結構如下:

總結一下,這個小的測試工具實現了測試關鍵的隔離運行、斷言、結果收集彙總、命令行解析運行功能,但是沒有實現額外的包括模擬、覆蓋率、快照等擴展,並且只是一個大概的運行邏輯,很多細節經不起推敲,比如對於 callback 中出現語法錯誤目前還沒有實現等等,總之,革命尚未成功,同志還需努力!希望與大家共勉之!

題外話

Test-Runner

插播一句目前在使用 Jest 的痛點:(其實也不能算痛點,主要是自己的使用方式比較反測試)

爲了解決上述的問題,之前寫過一個簡單 test-runner,可以直接在 node 項目中引入,調用執行,有興趣的話,可以通過 npm install jestprogramrunner 耍一下;

declare namespace JPR {
    /**
     *
     * @param filePath 接收單個測試文件或者一組測試文件,可使用正則模式
     * @param config 可參照Jest配置
     * @param thread 是否啓動線程執行,默認爲false
     * @returns 返回一個對象,包括解析後的結果result和原始結果rawResult
     */
    declare function run(filePath:string | string[], thread?:boolean, config?:Config.Argv):Promise<{
        result:Result,
        rawResult:AggregatedResult
    }>;
    declare function parseResult(data:AggregatedResult):Result
}

export default JPR;

TDD 和 BDD

這裏接上文的 TDD 和 BDD,它們都屬於開發模式,首先做一下名詞解釋:

本質上 BDD 是 TDD 的一種補充,將測試行爲更加細化,舉例說明,對點擊按鈕的行爲來說:TDD 模式就是點擊按鈕,會觸發什麼事件;而 BDD 模式則是點擊按鈕要展現何種效果。

官方解釋:測試驅動開發(TDD)是敏捷開發中的一項核心實踐和技術,也是一種設計方法論。TDD 的原理是在開發功能代碼之前,先編寫單元測試用例代碼,測試代碼確定需要編寫什麼產品代碼。TDD 的基本思路就是通過測試來推動整個開發的進行,但測試驅動開發並不只是單純的測試工作,而是把需求分析,設計,質量控制量化的過程。TDD 首先考慮使用需求(對象、功能、過程、接口等),主要是編寫測試用例對功能的過程和接口進行設計,而測試框架可以持續進行驗證。

從上述解釋中可以提煉出 TDD 開發的一般流程:

TDD 能帶來的好處有哪些呢?與傳統的開發過程進行對比一下會就會比較明顯:

通過對比,優勢自然不用說,缺點也很明顯,就是需要寫很多測試用例,TDD 力求測試用例覆蓋完全,實際的開發中,可以按照項目大小、排期時間、需求清晰明確與否等選擇合適的方式。

參考

[1] ava vs jasmine vs jest vs mocha vs tape | npm trends: https://www.npmtrends.com/ava-vs-jasmine-vs-jest-vs-mocha-vs-tape

[2] Testing Library: https://testing-library.com/docs/

[3] assert: https://nodejs.org/dist/latest-v18.x/docs/api/assert.html

[4] Jest, part 1: 什麼是快照(snapshot): https://blog.axiu.me/jest-what-is-snapshot/

[5] 前端測試框架調研丨【WEB 前端大作戰】- 雲社區 - 華爲雲: https://bbs.huaweicloud.com/blogs/257894

[6] JS 測試框架 Jest/Mocha/Ava 的簡單比較 - 掘金: https://juejin.cn/post/6844904009887645709

[7] 代碼覆蓋率工具 Istanbul 入門教程 - 阮一峯的網絡日誌: https://www.ruanyifeng.com/blog/2015/06/istanbul.html

[8] github.com: https://github.com/Wscats/jest-tutorial

[9] trycatch 不能捕獲運行時異常_面試官: 用一句話描述 JS 異常是否能被 try catch 捕獲到 ?... - 掘金: https://juejin.cn/post/7021889887615844360

[10] Node.js 中如何收集和解析命令行參數_傲嬌的 koala 的博客 - CSDN 博客: https://blog.csdn.net/xgangzai/article/details/113577572

[11] 深度解讀 - TDD(測試驅動開發): https://www.jianshu.com/p/62f16cd4fef3

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/REKt5JnbfJIQY0W9PhCPOA