如何作前端單元測試

做者:邊順html

單元測試

什麼是單元測試

單元測試(unit testing),是指對軟件中的最小可測試單元進行檢查和驗證前端

須要訪問數據庫的測試不是單元測試

須要訪問網絡的測試不是單元測試

須要訪問文件系統的測試不是單元測試

--- 修改代碼的藝術
複製代碼

爲何要作單元測試

  1. 執行單元測試,就是爲了證實這段代碼的行爲和咱們指望的一致
  2. 進行充分的單元測試,是提升軟件質量,下降開發成本的必由之路
  3. 在開發人員作出修改後進行可重複的單元測試能夠避免產生那些使人不快的負做用

怎麼去設計單元測試

  1. 理解這個單元本來要作什麼(倒推出一個概要的規格說明(閱讀那些程序代碼和註釋))
  2. 畫出流程圖
  3. 組織對這個概要規格說明的走讀(Review),以確保對這個單元的說明沒有基本的錯誤
  4. 設計單元測試

    在實踐工做中,進行了完整計劃的單元測試和編寫實際的代碼所花費的精力大體上是相同的node

兩個經常使用的單元測試方法論react

  • TDD(Test-driven development):測試驅動開發
  • BDD(Behavior-driven development):行爲驅動開發

前端與單元測試

如何對前端代碼作單元測試

一般是針對函數、模塊、對象進行測試jquery

至少須要三類工具來進行單元測試:ios

  • *測試管理工具
  • *測試框架:就是運行測試的工具。經過它,能夠爲 JavaScript 應用添加測試,從而保證代碼的質量
  • *斷言庫
  • 測試瀏覽器
  • 測試覆蓋率統計工具

測試框架選擇

Jasmine:Behavior-Drive development(BDD)風格的測試框架,在業內較爲流行,功能很全面,自帶 asssert、mock 功能git

Qunit:該框架誕生之初是爲了 jquery 的單元測試,後來獨立出來再也不依賴於 jquery 自己,可是其身上仍是脫離不開 jquery 的影子github

Mocha:node 社區大神 tj 的做品,能夠在 node 和 browser 端使用,具備很強的靈活性,能夠選擇本身喜歡的斷言庫,選擇測試結果的 report數據庫

Jest:來自於 facebook 出品的通用測試框架,Jest 是一個使人愉快的 JavaScript 測試框架,專一於簡潔明快。他適用但不侷限於使用如下技術的項目:Babel, TypeScript, Node, React, Angular, Vuenpm

如何編寫測試用例(Jest + Enzyme)

一般測試文件名與要測試的文件名相同,後綴爲.test.js,全部測試文件默認放在__test__文件夾中

describe塊之中,提供測試用例的四個函數:before()、after()、beforeEach()和 afterEach()。它們會在指定時間執行(若是不須要能夠不寫)

測試文件中應包括一個或多個describe, 每一個 describe 中能夠有一個或多個it,每一個describe中能夠有一個或多個expect.

describe 稱爲"測試套件"(test suite),it 塊稱爲"測試用例"(test case)。

expect就是判斷源碼的實際執行結果與預期結果是否一致,若是不一致就拋出一個錯誤.

全部的測試都應該是肯定的。 任什麼時候候測試未改變的組件都應該產生相同的結果。 你須要確保你的快照測試與平臺和其餘不相干數據無關。

基礎模板

describe('加法函數測試', () => {
  before(() => {
    // 在本區塊的全部測試用例以前執行
  });
  after(() => {
    // 在本區塊的全部測試用例以後執行
  });
  beforeEach(() => {
    // 在本區塊的每一個測試用例以前執行
  });
  afterEach(() => {
    // 在本區塊的每一個測試用例以後執行
  });

  it('1加1應該等於2', () => {
    expect(add(1, 1)).toBe(2);
  });
  it('2加2應該等於4', () => {
    expect(add(2, 2)).toBe(42);
  });
});
複製代碼

經常使用的測試

組件中的方法測試

it('changeCardType', () => {
  let component = shallow(<Card />);
  expect(component.instance().cardType).toBe('initCard');
  component.instance().changeCardType('testCard');
  expect(component.instance().cardType).toBe('testCard');
});
複製代碼

模擬事件測試

經過 Enzyme 能夠在這個返回的 dom 對象上調用相似 jquery 的 api 進行一些查找操做,還能夠調用 setProps 和 setState 來設置 props 和 state,也能夠用 simulate 來模擬事件,觸發事件後,去判斷 props 上特定函數是否被調用,傳參是否正確;組件狀態是否發生預料之中的修改;某個 dom 節點是否存在是否符合指望

