React 測試指南

前端測試金字塔

對於一個 Web 應用來說,理想的測試組合應該包含大量單元測試(unit tests),部分快照測試(snapshot tests),以及少量端到端測試(e2e tests)。參考測試金字塔,我們構建了前端應用的測試金字塔。

image.png

單元測試

針對程序模塊進行測試。模塊是軟件設計中的最小單位,一個函數或者一個 React 組件都可以稱之爲一個模塊。單元測試運行快,反饋週期短,在短時間內就能夠知道是否破壞了代碼,因此在測試組合中佔據了絕大部分。

快照測試

對組件的 UI 進行測試。傳統的快照測試會拍攝組件的圖片,並且將它和之前的圖片進行對比,如果兩張圖片不匹配則測試失敗。Jest 的快照測試不會拍攝圖片,而是將 React 樹序列化成字符串,通過比較兩個字符串來判斷 UI 是否改變。因爲是純文本的對比,所以不需要構建整個應用,運行速度自然比傳統快照測試更快。

E2E 測試
相當於黑盒測試。測試者不需要知道程序內部是如何實現的,只需要根據業務需求,模擬用戶的真實使用場景進行測試。

技術選型

測試種類 技術選型
單元測試 Jest + Enzyme
快照測試 Jest
E2E 測試 jest-puppeteer

Jest 是 Facebook 開源的測試框架。它的功能很強大,包含了測試執行器、斷言庫、spy、mock、snapshot 和測試覆蓋率報告等。


Enzyme 是 Airbnb 開源的 React 單元測試工具。它擴展了 React 官方的 TestUtils,通過類 jQuery 風格的 API 對 DOM 進行處理,減少了很多重複代碼,可以很方便的對渲染出來的結果進行斷言。


jest-p[uppeteer]() 是一個同時包含 Jest 和 Puppeteer 的工具。Puppeteer 是谷歌官方提供的 Headless Chrome Node API,它提供了基於 DevTools Protocol 的上層 API 接口,用來控制 Chrome 或者 Chromium。有了 Puppeteer,我們可以很方便的進行端到端測試。

React 測試策略

測試本質上是對代碼的保護,保證項目在迭代的過程中正常運行。當然,寫測試也是有成本的,特別是複雜邏輯,寫測試花的時間,可能不比寫代碼少。所以我們要制定合理的測試策略,有針對性的去寫測試。至於哪些代碼要測,哪些代碼不測,總的來說遵循一個原則:投入低,收益高。「投入低」是指測試容易寫,「收益高」是測試的價值高。換句話說,就是指測試應該優先保證核心代碼邏輯,比如核心業務、基礎模塊、基礎組件等,同時,編寫測試和維護測試的成本也不宜過高。當然,這是理想情況,在實際的開發過程中還是要進行權衡。

單元測試

基於 React 和 Redux 項目的特點,我們制定了下面的測試策略:

分類 哪些要測? 哪些不測?
組件 有條件渲染的組件(如 if-else 分支,聯動組件,權限控制組件等)
有用戶交互的組件(如 Click、提交表單等)
* 邏輯組件(如高階組件和 Children Render 組件)
connect 生成的容器組件
純組合子組件的 Page 組件
純展示的組件
組件樣式
Reducer 有邏輯的 Reducer。如合併、刪除  state。 純取值的 reducer 不測。比如
(_, action) => action.payload.data 
Middleware 全測
Action Creator 全不測
方法 validators
formatters
* 其他公有方法
私有方法
公用模塊 全測。比如處理 API 請求的模塊。
Note: 如果使用了 TypeScript,類型約束可以替代部分函數入參和返回值類型的檢查。

快照測試

Jest 的 snapshot 測試雖然運行起來很快,也能夠起到一定保護 UI 的作用。但是它維護起來很困難(大量依賴人工對比),並且有時候不穩定(UI 無變化但 className 變化仍然會導致測試失敗)。因此,個人不推薦在項目中使用。但是爲了應付測試覆蓋率,以及「給自己信心」,也可以給以下部分添加 snapshot 測試:

  • Page 組件:一個 page 對應一個 snapshot。
  • 純展示的公用 UI 組件。

快照測試可以等整個 Page 或者 UI 組件構建完成之後再添加,以保證穩定。

E2E 測試

