React 組件的單元測試

原文地址: medium.com/javascript-…
譯文地址:github.com/xiao-T/note…
本文版權歸原做者全部,翻譯僅用於學習。javascript


單元測試是一門很是偉大的學科,它能夠減小40%-80%的 bug。同時,還有如下幾個重要的好處:html

  • 改善應用的結構和可維護性。
  • 在具體實現以前,讓開發者更加關注開發體驗,從而實現更好的 API 和更好的組合能力。
  • 每當保存文件不論是否正確,都會提供快速的反饋。這能夠避免使用 console.log() 和直接點擊UI驗證改變是否正確。做爲一個單元測試新手可能須要在 TDD 流程上花費額外的15% - 30% 的時間瞭解如何測試各類組件,可是,TDD 經驗豐富的開發者會節省具體實現的時間。
  • 當須要添加功能或者重構現有功能時,提供強大的安全保障。

有些狀況單元測試相對比較容易。舉例來講,單元測試對純函數更加有效:一個函數,也就意味着一樣的輸入總會獲得一樣的輸出,不會有反作用。java

可是,UI 組件並不屬於這一類,這使得 TDD 更加艱難,須要先編寫測試。node

對於我列出好處中有一些先編寫測試用例是必要的,好比:在開發應用過程當中,改善結構、更好的開發體驗和更快的反饋。做爲一個開發者要練習使用 TDD。不少開發者喜歡在編寫測試以前編寫業務,若是,你不先編寫測試,你就會失去不少單元測試帶來的好處。react

儘管如此,先編寫測試仍是值得實踐的。TDD 和單元測試可讓你編寫 UI 組件更加簡單、更容易維護和更容易組合複用組件。git

我在測試領域最新的一個發明就是:實現了單元測試框架 RITEway,它是對 Tape 的簡單封裝,讓你編寫測試更加簡單、更容易維護。github

無論你用什麼測試框架,接下來的提示均可以幫助你編寫更好、更易測試、更具可讀性和更具可組合性的 UI 組件:shell

  • 對於 UI 組件選用純函數組件:一樣的 props,老是一樣的渲染。若是,你的應用須要 state,你能夠用容器組件管理 state 和反作用,而後來包裝純函數組件。
  • 在純 reducer 函數中隔離應用的業務邏輯
  • 使用容器組件隔離反作用

函數組件

一個函數組件,也就意味着一樣的 props,中會渲染一樣的 UI,也不會有反作用。好比:數據庫

import React from 'react';
const Hello = ({ userName }) => (
  <div className="greeting">Hello, {userName}!</div>
);
export default Hello;
複製代碼

這類組件一般很是容易測試。你須要一方式選擇定位組件(在這個示例中,咱們經過類名 greeting 來選擇組件),而後,你須要知道組件輸出什麼。爲純函數組件編寫測試用例,我使用 RITEway 中的 render-componentnpm

首先,須要安裝 RITEway:

npm install --save-dev riteway
複製代碼

RITEway 內部使用 react-dom/server renderToStaticMarkup() 而後把輸出的內容包裝成 Cheerio 對象以便選擇。若是,你不使用 RITEway,你也建立屬於本身的功能函數把 React 組件渲染成靜態標籤,而後用 Cheerio 來操做。

一旦,你獲得 Cheerio 對象,你就能夠像下面這樣編寫測試:

import { describe } from 'riteway';
import render from 'riteway/render-component';
import React from 'react';
import Hello from '../hello';
describe('Hello component', async assert => {
  const userName = 'Spiderman';
  const $ = render(<Hello userName={userName} />); assert({ given: 'a username', should: 'Render a greeting to the correct username.', actual: $('.greeting') .html() .trim(), expected: `Hello, ${userName}!` }); }); 複製代碼

可是,這並沒什麼神奇的。若是,你須要測試 stateful 組件或者有反作用的組件呢?這纔是 TDD 對 React 組件真正神奇的地方,由於,這個問題的答案同另一個很是重要的問題答案相同:「如何讓組件更容易維護和 debug?」。

回答是:從組件中隔離 state 和反作用。你能夠把 state 和反作用封裝到一個容器組件,而後,把 state 作爲純函數組件的 props 向下傳遞。

