1. 程式人生 > >React躬行記(14)——測試框架

React躬行記(14)——測試框架

  測試不僅可以發現和預防問題,還能降低風險、減少企業損失。在React中,湧現了多種測試框架,本節會對其中的Jest和Enzyme做詳細的講解。

一、Jest

  Jest是由Facebook開源的一個測試框架,可無縫相容React專案,專注簡單,推崇零配置,開箱即用的宗旨,用於邏輯和元件的單元測試。它的語法和斷言與Jasmine類似,並且還集成了快照測試、Mock、覆蓋率報告等功能,支援多程序並行執行測試,在內部使用JSDOM操作DOM,JSDOM是一種模擬的DOM環境,其行為類似於常規瀏覽器,可用來與使用者互動、在節點上派發事件等。

1)執行

  為了便於執行Jest,本文使用Create React App建立專案,命令如下所示。

npx create-react-app my-app

  只要把測試檔案放置在__tests__目錄內,或將它們的名稱新增.test.js或.spec.js字尾,並儲存在專案的src目錄中的任何深度,就能被Jest檢測到。當執行下面的命令時,可得到相關的測試結果。

npm test

  預設情況下,Jest每次只執行與本次更改的檔案相關的測試用例。

2)建立測試

  如果要建立測試用例(Test Case),那麼需要使用test()或it()函式,其第一個引數是測試名稱,第二個引數是包含測試程式碼的回撥函式,如下所示。

test("two plus two is four", () => {
  expect(2 + 2).toBe(4);
});

  expect()函式用於斷言,它能接收一個實際值,並將其作為結果與匹配器中的期望值做比較。如果匹配失敗,那麼就會在控制檯輸出相應的錯誤提示。

  describe()函式可將測試用例進行邏輯分組,其第一個引數可定義分組的名稱,如下所示。

describe("my test case", () => {
  test("one plus one is two", () => {
    expect(1 + 1).toBe(2);
  });
  test("two plus two is four", () => {
    expect(2 + 2).toBe(4);
  });
});

3)匹配器

  通過匹配器(Matcher)可以各種方式來測試程式碼,例如之前示例中的toBe()就是一個匹配器,它使用Object.is()來測試精確匹配,如果要檢查物件是否相等,可改用toEqual(),如下所示。

test("object assignment", () => {
  const data = { name: "strick" };
  data["age"] = 28;
  expect(data).toEqual({ name: "strick", age: 28 });
});

  其它常用的匹配器還有區分undefined、null和布林值、比較數字、匹配字串、檢查陣列或可迭代物件是否包含某個特定項、測試丟擲的錯誤等功能。

  所有的匹配器都可以通過.not取反,例如驗證toBeUndefined()不能匹配null,如下所示。

test("null is not undefined", () => {
  expect(null).not.toBeUndefined();
});

4)非同步測試

  Jest提供了多種方式來測試非同步程式碼,包括回撥函式、Promise和Async/Await,接下來會逐個講解用法。

  (1)預設情況下,Jest測試一旦執行到末尾就會完成,例如有一個check()函式(如下所示),它能接收一個回撥函式,一旦check()執行結束,此測試就會在沒有執行回撥函式前結束。

function check(func) {
  const success = true;
  func(success);
}
test("the data is truth", () => {
  function callback(data) {
    expect(data).toBeTruthy();
  }
  check(callback);
});

  若要解決此問題,可為test()的回撥函式傳遞一個名為done的函式引數,Jest會等done()回撥函式執行完後,再結束測試,如下所示。

test("the data is truth", done => {
  function callback(data) {
    expect(data).toBeTruthy();
    done();
  }
  check(callback);
});

  (2)當非同步程式碼返回Promise物件時,Jest會等待其狀態的變化。如果狀態變為已完成,那麼得使用then()方法;如果狀態變為已拒絕,那麼得使用catch()方法,如下所示。

//狀態為已完成
function checkResolve() {
  return new Promise((resolve, reject) => {
    resolve(true);
  });
}
test("the data is truth", () => {
  return checkResolve().then(data => {
    expect(data).toBeTruthy();
  });
});
//狀態為已拒絕
function checkReject() {
  return new Promise((resolve, reject) => {
    reject(false);
  });
}
test("the data is falsity", () => {
  return checkReject().catch(data => {
    expect(data).toBeFalsy();
  });
});

  注意,要將Promise物件作為test()的回撥函式的返回值,以免測試提前完成,導致沒有進行方法鏈中的斷言。

  在expect語句中也可以使用.resolves或.rejects兩種匹配器來處理Promise的兩種狀態,如下所示,語法更為簡潔。

test("the data is truth", () => {
  expect(checkResolve()).resolves.toBeTruthy();
});
test("the data is falsity", () => {
  expect(checkReject()).rejects.toBeFalsy();
});

  (3)在測試中使用async和await兩個關鍵字,也可以匹配Promise物件,例如斷言checkResolve()的處理結果,如下所示。