it('can save value and cancel', () => {
  const value = 'edit';
  const { wrapper, props } = setup({
    editable: true,
  });
  wrapper.find('input').simulate('change', { target: { value } });
  wrapper.setProps({ status: 'save' });
  expect(props.onChange).toBeCalledWith(value);
});
複製代碼

使用 snapshot 進行 UI 測試

it('App -- snapshot', () => {
  const renderedValue = renderer.create(<App />).toJSON();
  expect(renderedValue).toMatchSnapshot();
});
複製代碼

真實用例分析(組件)

寫一個單元測試你須要這樣作

  1. 看代碼,熟悉待測試模塊的功能和做用
  2. 設計測試用例必須覆蓋到組件的各類狀況
  3. 對錯誤狀況的測試

一般測試文件名與要測試的文件名相同,後綴爲.test.js,全部測試文件默認放在test文件夾中,通常測試文件包含下列內容:

  • 全局設置:一些前置配置,mock 的全局或第三方方法、進行一些重複的組件初始化工做,,當多個測試用例有相同的初始化組件行爲時,能夠在這裏進行掛載和銷燬
  • UI 測試:爲組件打快照,第一次運行測試命令會在目錄下生成一個組件的 DOM 節點快照,在以後的測試命令中會與快照文件進行 diff 對照,避免在後面對組件進行了非指望的 UI 更改
  • 關鍵行爲:驗證組件的基本行爲(如:Checkbox 組件的勾選行爲)
  • 事件:測試各類事件的觸發
  • 屬性:測試傳入不一樣屬性值是否獲得與指望一致的結果

accordion 組件

