前端單元測試探索
雖然很多公司有自己的測試部門,而且前端開發大多不涉及測試環節,但鑒于目前前端領域的快速發展,其涉及面越來越廣,前端開發者們必然不能止步于目前的狀態。我覺得學會編寫良好的測試,不僅僅有利于自己整理需求、檢查代碼,更是一個優秀開發者的體現。
首先不得不推薦兩篇文章:
Intro
單元測試到底是什么?
需要訪問數據庫的測試不是單元測試
需要訪問網絡的測試不是單元測試
需要訪問文件系統的測試不是單元測試
--- 修改代碼的藝術
我們在單元測試中應該避免什么?
- 太多的條件邏輯
- 構造函數中做了太多事情
- too many全局變量
- too many靜態方法
- 無關邏輯
- 過多外部依賴
TDD(Test-driven development)
測試驅動開發(TDD),其基本思路是通過測試來推動整個開發的進行。
- 單元測試的首要目的不是為了能夠編寫出大覆蓋率的全部通過的測試代碼,而是需要從使用者(調用者)的角度出發,嘗試函數邏輯的各種可能性,進而輔助性增強代碼質量
- 測試是手段而不是目的。測試的主要目的不是證明代碼正確,而是幫助發現錯誤,包括低級的錯誤
- 測試要快。快速運行、快速編寫
- 測試代碼保持簡潔
- 不會忽略失敗的測試。一旦團隊開始接受1個測試的構建失敗,那么他們漸漸地適應2、3、4或者更多的失敗。在這種情況下,測試集就不再起作用
IMPORTANT
- 一定不能誤解了TDD的核心目的!
- 測試不是為了覆蓋率和正確率
- 而是作為實例,告訴開發人員要編寫什么代碼
- 紅燈(代碼還不完善,測試掛)-> 綠燈(編寫代碼,測試通過)-> 重構(優化代碼并保證測試通過)
大致過程
- 需求分析,思考實現。考慮如何“使用”產品代碼,是一個實例方法還是一個類方法,是從構造函數傳參還是從方法調用傳參,方法的命名,返回值等。這時其實就是在做設計,而且設計以代碼來體現。此時測試為紅
- 實現代碼讓測試為綠
- 重構,然后重復測試
- 最終符合所有要求:
* 每個概念都被清晰的表達
* Not Repeat Self
* 沒有多余的東西
* 通過測試
BDD(Behavior-driven development)
行為驅動開發(BDD),重點是通過與利益相關者的討論,取得對預期的軟件行為的清醒認識,其重點在于溝通
大致過程
- 從業務的角度定義具體的,以及可衡量的目標
- 找到一種可以達到設定目標的、對業務最重要的那些功能的方法
- 然后像故事一樣描述出一個個具體可執行的行為。其描述方法基于一些通用詞匯,這些詞匯具有準確無誤的表達能力和一致的含義。例如,expect, should, assert
- 尋找合適語言及方法,對行為進行實現
- 測試人員檢驗產品運行結果是否符合預期行為。最大程度的交付出符合用戶期望的產品,避免表達不一致帶來的問題
測試的分類 & 測試工具
分類
- API/Func UnitTest
* 測試不常變化的函數邏輯
* 測試前后端API接口
- UI UnitTest
* 頁面自動截圖
* 頁面DOM元素檢查
* 跑通交互流程
工具
- Mocha + Chai
- PhantomJS or CasperJS or Nightwatch.js
- selenium
* with python
* with js
mocha + chai的API/Func UnitTest
mocha是一套前端測試工具,我們可以拿它和其他測試工具搭配。
而chai則是BDD/TDD測試斷言庫,提供諸如expect這樣的測試語法
initial
下面兩篇文章值得一看:
- Testing in ES6 with Mocha and Babel 6
- Using Babel
setup
- $ npm i mocha --save-dev
- $ npm i chai --save-dev
Use with es6
babel 6+
- $ npm install --save-dev babel-register
- $ npm install babel-preset-es2015 --save-dev
- // package.json
- {
- "scripts": {
- "test": "./node_modules/mocha/bin/mocha --compilers js:babel-register"
- },
- "babel": {
- "presets": [
- "es2015"
- ]
- }
- }
babel 5+
- $ npm install --save-dev babel-core
- // package.json
- {
- "scripts": {
- "test": "./node_modules/mocha/bin/mocha --compilers js:babel-core/register"
- }
- }
Use with coffeescript
- $ npm install --save coffee-script
- {
- "scripts": {
- "test": "./node_modules/mocha/bin/mocha --compilers coffee:coffee-script/register"
- }
- }
Use with es6+coffeescript
After done both...
- {
- "scripts": {
- "test": "./node_modules/mocha/bin/mocha --compilers js:babel-core/register,coffee:coffee-script/register"
- }
- }
- # $ mocha
- $ npm t
- $ npm test
chai
- import chai from 'chai';
- const assert = chai.assert;
- const expect = chai.expect;
- const should = chai.should();
- foo.should.be.a('string');
- foo.should.equal('bar');
- list.should.have.length(3);
- obj.should.have.property('name');
- expect(foo).to.be.a('string');
- expect(foo).to.equal('bar');
- expect(list).to.have.length(3);
- expect(obj).to.have.property('flavors');
- assert.typeOf(foo, 'string');
- assert.equal(foo, 'bar');
- assert.lengthOf(list, 3);
- assert.property(obj, 'flavors');
Test
測試的一個基本思路是,自身從函數的調用者出發,對函數進行各種情況的調用,查看其容錯程度、返回結果是否符合預期。
- import chai from 'chai';
- const assert = chai.assert;
- const expect = chai.expect;
- const should = chai.should();
- describe('describe a test', () => {
- it('should return true', () => {
- let example = true;
- // expect
- expect(example).not.to.equal(false);
- expect(example).to.equal(true);
- // should
- example.should.equal(true);
- example.should.be.a(boolen);
- [1, 2].should.have.length(2);
- });
- it('should check an object', () => {
- // 對于多層嵌套的Object而言..
- let nestedObj = {
- a: {
- b: 1
- }
- };
- let nestedObjCopy = Object.assign({}, nestedObj);
- nestedObj.a.b = 2;
- // do a function to change nestedObjCopy.a.b
- expect(nestedObjCopy).to.deep.equal(nestedObj);
- expect(nestedObjCopy).to.have.property('a');
- });
- });
AsynTest
Testing Asynchronous Code with MochaJS and ES7 async/await
mocha無法自動監聽異步方法的完成,需要我們在完成之后手動調用done()方法
而如果要在回調之后使用異步測試語句,則需要使用try/catch進行捕獲。成功則done(),失敗則done(error)
- // 普通的測試方法
- it("should work", () =>{
- console.log("Synchronous test");
- });
- // 異步的測試方法
- it("should work", (done) =>{
- setTimeout(() => {
- try {
- expect(1).not.to.equal(0);
- done(); // 成功
- } catch (err) {
- done(err); // 失敗
- }
- }, 200);
- });
異步測試有兩種方法完結:done或者返回Promise。而通過返回Promise,則不再需要編寫笨重的try/catch語句
- it("Using a Promise that resolves successfully with wrong expectation!", function() {
- var testPromise = new Promise(function(resolve, reject) {
- setTimeout(function() {
- resolve("Hello World!");
- }, 200);
- });
- return testPromise.then(function(result){
- expect(result).to.equal("Hello!");
- });
- });
mock
mock是一個接口模擬庫,我們可以通過它來模擬代碼中的一些異步操作
React單元測試
Test React Component
React組件無法直接通過上述方法進行測試,需要安裝enzyme依賴。
- $ npm i --save-dev enzyme
- #
- $ npm i --save-dev react-addons-test-utils
假設有這樣一個組件:
- // ...省略部分import代碼
- class TestComponent extends React.Component {
- constructor(props) {
- super(props);
- let {num} = props;
- this.state = {
- clickNum: num
- }
- this.handleClick = this.handleClick.bind(this)
- }
- handleClick() {
- let {clickNum} = this.state;
- this.setState({
- clickNum: clickNum + 1
- });
- }
- render() {
- let {clickNum} = this.state;
- return (
- <div className="test_component">
- {clickNum}
- <span onClick={this.handleClick}>點我加1</span>
- </div>
- )
- }
- }
使用樣例:
- import React from 'react';
- import {expect} from 'chai';
- import {shallow} from 'enzyme';
- import TestComponent from '../components/TestComponent';
- describe('Test TestComponent', () => {
- // 創建一個虛擬的組件
- const wrapper = shallow(
- <TestComponent num={10} />/
- );
- /*
- * 之后,我們可以:
- * 通過wrapper.state()拿到組件的state
- * 通過wrapper.instance()拿到組件實例,以此調用組件內的方法
- * 通過wrapper.find()找到組件內的子組件
- * 但是,無法通過wrapper.props()拿到組件的props
- */
- // 測試該組件組外層的class
- it('should render with currect wrapper', () => {
- expect(wrapper.is('.test_component')).to.equal(true);
- });
- // 測試該組件初始化的state
- it('should render with currect state', () => {
- expect(wrapper.state()).to.deep.equal({
- clickNum: 10
- });
- });
- // 測試組件的方法
- it('should add one', () => {
- wrapper.instance().handleClick();
- expect(wrapper.state()).to.deep.equal({
- clickNum: 11
- });
- });
- });
Test Redux
redux身為純函數,非常便于mocha進行測試
- // 測試actions
- import * as ACTIONS from '../redux/actions';
- describe('test actions', () => {
- it('should return an action to create a todo', () => {
- let expectedAction = {
- type: ACTIONS.NEW_TODO,
- todo: 'this is a new todo'
- };
- expect(ACTIONS.addNewTodo('this is a new todo')).to.deep.equal(expectedAction);
- });
- });
- // 測試reducer
- import * as REDUCERS from '../redux/reducers';
- import * as ACTIONS from '../redux/actions';
- describe('todos', () => {
- let todos = [];
- it('should add a new todo', () => {
- todos.push({
- todo: 'new todo',
- complete: false
- });
- expect(REDUCERS.todos(todos, {
- type: ACTIONS.NEW_TODO,
- todo: 'new todo'
- })).to.deep.equal([
- {
- todo: 'new todo',
- complete: false
- }
- ]);
- });
- });
- // 還可以和store混用
- import { createStore, applyMiddleware, combineReducers } from 'redux';
- import thunk from 'redux-thunk';
- import chai from 'chai';
- import thunkMiddleware from 'redux-thunk';
- import * as REDUCERS from '../redux/reducers';
- import defaultState from '../redux/ConstValues';
- import * as ACTIONS from '../redux/actions'
- const appReducers = combineReducers(REDUCERS);
- const AppStore = createStore(appReducers, defaultState, applyMiddleware(thunk));
- let state = Object.assign({}, AppStore.getState());
- // 一旦注冊就會時刻監聽state變化
- const subscribeListener = (result, done) => {
- return AppStore.subscribe(() => {
- expect(AppStore.getState()).to.deep.equal(result);
- done();
- });
- };
- describe('use store in unittest', () => {
- it('should create a todo', (done) => {
- // 首先取得我們的期望值
- state.todos.append({
- todo: 'new todo',
- complete: false
- });
- // 注冊state監聽
- let unsubscribe = subscribeListener(state, done);
- AppStore.dispatch(ACTIONS.addNewTodo('new todo'));
- // 結束之后取消監聽
- unsubscribe();
- });
- });
基于phantomjs和selenium的UI UnitTest
PhantomJS是一個基于webkit的服務器端JavaScript API,即相當于在內存中跑了個無界面的webkit內核的瀏覽器。通過它我們可以模擬頁面加載,并獲取到頁面上的DOM元素,進行一系列的操作,以此來模擬UI測試。但缺點是無法實時看見頁面上的情況(不過可以截圖)。
而Selenium是專門為Web應用程序編寫的一個驗收測試工具,它直接運行在瀏覽器中。Selenium測試通常會調起一個可見的界面,但也可以通過設置,讓它以PhantomJS的形式進行無界面的測試。
- open 某個 url
- 監聽 onload 事件
- 事件完成后調用 sendEvent 之類的 api 去點擊某個 DOM 元素所在 point
- 觸發交互
- 根據 UI 交互情況 延時 setTimeout (規避惰加載組件點不到的情況)繼續 sendEvent 之類的交互