覆蓋核心的業務 flow。

一個好的單元測試應該具備的條件?

安全重構已有代碼

單元測試一個很重要的價值是爲重構保駕護航。當輸入不變時,當且僅當「被測業務代碼功能被改動了」時,測試才應該掛掉。也就是說,無論怎麼重構,測試都不應該掛掉。

在寫組件測試時,我們常常遇到這樣的情況:用 css class 選擇器選中一個節點,然後對它進行斷言,那麼即使業務邏輯沒有發生變化,重命名這個 class 時也會使測試掛掉。理論上來說,這樣的測試並不算一個「好的測試」,但是考慮到它的業務價值,我們還是會寫一些這樣的測試,只不過寫測試的時候需要注意:使用一些不容易發生變化的選擇器,比如 component name、arial-label 等。

保存業務上下文

我們經常說測試即文檔,沒錯,一個好的測試往往能夠非常清晰的表單業務或代碼的含義。

快速回歸

快速回歸是指測試運行速度快,且穩定。要想運行速度快,很重要的一點是 mock 好外部依賴。至於怎麼具體怎麼 mock 外部依賴,後面會詳細說明。

單元測試怎麼寫?

定義測試名稱

建議採用 BDD 的方式,即測試要接近自然語言,方便團隊中的各個成員進行閱讀。編寫測試用例的時候,可以參考 AC,試着將 AC 的 Give-When-Then 轉化成測試用例。

GIVEN: 準備測試條件,比如渲染組件。
WHEN:在某個具體的場景下,比如點擊 button。
THEN:斷言

describe("add user", () => {
  it("when I tap add user button, expected dialog opened with 3 form fields", () => {
    // Given: in profile page. 
    // Prepare test env, like render component etc.
    
    // When: button click. 
    // Simulate button click
    
    // Then: display `add user` form, which contains username, age and phone number.
    // Assert form fields length to equal 3
  });
});

Mock 外部依賴

單元測試的一個重要原則就是無依賴和隔離。也就是說,在測試某部分代碼時,我們不期望它受到其他代碼的影響。如果受到外部因素影響,測試就會變得非常複雜且不穩定。

我們寫單元測試時,遇到的最大問題就是:代碼過於複雜。比如當頁面有 API 請求、日期、定時器或 redux conent 時,寫測試就變得異常困難,因爲我們需要花大量時間去隔離這些外部依賴。

隔離外部依賴需要用到測試替代方法,常見的有 spies、stubs 和 mocks。很多測試框架都實現了這三種方法,比如著名的 Jest 和 Sinon。這些方法可以幫助我們在測試中替換代碼,減少測試編寫的複雜度。

spies

spies 本質上是一個函數,它可以記錄目標函數的調用信息,如調用次數、傳參、返回值等等,但不會改變原始函數的行爲。Jest 中的 mock function 就是 spies,比如我們常用的 jest.fn() 。

// Example:
onSubmit() {
  // some other logic here
  this.props.dispatch("xxx_action");
}

// Example Test:
it("when form submit, expected dispatch function to be called", () => {
  const mockDispatch = jest.fn();
  
  mount(<SomeComp dispatch={mockDispatch}/>);
  // simlate submit event here 
  expect(mockDispatch).toBeCalledWith("xxx_action");
  expect(mockDispatch).toBeCalledTimes(1);
});

spies 還可以用於替換屬性方法、靜態方法和原型鏈方法。由於這種修改會改變原始對象,使用之後必須調用 restore 方法予以還原,因此使用的時候要特別小心。

// Example:
const video = {
  play() {
    return true;
  },
};

// Example Test:
test('plays video', () => {
  const spy = jest.spyOn(video, 'play');
  const isPlaying = video.play();

  expect(spy).toHaveBeenCalled();
  expect(isPlaying).toBe(true);

  spy.mockRestore();
});

stubs

stubs 跟 spies 類似,但與 spies 不同的是,stubs 會替換目標函數。也就是說,如果使用 spies,原始的函數依然會被調用,但使用 stubs,原始的函數就不會被執行了。stubs 能夠保證明確的測試邊界。它可以用於以下場景:

  • 替換讓測試變得複雜或慢的外部函數,如 ajax。
  • 測試異常條件,如拋出異常。

