React 測試指南

橘子小睿發表於2019-01-29

前端測試金字塔

對於一個 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-puppeteer 是一個同時包含 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 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 單元測試策略及落地

相關文章