【自動化測試】聊一聊前端自動化測試


聊一聊前端自動化測試

參考:https://segmentfault.com/a/1190000004558796

參考:https://github.com/tmallfe/tmallfe.github.io/issues

前言

為何要測試

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

自動化測試另外一個重要特點就是快速反饋,反饋越迅速意味着開發效率越高。拿UI組件為例,開發過程都是打開瀏覽器刷新頁面點點點才能確定UI組件工作情況是否符合自己預期。接入自動化測試以后,通過腳本代替這些手動點擊,接入代碼watch后每次保存文件都能快速得知自己的的改動是否影響功能,節省了很多時間,畢竟機器干事情比人總是要快得多。

有了自動化測試,開發者會更加信任自己的代碼。開發者再也不會懼怕將代碼交給別人維護,不用擔心別的開發者在代碼里搞“破壞”。后人接手一段有測試用例的代碼,修改起來也會更加從容。測試用例里非常清楚的闡釋了開發者和使用者對於這端代碼的期望和要求,也非常有利於代碼的傳承。

考慮投入產出比來做測試

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

Node.js模塊的測試

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

測試工具

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

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

  2. 斷言庫:Should.jschaiexpect.js等等,斷言庫提供了很多語義化的方法來對值做各種各樣的判斷。當然也可以不用斷言庫,Node.js中也可以直接使用原生assert庫。這里后續以Should.js為例

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

一個煎蛋的栗子

以如下的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找到它,運行它就可以執行測試了:

這樣就可以看到測試結果了。同樣我們可以故意讓測試不通過,修改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'); }); });

就可以看到下圖了:

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)和后處理(afterafterEach)。

如果把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就可以看到如下圖:

這里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就會發生下面這樣的杯具:

那么加上--delay試試:

熟悉的綠色又回來了!

代碼覆蓋率

單元測試玩得差不多了,可以開始試試代碼覆蓋率了。首先需要安裝代碼覆蓋率工具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,可以看到下圖:

這就是代碼覆蓋率結果了,因為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,可以得到下圖:

當使用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的覆蓋率如圖所示:

這里有四個指標,通過這些指標,可以量化代碼覆蓋情況:

  • 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頁面,就可以看到效果了:

在不同的瀏覽器里打開這個頁面,就可以看到當前瀏覽器的測試了。這種方式能兼容最多的瀏覽器,當然要跨機器之前記得把資源上傳到一個測試機器都能訪問到的地方,比如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的測試了:

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

覆蓋率打點

首先第一步,改寫我們的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里面了。

生成頁面

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

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

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

接入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,可以看到如下輸出:

可以看到,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編譯ES6、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

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

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

小結

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

持續集成

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

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

開源的持續集成

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

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看構建和代碼覆蓋率結果了

小結

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

總結

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM