對於一個 Web 應用來講,理想的測試組合應該包含大量單元測試(unit tests),部分快照測試(snapshot tests),以及少許端到端測試(e2e tests)。參考測試金字塔,咱們構建了前端應用的測試金字塔。javascript
單元測試css
針對程序模塊進行測試。模塊是軟件設計中的最小單位,一個函數或者一個 React 組件均可以稱之爲一個模塊。單元測試運行快,反饋週期短,在短期內就可以知道是否破壞了代碼,所以在測試組合中佔據了絕大部分。html
快照測試前端
對組件的 UI 進行測試。傳統的快照測試會拍攝組件的圖片,而且將它和以前的圖片進行對比,若是兩張圖片不匹配則測試失敗。Jest 的快照測試不會拍攝圖片,而是將 React 樹序列化成字符串,經過比較兩個字符串來判斷 UI 是否改變。由於是純文本的對比,因此不須要構建整個應用,運行速度天然比傳統快照測試更快。java
E2E 測試
至關於黑盒測試。測試者不須要知道程序內部是如何實現的,只須要根據業務需求,模擬用戶的真實使用場景進行測試。node
測試種類 | 技術選型 |
---|---|
單元測試 | 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
測試本質上是對代碼的保護,保證項目在迭代的過程當中正常運行。固然,寫測試也是有成本的,特別是複雜邏輯,寫測試花的時間,可能不比寫代碼少。因此咱們要制定合理的測試策略,有針對性的去寫測試。至於哪些代碼要測,哪些代碼不測,總的來講遵循一個原則:投入低,收益高。「投入低」是指測試容易寫,「收益高」是測試的價值高。換句話說,就是指測試應該優先保證核心代碼邏輯,好比核心業務、基礎模塊、基礎組件等,同時,編寫測試和維護測試的成本也不宜太高。固然,這是理想狀況,在實際的開發過程當中仍是要進行權衡。git
基於 React 和 Redux 項目的特色,咱們制定了下面的測試策略:github
分類 | 哪些要測? | 哪些不測? |
---|---|---|
組件 | * 有條件渲染的組件(如 if-else 分支,聯動組件,權限控制組件等) * 有用戶交互的組件(如 Click、提交表單等) * 邏輯組件(如高階組件和 Children Render 組件) |
* connect 生成的容器組件 * 純組合子組件的 Page 組件 * 純展現的組件 * 組件樣式 |
Reducer | 有邏輯的 Reducer。如合併、刪除 state。 | 純取值的 reducer 不測。好比(_, action) => action.payload.data |
Middleware | 全測 | 無 |
Action Creator | 無 | 全不測 |
方法 | * validators * formatters * 其餘公有方法 |
私有方法 |
公用模塊 | 全測。好比處理 API 請求的模塊。 | 無 |
Note: 若是使用了 TypeScript,類型約束能夠替代部分函數入參和返回值類型的檢查。ajax
Jest 的 snapshot 測試雖然運行起來很快,也可以起到必定保護 UI 的做用。可是它維護起來很困難(大量依賴人工對比),而且有時候不穩定(UI 無變化但 className 變化仍然會致使測試失敗)。所以,我的不推薦在項目中使用。可是爲了應付測試覆蓋率,以及「給本身信心」,也能夠給如下部分添加 snapshot 測試:
快照測試能夠等整個 Page 或者 UI 組件構建完成以後再添加,以保證穩定。
覆蓋核心的業務 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
});
});
複製代碼
單元測試的一個重要原則就是無依賴和隔離。也就是說,在測試某部分代碼時,咱們不指望它受到其餘代碼的影響。若是受到外部因素影響,測試就會變得很是複雜且不穩定。
咱們寫單元測試時,遇到的最大問題就是:代碼過於複雜。好比當頁面有 API 請求、日期、定時器或 redux conent 時,寫測試就變得異常困難,由於咱們須要花大量時間去隔離這些外部依賴。
隔離外部依賴須要用到測試替代方法,常見的有 spies、stubs 和 mocks。不少測試框架都實現了這三種方法,好比著名的 Jest 和 Sinon。這些方法能夠幫助咱們在測試中替換代碼,減小測試編寫的複雜度。
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 跟 spies 相似,但與 spies 不一樣的是,stubs 會替換目標函數。也就是說,若是使用 spies,原始的函數依然會被調用,但使用 stubs,原始的函數就不會被執行了。stubs 可以保證實確的測試邊界。它能夠用於如下場景:
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 是指用自定義對象代替目標對象。咱們不只能夠 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 時,須要注意:
有以下代碼:
// 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
方法會把組件渲染成 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>
複製代碼
mount
方法會把組件渲染成真實的 DOM 節點。若是你的測試依賴於真實的 DOM 節點或者子組件,那就必須使用 mount 方法。特別是大量使用 Child Render 的組件,不少時候測試會依賴 Child Render 裏面的內容,所以須要須要用全渲染,將子組件也渲染出來。
全渲染方式須要瀏覽器環境,不過 Jest 已經提供了,它的默認的運行環境 jsdom ,就是一個 JavaScript 瀏覽器環境。須要注意的是,若是多個測試依賴了同一個 DOM,它們可能會相互影響,所以在每一個測試結束以後,最好使用 .unmount()
進行清理。
將組件渲染成靜態的 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 等。
onSubmit
。主要是測試 onSubmit
方法被調用以後是否發生了正確的行爲,如 dispatch action 。validate
。 主要是測試 error message 是否按正確的順序顯示。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 測試主要是測試「根據 Action 和 State 是否生成了正確的 State」。由於 reducer 是純函數,因此測試很是好寫,這裏就不細講了。
測試 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測試當即結束。爲了解決這個問題,咱們可使用:
錯誤的方式:
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),再進行測試。
「好測試」的前提是要有「好代碼」。所以咱們能夠從測試的角度去反思整個應用的設計,讓組件的「可測試性」更高。
console.log(wrapper.debug());
複製代碼
譯-Sinon入門:利用Mocks,Spies和Stubs完成javascript測試
使用Jest進行React單元測試
對 React 組件進行單元測試
How to Rethink Your Testing
使用Enzyme測試React(Native)組件
Node.js模塊化機制原理探究
單元測試的意義、作法、經驗
React 單元測試策略及落地