Jest 中也提供了類似的 API [](https://jestjs.io/docs/en/jes...[]()jest.spyOn().mockImplementation(),如下:

const spy = jest.fn();
const payload = [1, 2, 3];

jest
  .spyOn(jQuery, "ajax")
  .mockImplementation(({ success }) => success(payload));

jQuery.ajax({
  url: "https://example.api",
  success: data => spy(data)
});

expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(payload);

mocks

mocks 是指用自定義對象代替目標對象。我們不僅可以 mock API 返回值和自定義類,還可以 mock npm 模塊等等。

// mock middleware api
const mockMiddlewareAPI = {
  dispatch: jest.fn(),
  getState: jest.fn(),
};

// mock npm module `config`
jest.mock("config", () => {
  return {
    API_BASE_URL: "http://base_url",
  };
});

使用 mocks 時,需要注意:

  • 如果 mock 了某個模塊的依賴,需要等 mock 完成了之後再 require 這個模塊。

有如下代碼:

// counter.ts
let count = 0;

export const get = () => count;
export const inc = () => count++;
export const dec = () => count--;

錯誤做法:

// counter.test.ts
import * as counter from "../counter";

describe("counter", () => {
  it("get", () => {
    jest.mock("../counter", () => ({
      get: () => "mock count",
    }));
    expect(counter.get()).toEqual("mock count"); // 測試失敗,此時的 counter 模塊並非 mock 之後的模塊。
  });
});

正確做法:

describe("counter", () => {
  it("get", () => {
    jest.mock("../counter", () => ({
      get: () => "mock count",
    }));
    const counter = require("../counter"); // 這裏的 counter 是 mock 之後的 counter
    expect(counter.get()).toEqual("mock count"); // 測試成功
  });
});
  • 多個測試有共享狀態時,每次測試完成之後需要重置模塊 jest.resetModules() 。它會清空所有 required 模塊的緩存,保證模塊之間的隔離。

錯誤的做法:

describe("counter", () => {
  it("inc", () => {
    const counter = require("../counter");
    counter.inc();
    expect(counter.get()).toEqual(1);
  });

  it("get", () => {
    const counter = require("../counter"); // 這裏的 counter 和上一個測試中的 counter 是同一份拷貝
    expect(counter.get()).toEqual(0); // 測試失敗
    console.log(counter.get()); // ? 輸出: 1
  });
});

正確的做法:

describe("counter", () => {
  afterEach(() => {
    jest.resetModules(); // 清空 required modules 的緩存
  });
  
  it("inc", () => {
    const counter = require("../counter");
    counter.inc();
    expect(counter.get()).toEqual(1);
  });

  it("get", () => {
    const counter = require("../counter"); // 這裏的 counter 和上一個測試中的 counter 是不同的拷貝
    expect(counter.get()).toEqual(0); // 測試成功
    console.log(counter.get()); // ? 輸出: 0
  });
});

修改代碼,從一個外部模塊 defaultCount 中獲取 count 的默認值。

// defaultCount.ts
export const defaultCount = 0;

// counter.ts
import {defaultCount} from "./defaultCount";

let count = defaultCount;

export const inc = () => count++;
export const dec = () => count--;
export const get = () => count;

測試代碼:

import * as counter from "../counter"; // 首次導入 counter 模塊
console.log(counter); 

describe("counter", () => {
  it("inc", () => {
    jest.mock("../defaultCount", () => ({
      defaultCount: 10,
    }));
    const counter1 = require("../counter"); // 再次導入 counter 模塊
    
    counter1.inc();
    
    expect(counter1.get()).toEqual(11); // 測試失敗
    console.log(counter1.get()); // 輸出: 1
  });
});

再次 require counter 時,發現模塊已經被 require 過了,就直接從緩存中獲取,所以 counter1 使用的還是counter 的上下文,也就是 defaultCount = 0。而調用 resetModules() 會清空 cache,重新調用模塊函數。

在上面的代碼中,註釋掉 1,2 行,測試也會成功。大家可以想想爲什麼?

編寫測試

組件測試

渲染組件

要對組件進行測試,首先要將組件渲染出來。Enzyme 提供了三種渲染方式: 淺渲染、全渲染以及靜態渲染。

淺渲染(Shallow Render)

shallow 方法會把組件渲染成 Virtual DOM 對象,只會渲染組件中的第一層,不會渲染它的子組件,因此不需要關心 DOM 和執行環境,測試的運行速度很快。

淺渲染對上層組件非常有用。上層組件往往包含很多子組件(比如 App 或 Page 組件),如果將它的子組件全部渲染出來,就意味着上層組件的測試要依賴於子組件的行爲,這樣不僅使測試變得更加困難,也大大降低了效率,不符合單元測試的原則。

淺渲染也有天生的缺點,因爲它只能渲染一級節點。如果要測試子節點,又不想全渲染怎麼辦呢?shallow 還提供了一個很好用的接口 .dive,通過它可以獲取 wrapper 子節點的 React DOM 結構。

示例代碼:

export const Demo = () => (
  <CompA>
    <Container><List /></Container>
  </CompA>
);

使用 shallow 後得到如下結構:

<CompA>
  <Container />
</CompA>

使用 .dive() 後得到如下結構:

<div>
  <Container>
      <List />
  </Container>
</div>
全渲染(Full DOM Render)

mount 方法會把組件渲染成真實的 DOM 節點。如果你的測試依賴於真實的 DOM 節點或者子組件,那就必須使用 mount 方法。特別是大量使用 Child Render 的組件,很多時候測試會依賴 Child Render 裏面的內容,因此需要需要用全渲染,將子組件也渲染出來。

全渲染方式需要瀏覽器環境,不過 Jest 已經提供了,它的默認的運行環境 jsdom ,就是一個 JavaScript 瀏覽器環境。需要注意的是,如果多個測試依賴了同一個 DOM,它們可能會相互影響,因此在每個測試結束之後,最好使用 .unmount() 進行清理。

靜態渲染(Static Render)

將組件渲染成靜態的 HTML 字符串,然後使用 Cheerio 對其進行解析,返回一個 Cheerio 實例對象,可以用來分析組件的 HTML 結構。

測試條件渲染

我們常常會用到條件渲染,也就是在滿足不同條件時,渲染不同組件。比如:
 

import React, { ReactNode } from "react";

const Container = ({ children }: { children: ReactNode }) => <div aria-label="container">{children}</div>;
const CompA = ({ children }: { children: ReactNode }) => <div>{children}</div>;
const List = () => <div>List Component</div>;

interface IDemoListProps {
  list: string[];
}

export const DemoList = ({ list }: IDemoListProps) => (
  <CompA>
    <Container>{list.length > 0 ? <List /> : null}</Container>
  </CompA>
);

對於條件渲染,這裏提供了兩種思路:

  • 測試是否渲染了正確節點

一般的做法是將 DemoList 組件渲染出來,再根據不同的條件,去檢查是否渲染出了正確的節點。

describe("DemoList", () => {
  it("when list length is more than 0, expected to render List component", () => {
    const wrapper = shallow(<DemoList list={["A", "B", "C"]} />);
    expect(
      wrapper
        .dive()
        .find("List")
        .exists(),
    ).toBe(true);
  });

  it("when list length is more than 0, expected to render null", () => {
    const wrapper = shallow(<DemoList list={[]} />);
    expect(
      wrapper
        .dive()
        .find("[aria-label='container']")
        .children().length,
    ).toBe(0);
  });
});
  • 公用組件 + 只測判斷條件

我們可以抽象一個公用組件 <Show/> ,用於所有條件渲染的組件。這個組件接受一個 condition ,當滿足這個 condition 時顯示某個節點,不滿足時顯示另一個節點。

<Show condition={}  ifNode={} elseNode={} />

我們可以爲這個組件添加測試,確保在不同的條件下顯示正確的節點。既然這個邏輯得已經得到了保證,使用 <Show/> 組件的地方就無需再次驗證。因此我們只需要測試是否正確生成了 condition 即可。

export const shouldShowBtn = (a: string, b: string, c: string) => a === b || b === c;
describe("should show button or not", () => {
  it("should show button", () => {
    expect(shouldShowBtn("x", "x", "x")).toBe(true);
  });
  it("should hide button", () => {
    expect(shouldShowBtn("x", "y", "z")).toBe(false);
  });
});

對於有權限控制的組件,一個小的配置改變也會導致整個渲染的不同,而且人工測試很難發現,這種配置多一個 prop 檢查會讓代碼更加安全。

測試用戶交互

常見的有點擊事件、表單提交、validate 等。

  • 點擊事件 click。
  • onSubmit 。主要是測試 onSubmit 方法被調用之後是否發生了正確的行爲,如 dispatch action 。
  • validate 。 主要是測試 error message 是否按正確的順序顯示。

Action Creator 測試

action creator 的實現和測試都非常簡單,這裏就不舉例了。但要注意的是,不要將計算邏輯放到 aciton creator 中。

錯誤的方式:

// action.ts
export const getList = createAction("@@list/getList", (reqParams: any) => {
  const params = formatReqParams({
    ...reqParams,
    page: reqParams.page + 1,
    startDate: formatStartDate(reqParams.startDate)
    endDate: formatStartDate(reqParams.endDate)
  });
  
  return {
    url: "/api/list",
    method: "GET",
    params,
  };
});

正確的方式:

// action.ts
export const getList = createAction("@@list/getList", (params: any) => {
  return {
    url: "/api/list",
    method: "GET",
    params,
  };
});

// 調用 action creator 時,先把值計算好,再傳給 action creator。

// utils.ts
const formatReqParams = (reqParams: any) => {
return formatReqParams({
    ...reqParams,
    page: reqParams.page + 1,
    startDate: formatStartDate(reqParams.startDate)
    endDate: formatStartDate(reqParams.endDate)
  });
};

// page.ts
getFeedbackList(formatReqParams({}));

Reducer 測試

Reducer 測試主要是測試「根據 Action 和 State 是否生成了正確的 State」。因爲 reducer 是純函數,所以測試非常好寫,這裏就不細講了。 

Middleware 測試

測試 middleware 最重要的就是 mock 外部依賴,其中包括 middlewareAPI 和 next 。

Test Helper:

class MiddlewareTestHelper {
  static of(middleware: any) {
    return new MiddlewareTestHelper(middleware);
  }

  constructor(private middleware: Middleware) {}

  create() {
    const middlewareAPI = {
      dispatch: jest.fn(),
      getState: jest.fn(),
    };
    const next = jest.fn();
    const invoke$ = (action: any) => this.middleware(middlewareAPI)(next)(action);

    return {
      middlewareAPI,
      next,
      invoke$,
    };
  }
}

Example Test:

it("should handle the action", () => {
  const { next, invoke$ } = MiddlewareTestHelper.of(testMiddleware()).create();
  invoke$({
    type: "SOME_ACTION",
    payload: {},
  });
  expect(next).toBeCalled();
});

測試異步代碼

默認情況下,一旦到達運行上下文底部,jest測試立即結束。爲了解決這個問題,我們可以使用:

  • done() 回調函數
  • return promise
  • async/await

錯誤的方式:

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

  fetchData(callback);
});