// accordion.test.tsx
import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals';
import Enzyme, { mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import toJSON from 'enzyme-to-json';
import JestMock from 'jest-mock';
import React from 'react';
import { Accordion } from '..';

Enzyme.configure({ adapter: new Adapter() }); // 須要根據項目的react版原本配置適配

describe('Accordion', () => {
  // 測試套件,經過 describe 塊來將測試分組
  let onChange: JestMock.Mock<any, any>; // Jest 提供的mock 函數,擦除函數的實際實現、捕獲對函數的調用
  let wrapper: Enzyme.ReactWrapper;

  beforeEach(() => {
    // 在運行測試前作的一些準備工做
    onChange = jest.fn();
    wrapper = mount(
      <Accordion onChange={onChange}> <Accordion.Item name='one' header='one'> two </Accordion.Item> <Accordion.Item name='two' header='two' disabled={true}> two </Accordion.Item> <Accordion.Item name='three' header='three' showIcon={false}> three </Accordion.Item> <Accordion.Item name='four' header='four' active={true} icons={['custom']}> four </Accordion.Item> </Accordion>
    );
  });

  afterEach(() => {
    // 在運行測試後進行的一些整理工做
    wrapper.unmount();
  });

  // UI快照測試,確保你的UI不會因意外改變
  test('Test snapshot', () => {
    // 測試用例,須要提供詳細的測試用例描述
    expect(toJSON(wrapper)).toMatchSnapshot();
  });

  // 事件測試
  test('should trigger onChange', () => {
    wrapper.find('.qtc-accordion-item-header').first().simulate('click');

    expect(onChange.mock.calls.length).toBe(1);
    expect(onChange.mock.calls[0][0]).toBe('one');
  });

  // 關鍵邏輯測試
  //點擊頭部觸發展開收起
  test('should expand and collapse', () => {
    wrapper.find('.qtc-accordion-item-header').at(2).simulate('click');

    expect(wrapper.find('.qtc-accordion-item').at(2).hasClass('active')).toBeTruthy();
  });
  // 配置disabled時不可展開
  test('should not trigger onChange when disabled', () => {
    wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');

    expect(onChange.mock.calls.length).toBe(0);
  });

  // 對全部的屬性配置進行測試
  // 是否展現頭部左側圖標
  test('hide icon', () => {
    expect(wrapper.find('.qtc-accordion-item-header').at(2).children().length).toBe(2);
  });
  // 自定義圖標
  test('custom icon', () => {
    const customIcon = wrapper.find('.qtc-accordion-item-header').at(3).children().first();

    expect(customIcon.getDOMNode().innerHTML).toBe('custom');
  });
  // 是否可展開多項
  test('single expand', () => {
    onChange = jest.fn();
    wrapper = mount(
      <Accordion multiple={false} onChange={onChange}> <Accordion.Item name='1'>1</Accordion.Item> <Accordion.Item name='2'>2</Accordion.Item> </Accordion>
    );

    wrapper.find('.qtc-accordion-item-header').at(0).simulate('click');
    wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');

    expect(wrapper.find(Accordion).state().activeNames).toEqual(new Set(['2']));
  });
  test('mutiple expand', () => {
    onChange = jest.fn();
    wrapper = mount(
      <Accordion multiple={true} onChange={onChange}> <Accordion.Item name='1'>1</Accordion.Item> <Accordion.Item name='2'>2</Accordion.Item> </Accordion>
    );

    wrapper.find('.qtc-accordion-item-header').at(0).simulate('click');
    wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');

    expect(wrapper.find(Accordion).state().activeNames).toEqual(new Set(['1', '2']));
  });
});
複製代碼

難點記錄

對一些異步和延時的處理

使用單個參數調用 done,而不是將測試放在一個空參數的函數,Jest 會等 done 回調函數執行結束後,結束測試

test('the data is peanut butter', done => {
  function callback(data) {
    try {
      expect(data).toBe('peanut butter');
      done();
    } catch (error) {
      done(error);
    }
  }

  fetchData(callback);
});
複製代碼

模擬 setTimeout

// 提取utils方法,封裝一個sleep
export const sleep = async (timeout = 0) => {
  await act(async () => {
    await new Promise((resolve) => globalTimeout(resolve, timeout));
  });
};

// 測試用例中調用
it('測試用例', async () => {
  doSomething();
  await sleep(1000);
  doSomething();
});
複製代碼

mock 組件內系統函數的返回結果

對於組件內調用了 document 上的方法,能夠經過 mock 指定方法的返回值,來保證一致性

const getBoundingClientRectMock = jest.spyOn(
    HTMLHeadingElement.prototype,
    'getBoundingClientRect',
);

beforeAll(() => {
    getBoundingClientRectMock.mockReturnValue({
        width: 100,
        height: 100,
        top: 1000,
    } as DOMRect);
});

afterAll(() => {
    getBoundingClientRectMock.mockRestore();
});
複製代碼

直接調用組件方法

經過 wrapper.instance()獲取組件實例,再調用組件內方法,如:wrapper.instance().handleScroll() 測試系統方法的調用

const scrollToSpy = jest.spyOn(window, 'scrollTo');

const calls = scrollToSpy.mock.calls.length;

expect(scrollToSpy.mock.calls.length).toBeGreaterThan(calls);
複製代碼

使用屬性匹配器代替時間

當快照有時間時,經過屬性匹配器能夠在快照寫入或者測試前只檢查這些匹配器是否經過,而不是具體的值

it('will check the matchers and pass', () => {
  const user = {
    createdAt: new Date(),
    id: Math.floor(Math.random() * 20),
    name: 'LeBron James',
  };

  expect(user).toMatchSnapshot({
    createdAt: expect.any(Date),
    id: expect.any(Number),
  });
});
複製代碼

附錄

JEST 語法

匹配器

expect:返回一個'指望‘的對象

toBe:使用 object.is 去判斷相等

toEqual:遞歸檢測對象或數組的每一個字段

not:測試相反的匹配

真值

toBeNull:只匹配 null

toBeUndefined:只匹配 undefined

toBeDefined:與 toBeUndefined 相反

toBeTruthy:匹配任何 if 語句爲真

toBeFalsy:匹配任務 if 語句爲假

數字

toBeGreaterThan:大於

toBeGreaterThanOrEqual:大於等於

toBeLessThan:小於

toBeLessThanOrEqual:小於等於

toBeCloseTo:比較浮點數相等

字符串

toMatch:匹配字符串

Array

toContain:檢測一個數組或可迭代對象是否包含某個特定項

例外

toThrow:測試某函數在調用時是否拋出了錯誤

自定義匹配器

// The mock function was called at least once
expect(mockFunc).toHaveBeenCalled();

// The mock function was called at least once with the specified args
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);

// The last call to the mock function was called with the specified args
expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2);

// All calls and the name of the mock is written as a snapshot
expect(mockFunc).toMatchSnapshot();
複製代碼

測試異步代碼

回調

默認狀況下,一旦到達運行上下文底部 Jest 測試當即結束,使用單個參數調用 done,而不是將測試放在一個空參數的函數,Jest 會等 done 回調函數執行結束後,結束測試。

test('the data is peanut butter', (done) => {
  function callback(data) {
    try {
      expect(data).toBe('peanut butter');
      done();
    } catch (error) {
      done(error);
    }
  }

  fetchData(callback);
});
複製代碼

Promises

爲你的測試返回一個 Promise,Jest 會等待 Promise 的 resove 狀態,若是 Promist 被拒絕,則測試將自動失敗

test('the data is peanut butter', () => {
  return fetchData().then((data) => {
    expect(data).toBe('peanut butter');
  });
});
複製代碼

若是指望 Promise 被 Reject,則須要使用 .catch 方法。 請確保添加 expect.assertions 來驗證必定數量的斷言被調用。 不然,一個 fulfilled 狀態的 Promise 不會讓測試用例失敗

test('the fetch fails with an error', () => {
  expect.assertions(1);
  return fetchData().catch(e => expect(e).toMatch('error'));
});
複製代碼

.resolves/.rejects

test('the data is peanut butter', () => {
  return expect(fetchData()).resolves.toBe('peanut butter');
});
test('the fetch fails with an error', () => {
  return expect(fetchData()).rejects.toMatch('error');
});
複製代碼

Async/Await

寫異步測試用例時,能夠再傳遞給 test 的函數前面加上 async。

安裝和移除

爲屢次測試重複設置:beforeEach、afterEach 來爲屢次測試重複設置的工做

一次性設置:beforeAll、afterAll 在文件的開頭作一次設置

做用域:能夠經過 describe 塊將測試分組,before 和 after 的塊在 describe 塊內部時,則只適用於該 describe 塊內的測試

模擬函數

Mock 函數容許你測試代碼之間的鏈接——實現方式包括:擦除函數的實際實現、捕獲對函數的調用(以及在這些調用中傳遞的參數)、在使用 new 實例化時捕獲構造函數的實例、容許測試時配置返回值。

兩種方法能夠模擬函數:1.在測試代碼中建立一個 mock 函數,2.編寫一個手動 mock 來覆蓋模塊依賴

mock 函數

const mockCallback = jest.fn((x) => 42 + x);
forEach([0, 1], mockCallback);

// 此 mock 函數被調用了兩次
expect(mockCallback.mock.calls.length).toBe(2);

// 第一次調用函數時的第一個參數是 0
expect(mockCallback.mock.calls[0][0]).toBe(0);

// 第二次調用函數時的第一個參數是 1
expect(mockCallback.mock.calls[1][0]).toBe(1);

// 第一次函數調用的返回值是 42
expect(mockCallback.mock.results[0].value).toBe(42);
複製代碼

.mock 屬性

全部的 mokc 函數都有這個特殊的.mock 屬性,它保存了關於此函數如何被調用、調用時的返回值的信息。.mock 屬性還追蹤每次調用時的 this 的值,因此咱們一樣能夠檢查 this

// 這個函數被實例化兩次
expect(someMockFunction.mock.instances.length).toBe(2);

// 這個函數被第一次實例化返回的對象中,有一個 name 屬性,且被設置爲了 'test’
expect(someMockFunction.mock.instances[0].name).toEqual('test');
複製代碼

Mock 的返回值

const myMock = jest.fn();
console.log(myMock());
// > undefined

myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);

console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true
複製代碼

模擬模塊

能夠 用 jest.mock(...)函數自動模擬 axios 模塊,一旦模擬模塊,咱們可爲.get 提供一個 mockResolveValue,它會返回假數據用於測試

// users.test.js
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
  const users = [{ name: 'Bob' }];
  const resp = { data: users };
  axios.get.mockResolvedValue(resp);

  // or you could use the following depending on your use case:
  // axios.get.mockImplementation(() => Promise.resolve(resp))

  return Users.all().then((data) => expect(data).toEqual(users));
});
複製代碼

Mock 實現

用 mock 函數替換指定返回值:jest.fn(cb => cb(null, true))

用 mockImplementation 根據別的模塊定義默認的 mock 函數實現:jest.mock('../foo'); const foo = require('../foo');foo.mockImplementation(() => 42);

當你須要模擬某個函數調用返回不一樣結果時,請使用 mockImplementationOnce 方法

.mockReturnThis()函數來支持鏈式調用

Mock 名稱

能夠爲你的 Mock 函數命名,該名字會替代 jest.fn() 在單元測試的錯誤輸出中出現。 用這個方法你就能夠在單元測試輸出日誌中快速找到你定義的 Mock 函數

