聊一聊前端自動化測試

本文轉載自 天貓前端博客,更多精彩文章請進入天貓前端博客查看css

前言

爲什麼要測試

之前不喜歡寫測試,主要是以爲編寫和維護測試用例很是的浪費時間。在真正寫了一段時間的基礎組件和基礎工具後,才發現自動化測試有不少好處。測試最重要的天然是提高代碼質量。代碼有測試用例,雖不能說百分百無bug,但至少說明測試用例覆蓋到的場景是沒有問題的。有測試用例,發佈前跑一下,能夠杜絕各類疏忽而引發的功能bug。html

自動化測試另一個重要特色就是快速反饋,反饋越迅速意味着開發效率越高。拿UI組件爲例,開發過程都是打開瀏覽器刷新頁面點點點才能肯定UI組件工做狀況是否符合本身預期。接入自動化測試之後,經過腳本代替這些手動點擊,接入代碼watch後每次保存文件都能快速得知本身的的改動是否影響功能,節省了不少時間,畢竟機器幹事情比人老是要快得多。前端

有了自動化測試,開發者會更加信任本身的代碼。開發者不再會害怕將代碼交給別人維護,不用擔憂別的開發者在代碼裏搞「破壞」。後人接手一段有測試用例的代碼,修改起來也會更加從容。測試用例裏很是清楚的闡釋了開發者和使用者對於這端代碼的指望和要求,也很是有利於代碼的傳承。node

考慮投入產出比來作測試

說了這麼多測試的好處,並不表明一上來就要寫出100%場景覆蓋的測試用例。我的一直堅持一個觀點:基於投入產出比來作測試。因爲維護測試用例也是一大筆開銷(畢竟沒有多少測試會專門幫前端寫業務測試用例,而前端使用的流程自動化工具更是沒有測試參與了)。對於像基礎組件、基礎模型之類的不常變動且複用較多的部分,能夠考慮去寫測試用例來保證質量。我的比較傾向於先寫少許的測試用例覆蓋到80%+的場景,保證覆蓋主要使用流程。一些極端場景出現的bug能夠在迭代中造成測試用例沉澱,場景覆蓋也將逐漸趨近100%。但對於迭代較快的業務邏輯以及生存時間不長的活動頁面之類的就別花時間寫測試用例了,維護測試用例的時間大了去了,成本過高。react

Node.js模塊的測試

對於Node.js的模塊,測試算是比較方便的,畢竟源碼和依賴都在本地,看得見摸得着。webpack

測試工具

測試主要使用到的工具是測試框架、斷言庫以及代碼覆蓋率工具:git

  1. 測試框架:MochaJasmine等等,測試主要提供了清晰簡明的語法來描述測試用例,以及對測試用例分組,測試框架會抓取到代碼拋出的AssertionError,並增長一大堆附加信息,好比那個用例掛了,爲何掛等等。測試框架一般提供TDD(測試驅動開發)或BDD(行爲驅動開發)的測試語法來編寫測試用例,關於TDD和BDD的對比能夠看一篇比較知名的文章The Difference Between TDD and BDD。不一樣的測試框架支持不一樣的測試語法,好比Mocha既支持TDD也支持BDD,而Jasmine只支持BDD。這裏後續以Mocha的BDD語法爲例github

  2. 斷言庫:Should.jschaiexpect.js等等,斷言庫提供了不少語義化的方法來對值作各類各樣的判斷。固然也能夠不用斷言庫,Node.js中也能夠直接使用原生assert庫。這裏後續以Should.js爲例web

  3. 代碼覆蓋率:istanbul等等爲代碼在語法級分支上打點,運行了打點後的代碼,根據運行結束後收集到的信息和打點時的信息來統計出當前測試用例的對源碼的覆蓋狀況。chrome

一個煎蛋的栗子

以以下的Node.js項目結構爲例

.
├── LICENSE
├── README.md
├── index.js
├── node_modules
├── package.json
└── test
    └── test.js

首先天然是安裝工具,這裏先裝測試框架和斷言庫:npm install --save-dev mocha should。裝完後就能夠開始測試之旅了。

好比當前有一段js代碼,放在index.js

'use strict';
module.exports = () => 'Hello Tmall';

那麼對於這麼一個函數,首先須要定一個測試用例,這裏很明顯,運行函數,獲得字符串Hello Tmall就算測試經過。那麼就能夠按照Mocha的寫法來寫一個測試用例,所以新建一個測試代碼在test/index.js

'use strict';
require('should');
const mylib = require('../index');

describe('My First Test', () => {
  it('should get "Hello Tmall"', () => {
    mylib().should.be.eql('Hello Tmall');
  });
});

測試用例寫完了,那麼怎麼知道測試結果呢?

因爲咱們以前已經安裝了Mocha,能夠在node_modules裏面找到它,Mocha提供了命令行工具_mocha,能夠直接在./node_modules/.bin/_mocha找到它,運行它就能夠執行測試了:

Hello Tmall

這樣就能夠看到測試結果了。一樣咱們能夠故意讓測試不經過,修改test.js代碼爲:

'use strict';
require('should');
const mylib = require('../index');

describe('My First Test', () => {
  it('should get "Hello Taobao"', () => {
    mylib().should.be.eql('Hello Taobao');
  });
});

就能夠看到下圖了:

Taobao is different with Tmall

Mocha實際上支持不少參數來提供不少靈活的控制,好比使用./node_modules/.bin/_mocha --require should,Mocha在啓動測試時就會本身去加載Should.js,這樣test/test.js裏就不須要手動require('should');了。更多參數配置能夠查閱Mocha官方文檔

那麼這些測試代碼分別是啥意思呢?

這裏首先引入了斷言庫Should.js,而後引入了本身的代碼,這裏it()函數定義了一個測試用例,經過Should.js提供的api,能夠很是語義化的描述測試用例。那麼describe又是幹什麼的呢?

describe乾的事情就是給測試用例分組。爲了儘量多的覆蓋各類狀況,測試用例每每會有不少。這時候經過分組就能夠比較方便的管理(這裏提一句,describe是能夠嵌套的,也就是說外層分組了以後,內部還能夠分子組)。另外還有一個很是重要的特性,就是每一個分組均可以進行預處理(beforebeforeEach)和後處理(after, afterEach)。