test("the data is truth", async () => {
  const data = await checkResolve();
  expect(data).toBeTruthy();
});

  它們也能用來測試已拒絕狀態的Promise,如下所示,其中assertions()用於驗證在測試中是否執行了指定數量的斷言。

function checkError() {
  return new Promise((resolve, reject) => {
    reject();
  }).catch(() => {
    throw "error";
  });
}
test("the check fails with an error", async () => {
  expect.assertions(1);
  try {
    await checkError();
  } catch (e) {
    expect(e).toMatch("error");
  }
});

  aysnc和awiat還可以與.resolves或.rejects結合使用,如下所示。

test("the data is truth", async () => {
  await expect(checkResolve()).resolves.toBeTruthy();
});
test("the check fails with an error", async () => {
  await expect(checkError()).rejects.toMatch("error");
});

5)輔助函式

  有時候,在執行測試前需要做些準備工作,而在執行測試之後又需要做些整理工作,Jest提供了四個相關的輔助函式來處理這兩類工作,如下所列。

  (1)beforeAll()和afterAll()會在所有測試用例之前和之後執行一次。

  (2)beforeEach()和afterEach()會在每個測試用例之前和之後執行,並且可以像非同步測試那樣處理非同步程式碼。

  假設在四個輔助函式中輸出各自的函式名稱,並且有兩個測試用例,如下程式碼所示。

beforeAll(() => {
  console.log("beforeAll");
});
afterAll(() => {
  console.log("afterAll");
});
beforeEach(() => {
  console.log("beforeEach");
});
afterEach(() => {
  console.log("afterEach");
});

test("first", () => {
  expect(2).toBeGreaterThan(1);
});
test("second", () => {
  expect(2).toBeLessThan(3);
});

  每次執行測試,在控制檯將依次打印出“beforeAll”,兩對“beforeEach”和“afterEach”,“afterAll”。

  當通過describe()對測試用例進行分組時(如下所示),外部的beforeEach()和afterEach()會優先執行。

describe("scoped", () => {
  beforeEach(() => console.log("inner beforeEach"));
  afterEach(() => console.log("inner afterEach"));
  test("third", () => {
    expect([1, 2]).toContain(1);
  });
});

6)Mock

  Jest內建了Mock函式,可用於擦除函式的實際實現來測試程式碼之間的連線,捕獲函式的呼叫和引數、配置其返回值等。

  假設要測試一個自定義的forEach()函式的內部實現,那麼可以使用jest.fn()建立一個Mock函式,然後通過檢查它的mock屬性來確保回撥函式是否在按預期呼叫,如下所示。

function forEach(items, callback) {
  for (let index = 0; index < items.length; index++) {
    callback(items[index]);
  }
}
test("forEach", () => {
  const mockFunc = jest.fn(x => 42 + x);
  forEach([0, 1], mockFunc);
  expect(mockFunc.mock.calls.length).toBe(2);            //此Mock函式被呼叫了兩次
  expect(mockFunc.mock.calls[0][0]).toBe(0);             //第一次呼叫函式時的第一個引數是0
  expect(mockFunc.mock.calls[1][0]).toBe(1);             //第二次呼叫函式時的第一個引數是1
  expect(mockFunc.mock.results[0].value).toBe(42);       //第一次函式呼叫的返回值是42
});

  每個Mock函式都會包含一個特殊的mock屬性,記錄了函式如何被呼叫、呼叫時的返回值等資訊,通過該屬性還能追蹤每次呼叫時的this的值。如果要用Mock函式注入返回值,那麼可以像下面這樣鏈式的新增,首次呼叫返回10,第二次呼叫返回“x”,接下來的呼叫都返回true。其中mockName()方法可為Mock函式命名,該名稱將在輸出的日誌中顯示,可替換掉預設的“jest.fn()”。

const myMock = jest.fn().mockName("returnValue");
myMock
  .mockReturnValueOnce(10)
  .mockReturnValueOnce("x")
  .mockReturnValue(true);
console.log(myMock(), myMock(), myMock(), myMock());     //10, 'x', true, true

  Mock函式還可以模擬模組,例如攔截axios請求得到的資料,如下程式碼所示,為.get提供了一個mockResolvedValue()方法,它會返回用於測試的假資料。

import axios from "axios";
jest.mock("axios");
class Users {
  static all() {
    return axios.get("./users.json").then(resp => resp.data);
  }
}
test("should fetch users", () => {
  const users = [{ name: "strick" }];
  const resp = { data: users };
  axios.get.mockResolvedValue(resp);
  return Users.all().then(data => expect(data).toEqual(users));
});

  原生的定時器函式測試起來並不方便,通過jest.useFakeTimers()可以模擬定時器函式,如下所示。