可是,Hooks API 不就是爲了讓組件層級更加扁平,避免更深層的嵌套嗎?不徹底是。把組件分紅三類仍舊是一個很好的注意,讓彼此相互隔離:

  • 顯示/UI 組件
  • 程序邏輯/業務規則 — 處理解決用戶相關的問題。
  • 反作用((I/O, network, disk 等)

根據我我的的經驗,若是,你將顯示/UI 與程序邏輯和反作用分離開,會提高你的開發體驗。這種規則在我使用過的每種語言或者每一個框架中,包括 React Hooks,都適用。

咱們來建立一個 Counter 組件來演示 stateful 組件。首先,咱們須要建立 UI 組件。它應該包括這些內容:「Clicks: 13」 來表示按鈕被點擊了多少次。按鈕的值是「Click」。

爲這個顯示組件編寫單元測試很是簡單。咱們只需測試按鈕是否被渲染(咱們不關心按鈕的值是什麼 — 根據用戶的設置,不一樣的語言會有不一樣的顯示)。咱們還想知道是否顯示了正確的點擊數。咱們須要編寫兩個測試:一個測試按鈕是否顯示,另一個驗證點擊次數是否顯示正確。

使用 TDD 時,我習慣使用兩種不一樣的斷言來確保組件能夠正確顯示相關的 props。若是,只編寫一個測試有可能正好對應組件中的 hard-code。爲了不這種狀況,你能夠用兩個不一樣的值來編寫不一樣測試用例。

這個示例中,咱們建立了一個名叫 <ClickCounter> 的組件,組件會有一個名爲 clicks 的屬性表明點擊次數。爲了使用它,只需爲組件設置一個 clicks 屬性,來表示須要顯示的數字便可。

咱們來看一下單元測試是如何保證組件渲染的。咱們須要建立新文件:click-counter/click-counter-component.test.js

import { describe } from 'riteway';
import render from 'riteway/render-component';
import React from 'react';
import ClickCounter from '../click-counter/click-counter-component';
describe('ClickCounter component', async assert => {
  const createCounter = clickCount =>
    render(<ClickCounter clicks={ clickCount } />) ; { const count = 3; const $ = createCounter(count); assert({ given: 'a click count', should: 'render the correct number of clicks.', actual: parseInt($('.clicks-count').html().trim(), 10), expected: count }); } { const count = 5; const $ = createCounter(count); assert({ given: 'a click count', should: 'render the correct number of clicks.', actual: parseInt($('.clicks-count').html().trim(), 10), expected: count }); } }); 複製代碼

爲了更加簡單的編寫測試用例,我喜歡建立小的工廠函數。這個示例中,createCounter 須要一個數字參數,而後,返回一個渲染後的組件:

const createCounter = clickCount =>
  render(<ClickCounter clicks={ clickCount } />) ; 複製代碼

有了測試用例,是時候實現 ClickCounter 組件了。我把組件和測試文件放在了同一目錄下,並命名爲 click-counter-component.js。首先,咱們先編寫組件的框架,而後,你會看到測試用例報錯了:

import React, { Fragment } from 'react';
export default () =>
  <Fragment> </Fragment>
;
複製代碼

若是,咱們保存而後運行測試用例,你會看到報錯TypeError,它觸發了 Node 的 UnhandledPromiseRejectionWarning 。最終,Node 將不會使用煩人的警告 DeprecationWarning,而是拋出一個 UnhandledPromiseRejectionError 錯誤。咱們之因此遇到這個 TypeError,是由於咱們選擇器返回了 null,而後,咱們嘗試調用 nulltrim() 方法。咱們能夠經過渲染指望的結構來修復這個錯誤:

import React, { Fragment } from 'react';
export default () =>
  <Fragment> <span className="clicks-count">3</span> </Fragment>
;
複製代碼

很好。如今,咱們應該會有一個測試經過,一個測試失敗:

# ClickCounter component
ok 2 Given a click count: should render the correct number of clicks.
not ok 3 Given a click count: should render the correct number of clicks.
  ---
    operator: deepEqual
    expected: 5
    actual:   3
    at: assert (/home/eric/dev/react-pure-component-starter/node_modules/riteway/source/riteway.js:15:10)
...
複製代碼

爲了修復它,咱們須要把 count 設置爲組件的 prop,而後用真實的值來渲染:

import React, { Fragment } from 'react';
export default ({ clicks }) =>
  <Fragment> <span className="clicks-count">{ clicks }</span> </Fragment>
;
複製代碼

如今,咱們全部的測試都經過了:

TAP version 13
# Hello component
ok 1 Given a username: should Render a greeting to the correct username.
# ClickCounter component
ok 2 Given a click count: should render the correct number of clicks.
ok 3 Given a click count: should render the correct number of clicks.
1..3
# tests 3
# pass 3
# ok
複製代碼

是時候測試點擊按鈕了。首先,添加測試用例,很顯然會失敗:

{
  const $ = createCounter(0);
  assert({
    given: 'expected props',
    should: 'render the click button.',
    actual: $('.click-button').length,
    expected: 1
  });
}
複製代碼

這是測試失敗後的提示:

not ok 4 Given expected props: should render the click button
  ---
    operator: deepEqual
    expected: 1
    actual:   0
...
複製代碼

如今,咱們來實現點擊按鈕:

export default ({ clicks }) =>
  <Fragment> <span className="clicks-count">{ clicks }</span> <button className="click-button">Click</button> </Fragment>
;
複製代碼

測試經過了:

TAP version 13
# Hello component
ok 1 Given a username: should Render a greeting to the correct username.
# ClickCounter component
ok 2 Given a click count: should render the correct number of clicks.
ok 3 Given a click count: should render the correct number of clicks.
ok 4 Given expected props: should render the click button.
1..4
# tests 4
# pass 4
# ok
複製代碼

如今,咱們只須要實現 state 相關的邏輯和相關事件便可。

Stateful 組件的單元測試

我告訴你的方法對於 ClickCounter 來講過於複雜,可是,大部分應用比這個組件更加複雜。State 常常會保存在數據庫或者在多個組件之間共享。React 社區流行的作法是先從組件本地 state 開始,而後,根據須要把 state 提高到父級組件或者全局。

事實證實,若是一開始你就使用純函數組件本地管理 state,對於之後也更易於管理。出於此緣由和其它緣由(好比:React 生命週期的混亂、state 的一致性、避免常見的bug),我更喜歡使用 reducer 管理組件 state。對於本地組件 state,你可使用 React Hook API useReducer 引入。

若是,你須要使用 state 管理框架,好比:Redux,在此以前你已經實現了一半的工做,好比:單元測試等等。

If you need to lift the state to be managed by a state manager like Redux, you’re already half way there before you even start: Unit tests and all.

譯者注:個人理解是,若是,你一開始就使用 useReducer 本地維護 state,在須要過渡到 Redux 時更加順暢,以前的單元測試也能夠很好的重用

首先,我爲 state reducer 建立了相應的測試文件。我將會把它放在相同目錄下,只是用了不一樣文件名。我把它命名爲 click-counter/click-counter-reducer.test.js

import { describe } from 'riteway';
import { reducer, click } from '../click-counter/click-counter-reducer';
describe('click counter reducer', async assert => {
  assert({
    given: 'no arguments',
    should: 'return the valid initial state',
    actual: reducer(),
    expected: 0
  });
});
複製代碼

我習慣以斷言開始,以確保 reducer 能夠產出一個正常的初始值。若是,你之後決定使用 Redux,它將會在沒有 state 的狀況下,調用每個 reducer,以便爲 store 初始化 state。這也使得在須要爲單元測試提供有效的初始 state 或者組件 state 時更加方便。

固然,我還須要建立相應的 reducer 文件:click-counter/click-counter-reducer.js

const click = () => {};
const reducer = () => {};
export { reducer, click };
複製代碼

一開始,我只是簡單的導出空的reducer 和 action creator。想知道更多有關 action creators 和 selectors 的知識,請查看:「改善 Redux 體系的 10 個提示」。今天,咱們不打算深刻探討 React/Redux 設計模式相關內容,可是,理解了這類問題,即便,你不使用 Redux 對於理解咱們今天所作的事情也有所幫助。

首先,咱們將會看到測試失敗:

# click counter reducer
not ok 5 Given no arguments: should return the valid initial state
  ---
    operator: deepEqual
    expected: 0
    actual:   undefined
複製代碼

如今,我來修復測試用例中的問題:

const reducer = () => 0;
複製代碼

初始化相關的測試用例如今能夠經過了,是時候添加更有意義的測試用例了:

assert({
    given: 'initial state and a click action',
    should: 'add a click to the count',
    actual: reducer(undefined, click()),
    expected: 1
  });
  assert({
    given: 'a click count and a click action',
    should: 'add a click to the count',
    actual: reducer(3, click()),
    expected: 4
  });
複製代碼

咱們看到測試用例都失敗了(兩個分別應該返回 14的,都返回了0)。咱們來修復它們。

注意我把 click() 做爲 reducer 的公共 API 使用。在我看來,你應該把 reducer 做爲應用的一部分,而不是直接與它交互。相反,reducer 的公共 API 應該是 action creators 和 selectors。

我沒有單獨爲 action creators 和 selectors 編寫測試用例。我老是與 reducer 相結合來測試它們。測試 reducer 也就是測試 action creators 和 selectors,反之亦然。若是,你遵照這些規則,你將會須要更少的測試用例,可是,若是,你單獨的測試它們,仍舊能夠實現一樣的測試和覆蓋率。

const click = () => ({
  type: 'click-counter/click',
});
const reducer = (state = 0, { type } = {}) => {
  switch (type) {
    case click().type: return state + 1;
    default: return state;
  }
};
export { reducer, click };
複製代碼

如今,全部的單元測試都應該能夠經過:

TAP version 13
# Hello component
ok 1 Given a username: should Render a greeting to the correct username.
# ClickCounter component
ok 2 Given a click count: should render the correct number of clicks.
ok 3 Given a click count: should render the correct number of clicks.
ok 4 Given expected props: should render the click button.
# click counter reducer
ok 5 Given no arguments: should return the valid initial state
ok 6 Given initial state and a click action: should add a click to the count
ok 7 Given a click count and a click action: should add a click to the count
1..7
# tests 7
# pass 7
# ok
複製代碼

最後一步:爲組件綁定行爲事件。咱們可使用容器組件來處理。我在本地目錄中建立了一個名爲 index.js 的文件。它的內容以下:

import React, { useReducer } from 'react';
import Counter from './click-counter-component';
import { reducer, click } from './click-counter-reducer';
export default () => {
  const [clicks, dispatch] = useReducer(reducer, reducer());
  return <Counter clicks={ clicks } onClick={() => dispatch(click())} />; }; 複製代碼

就是這樣。這個組件只是用來管理 state,而後把 state 做爲純函數組件的 prop 向下傳遞。在瀏覽器中打開應用,點擊按鈕看是否正常運行。

到如今爲止,咱們尚未在瀏覽器中查看組件和處理樣式的問題。爲了更加的清晰,我將會在 ClickCounter 組件中添加一個標籤和一些空格。同時,也會綁定 onClick 事件。代碼以下:

import React, { Fragment } from 'react';
export default ({ clicks, onClick }) =>
  <Fragment> Clicks: <span className="clicks-count">{ clicks }</span>&nbsp; <button className="click-button" onClick={onClick}>Click</button> </Fragment>
;
複製代碼

全部的測試用例仍是能夠經過。

容器組件的測試呢?我不會爲容器組件編寫單元測試。相反,我使用功能測試,這種測試運行在瀏覽器中或者模擬器中,用戶能夠與真實的 UI 交互,運行 end-to-end 測試。你的應用須要兩種測試(單元和功能測試),爲容器組件(那些爲了鏈接 reducer 的組件)編寫單元測試我以爲有點多餘,並且,很難實現正確的單元測試。一般,你須要模擬各類容器組件的依賴關係以即可以正常工做。

在此期間,咱們只是測試那些比較重要而不依賴反作用的組件:咱們測試了是否能夠正確的渲染,state 的管理是否正確。你仍是須要在瀏覽器中運行組件,而後查看按鈕是否正確工做。

不論是爲 React 組件實施功能/e2e測試,仍是爲其它框架實施都是相同的。詳情能夠查看 「Behavior Driven Development (BDD) and Functional Testing」

下一步

註冊 TDD Day:可得到 5 小時有關 TDD 的高質量的視頻內容和交互課程。這是一個很棒的速成教程,能夠提升團隊的 TDD 技能。無論,你當前的 TDD 經驗如何,你都會學到更多知識。

相關文章
相關標籤/搜索