若是把index.js源碼改成:

'use strict';
module.exports = bu => `Hello ${bu}`;

爲了測試不一樣的bu,測試用例也對應的改成:

'use strict';
require('should');
const mylib = require('../index');
let bu = 'none';

describe('My First Test', () => {
  describe('Welcome to Tmall', () => {
    before(() => bu = 'Tmall');
    after(() => bu = 'none');
    it('should get "Hello Tmall"', () => {
      mylib(bu).should.be.eql('Hello Tmall');
    });
  });
  describe('Welcome to Taobao', () => {
    before(() => bu = 'Taobao');
    after(() => bu = 'none');
    it('should get "Hello Taobao"', () => {
      mylib(bu).should.be.eql('Hello Taobao');
    });
  });
});

一樣運行一下./node_modules/.bin/_mocha就能夠看到以下圖:

all bu welcomes you

這裏before會在每一個分組的全部測試用例運行前,相對的after則會在全部測試用例運行後執行,若是要以測試用例爲粒度,可使用beforeEachafterEach,這兩個鉤子則會分別在該分組每一個測試用例運行前和運行後執行。因爲不少代碼都須要模擬環境,能夠再這些beforebeforeEach作這些準備工做,而後在afterafterEach裏作回收操做。

異步代碼的測試

回調

這裏很顯然代碼都是同步的,但不少狀況下咱們的代碼都是異步執行的,那麼異步的代碼要怎麼測試呢?

好比這裏index.js的代碼變成了一段異步代碼:

'use strict';
module.exports = (bu, callback) => process.nextTick(() => callback(`Hello ${bu}`));

因爲源代碼變成異步,因此測試用例就得作改造:

'use strict';
require('should');
const mylib = require('../index');

describe('My First Test', () => {
  it('Welcome to Tmall', done => {
    mylib('Tmall', rst => {
      rst.should.be.eql('Hello Tmall');
      done();
    });
  });
});

這裏傳入it的第二個參數的函數新增了一個done參數,當有這個參數時,這個測試用例會被認爲是異步測試,只有在done()執行時,才認爲測試結束。那若是done()一直沒有執行呢?Mocha會觸發本身的超時機制,超過必定時間(默認是2s,時長能夠經過--timeout參數設置)就會自動終止測試,並以測試失敗處理。

固然,beforebeforeEachafterafterEach這些鉤子,一樣支持異步,使用方式和it同樣,在傳入的函數第一個參數加上done,而後在執行完成後執行便可。

Promise

日常咱們直接寫回調會感受本身很low,也容易出現回調金字塔,咱們可使用Promise來作異步控制,那麼對於Promise控制下的異步代碼,咱們要怎麼測試呢?

首先把源碼作點改造,返回一個Promise對象:

'use strict';
module.exports = bu => new Promise(resolve => resolve(`Hello ${bu}`));

固然,若是是co黨也能夠直接使用co包裹:

'use strict';
const co = require('co');
module.exports = co.wrap(function* (bu) {
  return `Hello ${bu}`;
});

對應的修改測試用例以下:

'use strict';
require('should');
const mylib = require('../index');

describe('My First Test', () => {
  it('Welcome to Tmall', () => {
    return mylib('Tmall').should.be.fulfilledWith('Hello Tmall');
  });
});

Should.js在8.x.x版本自帶了Promise支持,能夠直接使用fullfilled()rejected()fullfilledWith()rejectedWith()等等一系列API測試Promise對象。

注意:使用should測試Promise對象時,請必定要return,必定要return,必定要return,不然斷言將無效

異步運行測試

有時候,咱們可能並不僅是某個測試用例須要異步,而是整個測試過程都須要異步執行。好比測試Gulp插件的一個方案就是,首先運行Gulp任務,完成後測試生成的文件是否和預期的一致。那麼如何異步執行整個測試過程呢?

其實Mocha提供了異步啓動測試,只須要在啓動Mocha的命令後加上--delay參數,Mocha就會以異步方式啓動。這種狀況下咱們須要告訴Mocha何時開始跑測試用例,只須要執行run()方法便可。把剛纔的test/test.js修改爲下面這樣:

'use strict';
require('should');
const mylib = require('../index');

setTimeout(() => {
  describe('My First Test', () => {
    it('Welcome to Tmall', () => {
      return mylib('Tmall').should.be.fulfilledWith('Hello Tmall');
    });
  });
  run();
}, 1000);

直接執行./node_modules/.bin/_mocha就會發生下面這樣的杯具:

no cases

那麼加上--delay試試:

oh my green

熟悉的綠色又回來了!

代碼覆蓋率

單元測試玩得差很少了,能夠開始試試代碼覆蓋率了。首先須要安裝代碼覆蓋率工具istanbul:npm install --save-dev istanbul,istanbul一樣有命令行工具,在./node_modules/.bin/istanbul能夠尋覓到它的身影。Node.js端作代碼覆蓋率測試很簡單,只須要用istanbul啓動Mocha便可,好比上面那個測試用例,運行./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --delay,能夠看到下圖:

my first coverage

這就是代碼覆蓋率結果了,由於index.js中的代碼比較簡單,因此直接就100%了,那麼修改一下源碼,加個if吧:

'use strict';
module.exports = bu => new Promise(resolve => {
  if (bu === 'Tmall') return resolve(`Welcome to Tmall`);
  resolve(`Hello ${bu}`);
});

測試用例也跟着變一下:

'use strict';
require('should');
const mylib = require('../index');

setTimeout(() => {
  describe('My First Test', () => {
    it('Welcome to Tmall', () => {
      return mylib('Tmall').should.be.fulfilledWith('Welcome to Tmall');
    });
  });
  run();
}, 1000);

換了姿式,咱們再來一次./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --delay,能夠獲得下圖:

coverage again

當使用istanbul運行Mocha時,istanbul命令本身的參數放在--以前,須要傳遞給Mocha的參數放在--以後

如預期所想,覆蓋率再也不是100%了,這時候我想看看哪些代碼被運行了,哪些沒有,怎麼辦呢?