function timerGame() {
  setTimeout(() => {
    console.log("start");
  }, 1000);
}
jest.useFakeTimers();
test("setTimeout", () => {
  timerGame();
  expect(setTimeout).toHaveBeenCalledTimes(1);                               //呼叫了1次
  expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000);        //1秒後執行回撥
});

  Jest模擬出的定時器函式還有快進到正確的時間點、執行當前正在等待的定時器等功能。

7)快照測試

  Jest提供的快照測試(Spapshot Testing)是一種高效的UI測試,它會將React元件序列化成純文字(即快照)並儲存在硬碟中,每次測試就把當前生成的快照與儲存的快照進行對比,接下來用一個例子來介紹快照測試的用法。

  首先建立一個Link元件,它會渲染出一條包含onMouseEnter事件的連結,當滑鼠移動到這條連結時,會改變它的class屬性。

import React from "react";
const STATUS = {
  HOVERED: "hovered",
  NORMAL: "normal"
};
export default class Link extends React.Component {
  constructor(props) {
    super(props);
    this._onMouseEnter = this._onMouseEnter.bind(this);
    this.state = {
      class: STATUS.NORMAL
    };
  }
  _onMouseEnter() {
    this.setState({ class: STATUS.HOVERED });
  }
  render() {
    return (
      <a
        href="#"
        className={this.state.class}
        onMouseEnter={this._onMouseEnter}
      >
        {this.props.children}
      </a>
    );
  }
}

  然後建立測試檔案spapshot.test.js,在其內部,除了要引入Link元件之外,還得引入react-test-renderer,它不依賴瀏覽器和JSDOM,可將React元件渲染成JavaScript物件(即快照)。

import React from "react";
import Link from "./Link";
import renderer from "react-test-renderer";

test("Link changes the class when hovered", () => {
    const component = renderer.create(<Link>Strick</Link>);
    let tree = component.toJSON();
    expect(tree).toMatchSnapshot();

    tree.props.onMouseEnter();         //觸發事件
    tree = component.toJSON();         //重新渲染
    expect(tree).toMatchSnapshot();
});

  在第一次執行測試時,會自動建立__snapshots__目錄,放置對應的快照檔案spapshot.test.js.snap,其內容如下所示,包含兩張快照,第二張是觸發onMouseEnter事件後生成的。

exports[`Link changes the class when hovered 1`] = `
<a
  className="normal"
  href="#"
  onMouseEnter={[Function]}
>
  Strick
</a>
`;

exports[`Link changes the class when hovered 2`] = `
<a
  className="hovered"
  href="#"
  onMouseEnter={[Function]}
>
  Strick
</a>
`;

  如果要重新整理儲存的快照,除了手動刪除之外,還可以通過jest -u命令實現。

二、Enzyme

  Enzyme是一款用於React元件的測試框架,可處理渲染出的DOM結構,開放的API類似於jQuery的語法,提供了三種不同的方式來測試元件:淺層渲染(Shallow Rendering)、完全渲染(Full Rendering)和靜態渲染(Static Rendering)。從Enzyme 3開始,在安裝Enzyme的同時,還需要安裝與React版本相對應的介面卡,命令如下所示。

npm install --save enzyme enzyme-adapter-react-16

1)淺層渲染

  獨立於DOM的淺層渲染只會渲染React元件的第一層,它會忽略子元件的行為,也就沒必要渲染子元件了,這提供了更好的隔離性。不過淺層渲染也有它侷限性,即不支援Refs。

  以上一節中的Link元件為例,在進行Enzyme之前,需要先通過configure()函式配置介面卡,然後才能通過shallow()函式淺渲染Link元件,如下所示。

import React from "react";
import { shallow, configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import Link from "../component/Form/Link";

configure({ adapter: new Adapter() });
test("Link changes the class after mouseenter", () => {
  const wrapper = shallow(<Link>Strick</Link>),
    a = wrapper.find("a");
  expect(wrapper.text()).toEqual("Strick");
  a.simulate("mouseenter");                         //觸發事件
  expect(a.prop("className")).toEqual("normal");       //匹配樣式
});

  wrapper是一個虛擬的DOM物件,它包含多個操作DOM的方法,例如find()可根據選擇器找到指定的節點,simulate()可觸發當前節點的事件。

2)完全渲染

  mount()函式會完全渲染接收的元件,即它的子元件也會被渲染。完全渲染依賴JSDOM,當多個測試處理同一個DOM時,可能會相互影響,因此在測試結束後需要使用unmount()方法解除安裝元件。

3)靜態渲染

  render()函式會靜態渲染元件,也就是將它渲染成HTML字串,再通過Cheerio庫解析該HTML結構。Cheerio類似於JSDOM,但更輕量,可像jQuery那樣操作字串。

&n