正確的方式:

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

  fetchData(callback);
});
test('the data is peanut butter', () => {
  expect.assertions(1);
  return fetchData().then(data => {
    expect(data).toBe('peanut butter');
  });
});
test("the data is peanut butter", async () => {
  const data = await fetchData();
  expect(data).toBe("peanut butter");
});

執行測試

採用「紅 - 綠」的方式,即先讓測試失敗,再修改代碼讓測試通過,以確保斷言被執行。

快照測試怎麼寫?

通過 redux-mock-store,將組件需要的全部數據準備好(給 mock store 準備 state),再進行測試。

從測試的角度反思應用設計

「好測試」的前提是要有「好代碼」。因此我們可以從測試的角度去反思整個應用的設計,讓組件的「可測試性」更高。

  • 單一職責。 一個組件只幹一類事情,降低複雜度。只要每個小的部分能夠被正確驗證,組合起來能夠完成整體功能,那麼測試的時候,只需要專注於各個小的部分即可。
  • 良好的複用。 即複用邏輯的同時,也複用了測試。
  • 保證最小可用,再逐漸增加功能。 也就是我們平時所說的 TDD。
  • ...

Debug

console.log(wrapper.debug());

參考文章 

譯-Sinon入門:利用Mocks,Spies和Stubs完成javascript測試
使用Jest進行React單元測試
對 React 組件進行單元測試
How to Rethink Your Testing
使用Enzyme測試React(Native)組件
Node.js模塊化機制原理探究
單元測試的意義、做法、經驗
React 單元測試策略及落地 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章