運行完成後,項目下會多出一個coverage文件夾,這裏就是放代碼覆蓋率結果的地方,它的結構大體以下:

.
├── coverage.json
├── lcov-report
│   ├── base.css
│   ├── index.html
│   ├── prettify.css
│   ├── prettify.js
│   ├── sort-arrow-sprite.png
│   ├── sorter.js
│   └── test
│       ├── index.html
│       └── index.js.html
└── lcov.info
  • coverage.json和lcov.info:測試結果描述的json文件,這個文件能夠被一些工具讀取,生成可視化的代碼覆蓋率結果,這個文件後面接入持續集成時還會提到。

  • lcov-report:經過上面兩個文件由工具處理後生成的覆蓋率結果頁面,打開能夠很是直觀的看到代碼的覆蓋率

這裏open coverage/lcov-report/index.html能夠看到文件目錄,點擊對應的文件進入到文件詳情,能夠看到index.js的覆蓋率如圖所示:

coverage report

這裏有四個指標,經過這些指標,能夠量化代碼覆蓋狀況:

  • statements:可執行語句執行狀況

  • branches:分支執行狀況,好比if就會產生兩個分支,咱們只運行了其中的一個

  • Functions:函數執行狀況

  • Lines:行執行狀況

下面代碼部分,沒有被執行過得代碼會被標紅,這些標紅的代碼每每是bug滋生的土壤,咱們要儘量消除這些紅色。爲此咱們添加一個測試用例:

'use strict';
require('should');
const mylib = require('../index');

setTimeout(() => {
  describe('My First Test', () => {
    it('Welcome to Tmall', () => {
      return mylib('Tmall').should.be.fulfilledWith('Welcome to Tmall');
    });
    it('Hello Taobao', () => {
      return mylib('Taobao').should.be.fulfilledWith('Hello Taobao');
    });
  });
  run();
}, 1000);

再來一次./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --delay,從新打開覆蓋率頁面,能夠看到紅色已經消失了,覆蓋率100%。目標完成,能夠睡個安穩覺了

集成到package.json

好了,一個簡單的Node.js測試算是作完了,這些測試任務均可以集中寫到package.jsonscripts字段中,好比:

{
  "scripts": {
    "test": "NODE_ENV=test ./node_modules/.bin/_mocha --require should",
    "cov": "NODE_ENV=test ./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --delay"
  },
}

這樣直接運行npm run test就能夠跑單元測試,運行npm run cov就能夠跑代碼覆蓋率測試了,方便快捷

對多個文件分別作測試

一般咱們的項目都會有不少文件,比較推薦的方法是對每一個文件單獨去作測試。好比代碼在./lib/下,那麼./lib/文件夾下的每一個文件都應該對應一個./test/文件夾下的文件名_spec.js的測試文件

爲何要這樣呢?不能直接運行index.js入口文件作測試嗎?

直接從入口文件來測實際上是黑盒測試,咱們並不知道代碼內部運行狀況,只是看某個特定的輸入可否獲得指望的輸出。這一般能夠覆蓋到一些主要場景,可是在代碼內部的一些邊緣場景,就很難直接經過從入口輸入特定的數據來解決了。好比代碼裏須要發送一個請求,入口只是傳入一個url,url自己正確與否只是一個方面,當時的網絡情況和服務器情況是沒法預知的。傳入相同的url,可能因爲服務器掛了,也可能由於網絡抖動,致使請求失敗而拋出錯誤,若是這個錯誤沒有獲得處理,極可能致使故障。所以咱們須要把黑盒打開,對其中的每一個小塊作白盒測試。

固然,並非全部的模塊測起來都這麼輕鬆,前端用Node.js常乾的事情就是寫構建插件和自動化工具,典型的就是Gulp插件和命令行工具,那麼這倆種特定的場景要怎麼測試呢?

Gulp插件的測試

如今前端構建使用最多的就是Gulp了,它簡明的API、流式構建理念、以及在內存中操做的性能,讓它備受追捧。雖然如今有像webpack這樣的後起之秀,但Gulp依舊憑藉着其繁榮的生態圈擔當着前端構建的絕對主力。目前天貓前端就是使用Gulp做爲代碼構建工具。

用了Gulp做爲構建工具,也就免不了要開發Gulp插件來知足業務定製化的構建需求,構建過程本質上實際上是對源代碼進行修改,若是修改過程當中出現bug極可能直接致使線上故障。所以針對Gulp插件,尤爲是會修改源代碼的Gulp插件必定要作仔細的測試來保證質量。

又一個煎蛋的栗子

好比這裏有個煎蛋的Gulp插件,功能就是往全部js代碼前加一句註釋// 天貓前端招人,有意向的請發送簡歷至lingyucoder@gmail.com,Gulp插件的代碼大概就是這樣:

'use strict';

const _ = require('lodash');
const through = require('through2');
const PluginError = require('gulp-util').PluginError;
const DEFAULT_CONFIG = {};

module.exports = config => {
  config = _.defaults(config || {}, DEFAULT_CONFIG);
  return through.obj((file, encoding, callback) => {
    if (file.isStream()) return callback(new PluginError('gulp-welcome-to-tmall', `Stream is not supported`));
    file.contents = new Buffer(`// 天貓前端招人,有意向的請發送簡歷至lingyucoder@gmail.com\n${file.contents.toString()}`);
    callback(null, file);
  });
};

對於這麼一段代碼,怎麼作測試呢?

一種方式就是直接僞造一個文件傳入,Gulp內部其實是經過vinyl-fs從操做系統讀取文件並作成虛擬文件對象,而後將這個虛擬文件對象交由through2創造的Transform來改寫流中的內容,而外層任務之間經過orchestrator控制,保證執行順序(若是不瞭解能夠看看這篇翻譯文章Gulp思惟——Gulp高級技巧)。固然一個插件不須要關心Gulp的任務管理機制,只須要關心傳入一個vinyl對象可否正確處理。所以只須要僞造一個虛擬文件對象傳給咱們的Gulp插件就能夠了。

首先設計測試用例,考慮兩個主要場景:

  1. 虛擬文件對象是流格式的,應該拋出錯誤

  2. 虛擬文件對象是Buffer格式的,可以正常對文件內容進行加工,加工完的文件加上// 天貓前端招人,有意向的請發送簡歷至lingyucoder@gmail.com的頭