const myMockFn = jest
  .fn()
  .mockReturnValue('default')
  .mockImplementation((scalar) => 42 + scalar)
  .mockName('add42');
複製代碼

快照測試

當要確保你的 UI 不會又意外的改變時,快照測試是很是有用的工具;典型的作法是在渲染了 UI 組件以後,保存一個快照文件, 檢測他是否與保存在單元測試旁的快照文件相匹配。 若兩個快照不匹配,測試將失敗:有可能作了意外的更改,或者 UI 組件已經更新到了新版本。

快照文件應該和項目代碼一塊兒提交併作代碼評審

更新快照

jest --updateSnapshot/jest -u,這將爲全部失敗的快照測試從新生成快照文件。 若是咱們無心間產生了 Bug 致使快照測試失敗,應該先修復這些 Bug,再生成快照文件;只從新生成一部分的快照文件,你可使用--testNamePattern 來正則匹配想要生成的快照名字

屬性匹配器

項目中經常會有不定值字段生成(例如 IDs 和 Dates),針對這些狀況,Jest 容許爲任何屬性提供匹配器(非對稱匹配器)。 在快照寫入或者測試前只檢查這些匹配器是否經過,而不是具體的值

it('will check the matchers and pass', () => {
  const user = {
    createdAt: new Date(),
    id: Math.floor(Math.random() * 20),
    name: 'LeBron James',
  };

  expect(user).toMatchSnapshot({
    createdAt: expect.any(Date),
    id: expect.any(Number),
  });
});
複製代碼

覆蓋率

Jest 還提供了生成測試覆蓋率報告的命令,只須要添加上 --coverage 這個參數便可生成,再加上--colors 可根據覆蓋率生成不一樣顏色的報告(<50%紅色,50%~80%黃色, ≥80%綠色)

  • % Stmts 是語句覆蓋率(statement coverage):是否每一個語句都執行了
  • % Branch 分支覆蓋率(branch coverage):是否每一個分支代碼塊都執行了(if, ||, ? : )
  • % Funcs 函數覆蓋率(function coverage):是否每一個函數都調用了
  • % Lines 行覆蓋率(line coverage):是否每一行都執行了

Enzyme

nzyme 來自 airbnb 公司,是一個用於 React 的 JavaScript 測試工具,方便你判斷、操縱和歷遍 React Components 輸出。Enzyme 的 API 經過模仿 jQuery 的 API ,使得 DOM 操做和歷遍很靈活、直觀。Enzyme 兼容全部的主要測試運行器和判斷庫。

安裝與配置

  • npm install --save-dev enzyme
  • 安裝 Enzyme Adapter 來對應 React 的版本 npm install --save-dev enzyme-adapter-react-16

渲染方式

shallow 淺渲染

返回組件的淺渲染,對官方 shallow rendering 進行封裝。淺渲染 做用就是:它僅僅會渲染至虛擬 dom,不會返回真實的 dom 節點,這個對測試性能有極大的提高。shallow 只渲染當前組件,只能能對當前組件作斷言

render 靜態渲染

將 React 組件渲染成靜態的 HTML 字符串,而後使用 Cheerio 這個庫解析這段字符串,並返回一個 Cheerio 的實例對象,能夠用來分析組件的 html 結構,對於 snapshot 使用 render 比較合適

mount 徹底渲染

將組件渲染加載成一個真實的 DOM 節點,用來測試 DOM API 的交互和組件的生命週期,用到了 jsdom 來模擬瀏覽器環境

經常使用 API

.simulate(event, mock):用來模擬事件觸發,event 爲事件名稱,mock 爲一個 event object

.instance():返回測試組件的實例

.find(selector):根據選擇器查找節點,selector 能夠是 CSS 中的選擇器,也能夠是組件的構造函數,以及組件的 display name 等

.get(index):返回指定位置的子組件的 DOM 節點

.at(index):返回指定位置的子組件

.first():返回第一個子組件

.last():返回最後一個子組件

.type():返回當前組件的類型

.contains(nodeOrNodes):當前對象是否包含參數重點 node,參數類型爲 react 對象或對象數組

.text():返回當前組件的文本內容

.html():返回當前組件的 HTML 代碼形式

.props():返回根組件的全部屬性

.prop(key):返回根組件的指定屬性

.state([key]):返回根組件的狀態

.setState(nextState):設置根組件的狀態

.setProps(nextProps):設置根組件的屬性

參考

JEST 文檔
Enzyme 文檔

相關文章
相關標籤/搜索