對於第一個測試用例,咱們須要建立一個流格式的vinyl對象。而對於各第二個測試用例,咱們須要建立一個Buffer格式的vinyl對象。

固然,首先咱們須要一個被加工的源文件,放到test/src/testfile.js下吧:

'use strict';
console.log('hello world');

這個源文件很是簡單,接下來的任務就是把它分別封裝成流格式的vinyl對象和Buffer格式的vinyl對象。

構建Buffer格式的虛擬文件對象

構建一個Buffer格式的虛擬文件對象能夠用vinyl-fs讀取操做系統裏的文件生成vinyl對象,Gulp內部也是使用它,默認使用Buffer:

'use strict';
require('should');
const path = require('path');
const vfs = require('vinyl-fs');
const welcome = require('../index');

describe('welcome to Tmall', function() {
  it('should work when buffer', done => {
    vfs.src(path.join(__dirname, 'src', 'testfile.js'))
      .pipe(welcome())
      .on('data', function(vf) {
        vf.contents.toString().should.be.eql(`// 天貓前端招人,有意向的請發送簡歷至lingyucoder@gmail.com\n'use strict';\nconsole.log('hello world');\n`);
        done();
      });
  });
});

這樣測了Buffer格式後算是完成了主要功能的測試,那麼要如何測試流格式呢?

構建流格式的虛擬文件對象

方案一和上面同樣直接使用vinyl-fs,增長一個參數buffer: false便可:

把代碼修改爲這樣:

'use strict';
require('should');
const path = require('path');
const vfs = require('vinyl-fs');
const PluginError = require('gulp-util').PluginError;
const welcome = require('../index');

describe('welcome to Tmall', function() {
  it('should work when buffer', done => {
    // blabla
  });
  it('should throw PluginError when stream', done => {
    vfs.src(path.join(__dirname, 'src', 'testfile.js'), {
      buffer: false
    })
      .pipe(welcome())
      .on('error', e => {
        e.should.be.instanceOf(PluginError);
        done();
      });
  });
});

這樣vinyl-fs直接從文件系統讀取文件並生成流格式的vinyl對象。

若是內容並不來自於文件系統,而是來源於一個已經存在的可讀流,要怎麼把它封裝成一個流格式的vinyl對象呢?

這樣的需求能夠藉助vinyl-source-stream

'use strict';
require('should');
const fs = require('fs');
const path = require('path');
const source = require('vinyl-source-stream');
const vfs = require('vinyl-fs');
const PluginError = require('gulp-util').PluginError;
const welcome = require('../index');

describe('welcome to Tmall', function() {
  it('should work when buffer', done => {
    // blabla
  });
  it('should throw PluginError when stream', done => {
    fs.createReadStream(path.join(__dirname, 'src', 'testfile.js'))
      .pipe(source())
      .pipe(welcome())
      .on('error', e => {
        e.should.be.instanceOf(PluginError);
        done();
      });
  });
});

這裏首先經過fs.createReadStream建立了一個可讀流,而後經過vinyl-source-stream把這個可讀流包裝成流格式的vinyl對象,並交給咱們的插件作處理

Gulp插件執行錯誤時請拋出PluginError,這樣可以讓gulp-plumber這樣的插件進行錯誤管理,防止錯誤終止構建進程,這在gulp watch時很是有用

模擬Gulp運行

咱們僞造的對象已經能夠跑通功能測試了,可是這數據來源終究是本身僞造的,並非用戶平常的使用方式。若是採用最接近用戶使用的方式來作測試,測試結果才更加可靠和真實。那麼問題來了,怎麼模擬真實的Gulp環境來作Gulp插件的測試呢?

首先模擬一下咱們的項目結構:

test
├── build
│   └── testfile.js
├── gulpfile.js
└── src
    └── testfile.js

一個簡易的項目結構,源碼放在src下,經過gulpfile來指定任務,構建結果放在build下。按照咱們日常使用方式在test目錄下搭好架子,而且寫好gulpfile.js:

'use strict';
const gulp = require('gulp');
const welcome = require('../index');
const del = require('del');

gulp.task('clean', cb => del('build', cb));

gulp.task('default', ['clean'], () => {
  return gulp.src('src/**/*')
    .pipe(welcome())
    .pipe(gulp.dest('build'));
});

接着在測試代碼裏來模擬Gulp運行了,這裏有兩種方案:

  1. 使用child_process庫提供的spawnexec開子進程直接跑gulp命令,而後測試build目錄下是不是想要的結果

  2. 直接在當前進程獲取gulpfile中的Gulp實例來運行Gulp任務,而後測試build目錄下是不是想要的結果

開子進程進行測試有一些坑,istanbul測試代碼覆蓋率時時沒法跨進程的,所以開子進程測試,首先須要子進程執行命令時加上istanbul,而後還須要手動去收集覆蓋率數據,當開啓多個子進程時還須要本身作覆蓋率結果數據合併,至關麻煩。

那麼不開子進程怎麼作呢?能夠藉助run-gulp-task這個工具來運行,其內部的機制就是首先獲取gulpfile文件內容,在文件尾部加上module.exports = gulp;後require gulpfile從而獲取Gulp實例,而後將Gulp實例遞交給run-sequence調用內部未開放的APIgulp.run來運行。

咱們採用不開子進程的方式,把運行Gulp的過程放在before鉤子中,測試代碼變成下面這樣:

'use strict';
require('should');
const path = require('path');
const run = require('run-gulp-task');
const CWD = process.cwd();
const fs = require('fs');

describe('welcome to Tmall', () => {
  before(done => {
    process.chdir(__dirname);
    run('default', path.join(__dirname, 'gulpfile.js'))
      .catch(e => e)
      .then(e => {
        process.chdir(CWD);
        done(e);
      });
  });
  it('should work', function() {
    fs.readFileSync(path.join(__dirname, 'build', 'testfile.js')).toString().should.be.eql(`// 天貓前端招人,有意向的請發送簡歷至lingyucoder@gmail.com\n'use strict';\nconsole.log('hello world');\n`);
  });
});

這樣因爲不須要開子進程,代碼覆蓋率測試也能夠和普通Node.js模塊同樣了

測試命令行輸出

雙一個煎蛋的栗子

固然前端寫工具並不僅限於Gulp插件,偶爾還會寫一些輔助命令啥的,這些輔助命令直接在終端上運行,結果也會直接展現在終端上。好比一個簡單的使用commander實現的命令行工具:

// in index.js
'use strict';
const program = require('commander');
const path = require('path');
const pkg = require(path.join(__dirname, 'package.json'));

program.version(pkg.version)
  .usage('[options] <file>')
  .option('-t, --test', 'Run test')
  .action((file, prog) => {
    if (prog.test) console.log('test');
  });

module.exports = program;

// in bin/cli
#!/usr/bin/env node
'use strict';
const program = require('../index.js');

program.parse(process.argv);

!program.args[0] && program.help();

// in package.json
{
  "bin": {
    "cli-test": "./bin/cli"
  }
}

攔截輸出

要測試命令行工具,天然要模擬用戶輸入命令,這一次依舊選擇不開子進程,直接用僞造一個process.argv交給program.parse便可。命令輸入了問題也來了,數據是直接console.log的,要怎麼攔截呢?

這能夠藉助sinon來攔截console.log,並且sinon很是貼心的提供了mocha-sinon方便測試用,這樣test.js大體就是這個樣子:

'use strict';
require('should');
require('mocha-sinon');
const program = require('../index');
const uncolor = require('uncolor');

describe('cli-test', () => {
  let rst;
  beforeEach(function() {
    this.sinon.stub(console, 'log', function() {
      rst = arguments[0];
    });
  });
  it('should print "test"', () => {
    program.parse([
      'node',
      './bin/cli',
      '-t',
      'file.js'
    ]);
    return uncolor(rst).trim().should.be.eql('test');
  });
});

PS:因爲命令行輸出時常常會使用colors這樣的庫來添加顏色,所以在測試時記得用uncolor把這些顏色移除

小結

Node.js相關的單元測試就扯這麼多了,還有不少場景像服務器測試什麼的就不扯了,由於我不會。固然前端最主要的工做仍是寫頁面,接下來扯一扯如何對頁面上的組件作測試。

頁面測試

對於瀏覽器裏跑的前端代碼,作測試要比Node.js模塊要麻煩得多。Node.js模塊純js代碼,使用V8運行在本地,測試用的各類各樣的依賴和工具都能快速的安裝,而前端代碼不只僅要測試js,CSS等等,更麻煩的事須要模擬各類各樣的瀏覽器,比較常見的前端代碼測試方案有下面幾種:

  1. 構建一個測試頁面,人肉直接到虛擬機上開各類瀏覽器跑測試頁面(好比公司的f2etest)。這個方案的缺點就是很差作代碼覆蓋率測試,也很差持續化集成,同時人肉工做較多

  2. 使用PhantomJS構建一個僞造的瀏覽器環境跑單元測試,好處是解決了代碼覆蓋率問題,也能夠作持續集成。這個方案的缺點是PhantomJS畢竟是Qt的webkit,並非真實瀏覽器環境,PhantomJS也有各類各樣兼容性坑

  3. 經過Karma調用本機各類瀏覽器進行測試,好處是能夠跨瀏覽器作測試,也能夠測試覆蓋率,但持續集成時須要注意只能開PhantomJS作測試,畢竟集成的Linux環境不可能有瀏覽器。這能夠說是目前看到的最好的前端代碼測試方式了

這裏以gulp爲構建工具作測試,後面在React組件測試部分再介紹以webpack爲構建工具作測試

叒一個煎蛋的栗子

前端代碼依舊是js,同樣能夠用Mocha+Should.js來作單元測試。打開node_modules下的Mocha和Should.js,你會發現這些優秀的開源工具已經很是貼心的提供了可在瀏覽器中直接運行的版本:mocha/mocha.jsshould/should.min.js,只須要把他們經過script標籤引入便可,另外Mocha還須要引入本身的樣式mocha/mocha.css

首先看一下咱們的前端項目結構:

.
├── gulpfile.js
├── package.json
├── src
│   └── index.js
└── test
    ├── test.html
    └── test.js

好比這裏源碼src/index.js就是定義一個全局函數:

window.render = function() {
  var ctn = document.createElement('div');
  ctn.setAttribute('id', 'tmall');
  ctn.appendChild(document.createTextNode('天貓前端招人,有意向的請發送簡歷至lingyucoder@gmail.com'));
  document.body.appendChild(ctn);
}

而測試頁面test/test.html大體上是這個樣子:

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="../node_modules/mocha/mocha.css"/>
  <script src="../node_modules/mocha/mocha.js"></script>
  <script src="../node_modules/should/should.js"></script>
</head>

<body>
  <div id="mocha"></div>
  <script src="../src/index.js"></script>
  <script src="test.js"></script>
</body>

</html>

head裏引入了測試框架Mocha和斷言庫Should.js,測試的結果會被顯示在<div id="mocha"></div>這個容器裏,而test/test.js裏則是咱們的測試的代碼。

前端頁面上測試和Node.js上測試沒啥太大不一樣,只是須要指定Mocha使用的UI,並須要手動調用mocha.run()

mocha.ui('bdd');
describe('Welcome to Tmall', function() {
  before(function() {
    window.render();
  });
  it('Hello', function() {
    document.getElementById('tmall').textContent.should.be.eql('天貓前端招人,有意向的請發送簡歷至lingyucoder@gmail.com');
  });
});
mocha.run();

在瀏覽器裏打開test/test.html頁面,就能夠看到效果了:

test page

在不一樣的瀏覽器裏打開這個頁面,就能夠看到當前瀏覽器的測試了。這種方式能兼容最多的瀏覽器,固然要跨機器以前記得把資源上傳到一個測試機器都能訪問到的地方,好比CDN。

測試頁面有了,那麼來試試接入PhantomJS吧

使用PhantomJS進行測試

PhantomJS是一個模擬的瀏覽器,它能執行js,甚至還有webkit渲染引擎,只是沒有瀏覽器的界面上渲染結果罷了。咱們可使用它作不少事情,好比對網頁進行截圖,寫爬蟲爬取異步渲染的頁面,以及接下來要介紹的——對頁面作測試。

固然,這裏咱們不是直接使用PhantomJS,而是使用mocha-phantomjs來作測試。npm install --save-dev mocha-phantomjs安裝完成後,就能夠運行命令./node_modules/.bin/mocha-phantomjs ./test/test.html來對上面那個test/test.html的測試了:

PhantomJS test

單元測試沒問題了,接下來就是代碼覆蓋率測試

覆蓋率打點

首先第一步,改寫咱們的gulpfile.js

'use strict';
const gulp = require('gulp');
const istanbul = require('gulp-istanbul');

gulp.task('test', function() {
  return gulp.src(['src/**/*.js'])
    .pipe(istanbul({
      coverageVariable: '__coverage__'
    }))
    .pipe(gulp.dest('build-test'));
});

這裏把覆蓋率結果保存到__coverage__裏面,把打完點的代碼放到build-test目錄下,好比剛纔的src/index.js的代碼,在運行gulp test後,會生成build-test/index.js,內容大體是這個樣子:

var __cov_WzFiasMcIh_mBvAjOuQiQg = (Function('return this'))();
if (!__cov_WzFiasMcIh_mBvAjOuQiQg.__coverage__) { __cov_WzFiasMcIh_mBvAjOuQiQg.__coverage__ = {}; }
__cov_WzFiasMcIh_mBvAjOuQiQg = __cov_WzFiasMcIh_mBvAjOuQiQg.__coverage__;
if (!(__cov_WzFiasMcIh_mBvAjOuQiQg['/Users/lingyu/gitlab/dev/mui/test-page/src/index.js'])) {
   __cov_WzFiasMcIh_mBvAjOuQiQg['/Users/lingyu/gitlab/dev/mui/test-page/src/index.js'] = {"path":"/Users/lingyu/gitlab/dev/mui/test-page/src/index.js","s":{"1":0,"2":0,"3":0,"4":0,"5":0},"b":{},"f":{"1":0},"fnMap":{"1":{"name":"(anonymous_1)","line":1,"loc":{"start":{"line":1,"column":16},"end":{"line":1,"column":27}}}},"statementMap":{"1":{"start":{"line":1,"column":0},"end":{"line":6,"column":1}},"2":{"start":{"line":2,"column":2},"end":{"line":2,"column":42}},"3":{"start":{"line":3,"column":2},"end":{"line":3,"column":34}},"4":{"start":{"line":4,"column":2},"end":{"line":4,"column":85}},"5":{"start":{"line":5,"column":2},"end":{"line":5,"column":33}}},"branchMap":{}};
}
__cov_WzFiasMcIh_mBvAjOuQiQg = __cov_WzFiasMcIh_mBvAjOuQiQg['/Users/lingyu/gitlab/dev/mui/test-page/src/index.js'];
__cov_WzFiasMcIh_mBvAjOuQiQg.s['1']++;window.render=function(){__cov_WzFiasMcIh_mBvAjOuQiQg.f['1']++;__cov_WzFiasMcIh_mBvAjOuQiQg.s['2']++;var ctn=document.createElement('div');__cov_WzFiasMcIh_mBvAjOuQiQg.s['3']++;ctn.setAttribute('id','tmall');__cov_WzFiasMcIh_mBvAjOuQiQg.s['4']++;ctn.appendChild(document.createTextNode('天貓前端招人\uFF0C有意向的請發送簡歷至lingyucoder@gmail.com'));__cov_WzFiasMcIh_mBvAjOuQiQg.s['5']++;document.body.appendChild(ctn);};

這都什麼鬼!無論了,反正運行它就好。把test/test.html裏面引入的代碼從src/index.js修改成build-test/index.js,保證頁面運行時使用的是編譯後的代碼。

編寫鉤子

運行數據會存放到變量__coverage__裏,可是咱們還須要一段鉤子代碼在單元測試結束後獲取這個變量裏的內容。把鉤子代碼放在test/hook.js下,裏面內容這樣寫:

'use strict';

var fs = require('fs');

module.exports = {
  afterEnd: function(runner) {
    var coverage = runner.page.evaluate(function() {
      return window.__coverage__;
    });
    if (coverage) {
      console.log('Writing coverage to coverage/coverage.json');
      fs.write('coverage/coverage.json', JSON.stringify(coverage), 'w');
    } else {
      console.log('No coverage data generated');
    }
  }
};

這樣準備工做工做就大功告成了,執行命令./node_modules/.bin/mocha-phantomjs ./test/test.html --hooks ./test/hook.js,能夠看到以下圖結果,同時覆蓋率結果被寫入到coverage/coverage.json裏面了。

coverage hook

生成頁面

有告終果覆蓋率結果就能夠生成覆蓋率頁面了,首先看看覆蓋率概況吧。執行命令./node_modules/.bin/istanbul report --root coverage text-summary,能夠看到下圖:

coverage summary

仍是原來的配方,仍是想熟悉的味道。接下來運行./node_modules/.bin/istanbul report --root coverage lcov生成覆蓋率頁面,執行完後open coverage/lcov-report/index.html,點擊進入到src/index.js

coverage page

一顆賽艇!這樣咱們對前端代碼就能作覆蓋率測試了

接入Karma

Karma是一個測試集成框架,能夠方便地以插件的形式集成測試框架、測試環境、覆蓋率工具等等。Karma已經有了一套至關完善的插件體系,這裏嘗試在PhantomJS、Chrome、FireFox下作測試,首先須要使用npm安裝一些依賴:

  1. karma:框架本體

  2. karma-mocha:Mocha測試框架

  3. karma-coverage:覆蓋率測試

  4. karma-spec-reporter:測試結果輸出

  5. karma-phantomjs-launcher:PhantomJS環境

  6. phantomjs-prebuilt: PhantomJS最新版本

  7. karma-chrome-launcher:Chrome環境

  8. karma-firefox-launcher:Firefox環境

安裝完成後,就能夠開啓咱們的Karma之旅了。仍是以前的那個項目,咱們把該清除的清除,只留下源文件和而是文件,並增長一個karma.conf.js文件:

.
├── karma.conf.js
├── package.json
├── src
│   └── index.js
└── test
    └── test.js

karma.conf.js是Karma框架的配置文件,在這個例子裏,它大概是這個樣子:

'use strict';

module.exports = function(config) {
  config.set({
    frameworks: ['mocha'],
    files: [
      './node_modules/should/should.js',
      'src/**/*.js',
      'test/**/*.js'
    ],
    preprocessors: {
      'src/**/*.js': ['coverage']
    },
    plugins: ['karma-mocha', 'karma-phantomjs-launcher', 'karma-chrome-launcher', 'karma-firefox-launcher', 'karma-coverage', 'karma-spec-reporter'],
    browsers: ['PhantomJS', 'Firefox', 'Chrome'],
    reporters: ['spec', 'coverage'],
    coverageReporter: {
      dir: 'coverage',
      reporters: [{
        type: 'json',
        subdir: '.',
        file: 'coverage.json',
      }, {
        type: 'lcov',
        subdir: '.'
      }, {
        type: 'text-summary'
      }]
    }
  });
};

這些配置都是什麼意思呢?這裏挨個說明一下:

  • frameworks: 使用的測試框架,這裏依舊是咱們熟悉又親切的Mocha

  • files:測試頁面須要加載的資源,上面的test目錄下已經沒有test.html了,全部須要加載內容都在這裏指定,若是是CDN上的資源,直接寫URL也能夠,不過建議儘量使用本地資源,這樣測試更快並且即便沒網也能夠測試。這個例子裏,第一行載入的是斷言庫Should.js,第二行是src下的全部代碼,第三行載入測試代碼

  • preprocessors:配置預處理器,在上面files載入對應的文件前,若是在這裏配置了預處理器,會先對文件作處理,而後載入處理結果。這個例子裏,須要對src目錄下的全部資源添加覆蓋率打點(這一步以前是經過gulp-istanbul來作,如今karma-coverage框架能夠很方便的處理,也不須要鉤子啥的了)。後面作React組件測試時也會在這裏使用webpack

  • plugins:安裝的插件列表

  • browsers:須要測試的瀏覽器,這裏咱們選擇了PhantomJS、FireFox、Chrome

  • reporters:須要生成哪些代碼報告

  • coverageReporter:覆蓋率報告要如何生成,這裏咱們指望生成和以前同樣的報告,包括覆蓋率頁面、lcov.info、coverage.json、以及命令行裏的提示

好了,配置完成,來試試吧,運行./node_modules/karma/bin/karma start --single-run,能夠看到以下輸出:

run karma

能夠看到,Karma首先會在9876端口開啓一個本地服務,而後分別啓動PhantomJS、FireFox、Chrome去加載這個頁面,收集到測試結果信息以後分別輸出,這樣跨瀏覽器測試就解決啦。若是要新增瀏覽器就安裝對應的瀏覽器插件,而後在browsers裏指定一下便可,很是靈活方便。

那若是個人mac電腦上沒有IE,又想測IE,怎麼辦呢?能夠直接運行./node_modules/karma/bin/karma start啓動本地服務器,而後使用其餘機器開對應瀏覽器直接訪問本機的9876端口(固然這個端口是可配置的)便可,一樣移動端的測試也能夠採用這個方法。這個方案兼顧了前兩個方案的優勢,彌補了其不足,是目前看到最優秀的前端代碼測試方案了

React組件測試

去年React旋風通常席捲全球,固然天貓也在技術上緊跟時代腳步。天貓商家端業務已經全面切入React,造成了React組件體系,幾乎全部新業務都採用React開發,而老業務也在不斷向React遷移。React大紅大紫,這裏單獨拉出來說一講React+webpack的打包方案如何進行測試

這裏只聊React Web,不聊React Native

事實上天貓目前並未採用webpack打包,而是Gulp+Babel編譯React CommonJS代碼成AMD模塊使用,這是爲了可以在新老業務使用上更加靈活,固然也有部分業務採用webpack打包並上線

叕一個煎蛋的栗子

這裏建立一個React組件,目錄結構大體這樣(這裏略過CSS相關部分,只要跑通了,集成CSS像PostCSS、Less都沒啥問題):

.
├── demo
├── karma.conf.js
├── package.json
├── src
│   └── index.jsx
├── test
│   └── index_spec.jsx
├── webpack.dev.js
└── webpack.pub.js

React組件源碼src/index.jsx大概是這個樣子:

import React from 'react';
class Welcome extends React.Component {
  constructor() {
    super();
  }
  render() {
    return <div>{this.props.content}</div>;
  }
}
Welcome.displayName = 'Welcome';
Welcome.propTypes = {
  /**
   * content of element
   */
  content: React.PropTypes.string
};
Welcome.defaultProps = {
  content: 'Hello Tmall'
};
module.exports = Welcome;

那麼對應的test/index_spec.jsx則大概是這個樣子:

import 'should';
import Welcome from '../src/index.jsx';
import ReactDOM from 'react-dom';
import React from 'react';
import TestUtils from 'react-addons-test-utils';
describe('test', function() {
  const container = document.createElement('div');
  document.body.appendChild(container);
  afterEach(() => {
    ReactDOM.unmountComponentAtNode(container);
  });
  it('Hello Tmall', function() {
    let cp = ReactDOM.render(<Welcome/>, container);
    let welcome = TestUtils.findRenderedComponentWithType(cp, Welcome);
    ReactDOM.findDOMnode(welcome).textContent.should.be.eql('Hello Tmall');
  });
});

因爲是測試React,天然要使用React的TestUtils,這個工具庫提供了很多方便查找節點和組件的方法,最重要的是它提供了模擬事件的API,這能夠說是UI測試最重要的一個功能。更多關於TestUtils的使用請參考React官網,這裏就不扯了...

代碼有了,測試用例也有了,接下就差跑起來了。karma.conf.js確定就和上面不同了,首先它要多一個插件karma-webpack,由於咱們的React組件是須要webpack打包的,不打包的代碼壓根就無法運行。另外還須要注意代碼覆蓋率測試也出現了變化。由於如今多了一層Babel編譯,Babel編譯ES六、ES7源碼生成ES5代碼後會產生不少polyfill代碼,所以若是對build完成以後的代碼作覆蓋率測試會包含這些polyfill代碼,這樣測出來的覆蓋率顯然是不可靠的,這個問題能夠經過isparta-loader來解決。React組件的karma.conf.js大概是這個樣子:

'use strict';
const path = require('path');

module.exports = function(config) {
  config.set({
    frameworks: ['mocha'],
    files: [
      './node_modules/phantomjs-polyfill/bind-polyfill.js',
      'test/**/*_spec.jsx'
    ],
    plugins: ['karma-webpack', 'karma-mocha',, 'karma-chrome-launcher', 'karma-firefox-launcher', 'karma-phantomjs-launcher', 'karma-coverage', 'karma-spec-reporter'],
    browsers: ['PhantomJS', 'Firefox', 'Chrome'],
    preprocessors: {
      'test/**/*_spec.jsx': ['webpack']
    },
    reporters: ['spec', 'coverage'],
    coverageReporter: {
      dir: 'coverage',
      reporters: [{
        type: 'json',
        subdir: '.',
        file: 'coverage.json',
      }, {
        type: 'lcov',
        subdir: '.'
      }, {
        type: 'text-summary'
      }]
    },
    webpack: {
      module: {
        loaders: [{
          test: /\.jsx?/,
          loaders: ['babel']
        }],
        preLoaders: [{
          test: /\.jsx?$/,
          include: [path.resolve('src/')],
          loader: 'isparta'
        }]
      }
    },
    webpackMiddleware: {
      noInfo: true
    }
  });
};

這裏相對於以前的karma.conf.js,主要有如下幾點區別:

  1. 因爲webpack的打包功能,咱們在測試代碼裏直接import組件代碼,所以再也不須要在files裏手動引入組件代碼

  2. 預處理裏面須要對每一個測試文件都作webpack打包

  3. 添加webpack編譯相關配置,在編譯源碼時,須要定義preLoaders,並使用isparta-loader作代碼覆蓋率打點

  4. 添加webpackMiddleware配置,這裏noInfo做用是不須要輸出webpack編譯時那一大串信息

這樣配置基本上就完成了,跑一把./node_modules/karma/bin/karma start --single-run

react karma

很好,結果符合預期。open coverage/lcov-report/index.html打開覆蓋率頁面:

react coverage

鵝妹子音!!!直接對jsx代碼作的覆蓋率測試!這樣React組件的測試大致上就完工了

小結

前端的代碼測試主要難度是如何模擬各類各樣的瀏覽器環境,Karma給咱們提供了很好地方式,對於本地有的瀏覽器能自動打開並測試,本地沒有的瀏覽器則提供直接訪問的頁面。前端尤爲是移動端瀏覽器種類繁多,很難作到完美,但咱們能夠經過這種方式實現主流瀏覽器的覆蓋,保證每次上線大多數用戶沒有問題。

持續集成

測試結果有了,接下來就是把這些測試結果接入到持續集成之中。持續集成是一種很是優秀的多人開發實踐,經過代碼push觸發鉤子,實現自動運行編譯、測試等工做。接入持續集成後,咱們的每一次push代碼,每一個Merge Request都會生成對應的測試結果,項目的其餘成員能夠很清楚地瞭解到新代碼是否影響了現有的功能,在接入自動告警後,能夠在代碼提交階段就快速發現錯誤,提高開發迭代效率。

持續集成會在每次集成時提供一個幾乎空白的虛擬機器,並拷貝用戶提交的代碼到機器本地,經過讀取用戶項目下的持續集成配置,自動化的安裝環境和依賴,編譯和測試完成後生成報告,在一段時間以後釋放虛擬機器資源。

開源的持續集成

開源比較出名的持續集成服務當屬Travis,而代碼覆蓋率則經過Coveralls,只要有GitHub帳戶,就能夠很輕鬆的接入Travis和Coveralls,在網站上勾選了須要持續集成的項目之後,每次代碼push就會觸發自動化測試。這兩個網站在跑完測試之後,會自動生成測試結果的小圖片

build result

Travis會讀取項目下的travis.yml文件,一個簡單的例子:

language: node_js
node_js:
  - "stable"
  - "4.0.0"
  - "5.0.0"
script: "npm run test"
after_script: "npm install coveralls@2.10.0 && cat ./coverage/lcov.info | coveralls"

language定義了運行環境的語言,而對應的node_js能夠定義須要在哪幾個Node.js版本作測試,好比這裏的定義,表明着會分別在最新穩定版、4.0.0、5.0.0版本的Node.js環境下作測試

而script則是測試利用的命令,通常狀況下,都應該把本身這個項目開發所須要的命令都寫在package.json的scripts裏面,好比咱們的測試方法./node_modules/karma/bin/karma start --single-run就應當這樣寫到scripts裏:

{
  "scripts": {
    "test": "./node_modules/karma/bin/karma start --single-run"
  }
}

而after_script則是在測試完成以後運行的命令,這裏須要上傳覆蓋率結果到coveralls,只須要安裝coveralls庫,而後獲取lcov.info上傳給Coveralls便可

更多配置請參照Travis官網介紹

這樣配置後,每次push的結果均可以上Travis和Coveralls看構建和代碼覆蓋率結果了

travis

coveralls

小結

項目接入持續集成在多人開發同一個倉庫時候能起到很大的用途,每次push都能自動觸發測試,測試沒過會發生告警。若是需求採用Issues+Merge Request來管理,每一個需求一個Issue+一個分支,開發完成後提交Merge Request,由項目Owner負責合併,項目質量將更有保障

總結

這裏只是前端測試相關知識的一小部分,還有很是多的內容能夠深刻挖掘,而測試也僅僅是前端流程自動化的一部分。在前端技術快速發展的今天,前端項目再也不像當年的刀耕火種通常,愈來愈多的軟件工程經驗被集成到前端項目中,前端項目正向工程化、流程化、自動化方向高速奔跑。還有更多優秀的提高開發效率、保證開發質量的自動化方案亟待咱們挖掘。

相關文章
相關標籤/搜索