[譯] 對 React 組件進行單元測試

Photo of a first attempt to test a React component by clement127 (CC BY-NC-ND 2.0)

單元測試是一門偉大的學科,它能夠減小 40% - 80% 的 bug。單元測試的主要好處有:javascript

  • 改善應用的結構和可維護性。
  • 經過在實現細節以前關注開發人員體驗(API),能夠得到更好的 API 和可組合性。
  • 提供快速的文件保存反饋,告訴你更改是否有效。 這能夠替代 console.log() 操做,僅在 UI 中單擊就能夠測試更改。單元測試的新手可能會在 TDD 過程上多花 15% - 30% 的時間,由於他們須要知道如何去測試各類組件,可是有經驗的 TDD 開發者會因使用 TDD 而節省開發時間。
  • 提供了一個很好的安全保障,能夠在添加功能或重構現有功能時加強你的信心。

可是有些東西比其餘的更容易進行單元測試。具體來講,單元測試對純函數很是有用:純函數是一種給定相同輸入,老是返回相同的值,而且沒有反作用的函數。html

一般,針對 UI 組件的單元測試不容易進行,測試先行的方法使得堅持使用 TDD 的原則變得更加困難。前端

首先編寫測試對於實現我列出的一些好處是必要的:架構改進、更好的開發人員體驗設計、以及在開發應用程序時得到更快的反饋。訓練本身使用 TDD 須要方法和實踐。許多開發人員喜歡在編寫測試以前進行粗劣的修補,可是若是不先編寫測試,就會錯過單元測試的許多好處。java

不過,這是值得的實踐和方法。使用單元測試的 TDD 能夠訓練你編寫 UI 組件,使得 UI 組件更簡潔、易於維護、而且更容易與其餘組件組合和重用。node

我最近關注的一個有創新性的單元測試框架 RITEway, 它是 Tape 的一個簡單包裝版,使得你可以編寫更簡潔、維護性更強的測試。react

不管你使用的是什麼框架,下面的小竅門將幫助你編寫更好、更可測試、更可讀、更可組合的 UI 組件:android

  • 使用純組件編寫 UI 代碼: 鑑於相同的 props 老是渲染同一個組件,若是你須要從應用中獲取 state,你可使用一個容器組件來包裹這些純組件,並使用容器組件管理 state 和反作用。
  • 在 reducer 純函數中隔離應用程序邏輯/業務規則
  • 使用容器組件隔離反作用

使用純組件

純組件是一種給定相同的 props,始終渲染出相同的 UI,而且沒有任何反作用的組件。好比:ios

import React from 'react';

const Hello = ({ userName }) => (
  <div className="greeting">Hello, {userName}!</div>
);

export default Hello;
複製代碼

這種組件通常來講很容易進行測試。你須要知道的是如何定位組件(拿上面的例子來講,咱們選擇類名爲 greeting 的組件),還要知道輸出的指望值。爲了的到純組件我將使用 RITEwayrender-component 方法。git

首先安裝 RITEway:github

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}!` }); }); 複製代碼

可是這樣作沒啥意思,若是你須要測試一個有 state 的組件,或者一個會產生反作用的組件,該怎麼辦?該問題的答案與另外一個重要問題的答案相同:「我如何使 React 組件更易於維護和調試?」,這就是 TDD 對於 React 組件變得有趣的地方。

答案是:將組件的 state 和反作用從展現組件中隔離出去。爲了實現這一目標,你能夠將 state 和反作用封裝在一個容器組件中,而後經過 props 將 state 傳遞到純組件中。

可是 hooks API 不也是這樣作的嗎?使得咱們擁有平鋪的組件層次結構,並忽略全部的組件嵌套內容。呃...,二者不徹底是同樣的。將代碼保存在三個不一樣的 bucket 中,並將這些 bucket 彼此隔離,這仍然仍是一個好主意。

  • 展現/UI 組件
  • 程序邏輯/業務規則 —— 這一部分處理用戶須要解決的問題。
  • 反作用(I/O、網絡、磁盤等等。)

根據個人經驗,若是你將展現/UI 問題與程序邏輯和反作用分開,你會以爲更加輕鬆。對於我來講,這個經驗法則始終適用於我曾經使用的每種語言和每一個框架,包括React hooks。

讓咱們經過構建一個點擊計數器來演示有 state 的組件。首先,咱們將構建 UI 組件。它應該顯示相似 「Clicks:13」 的內容,告訴你單擊按鈕的次數。按鈕只有點擊功能。

顯示組件的單元測試很是簡單。咱們只須要測試按鈕是否被渲染(咱們不關心 label 的內容 —— 它可能會用不一樣的語言表達不一樣的內容,具體取決於用戶的區域設置)。咱們設置 undefinedwant 以確保顯示正確的點擊次數。下面咱們將編寫兩個測試:一個用於測試按鈕顯示,另外一個用於測試點擊次數的正確呈現。

當使用 TDD 時,我常用兩個不一樣的斷言來確保我已經編寫了組件,以便從 props 中提取適當的。編寫一個測試來硬編碼函數中的值也是可能的。爲了防範這種硬編碼狀況,你能夠編寫兩個測試,每一個測試測試不一樣的值。

這個例子中,咱們將建立一個名爲 \<ClickCounter> 的組件,該組件將有一個 clicks prop 用於記錄按鈕單擊次數。要使用它,只需渲染組件並將 clicks prop 值設置爲要顯示的單擊次數便可。

讓咱們來看下面兩個單元測試,它們能夠確保咱們從 props 中提取點擊計數。建立一個新文件,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 測試文件放在同一個文件夾中。首先,讓咱們編寫一個組件 fragment 來監視測試是否失敗:

import React, { Fragment } from 'react';

export default () =>
  <Fragment> </Fragment>
;
複製代碼

若是保存並測試咱們建立的測試,會獲得一個 TypeError 錯誤,該錯誤最終會觸發 Node 的 UnhandledPromiseRejectionWarning 錯誤,Node 不會在額外的段落髮出 DeprecationWarning 這種惱人的警告,而是拋出 UnhandledPromiseRejectionError。獲得 TypeError 錯誤是因爲咱們的 selection 返回了我 null,而且咱們嘗試在它上面應用 .trim() 方法。讓咱們經過渲染指望的選擇器來解決這個問題:

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,並在 JSX 中使用 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
複製代碼

如今是時候測試 button 了。首先添加測試,並觀察錯誤信息(TDD 慣用方式):

{
  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
...
複製代碼

如今,咱們將應用 click button:

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 邏輯並將其與事件觸發鏈接起來。

單元測試有狀態的組件

我下面向你展現的方法對於單擊計數器來講可能有點大材小用,畢竟大多數應用程序都比單擊計數器複雜得多。state 一般保存到數據庫或在組件之間共享。React 社區流行的作法是從本地組件 state 開始,而後根據須要將其提高到父組件或全局應用程序 state。

事實證實,若是使用純函數啓動本地組件 state 管理,那麼該過程在之後更容易管理。鑑於此和其餘緣由(如 React 生命週期混亂、state 一致性、避免常見 bugs),我傾向於使用純 reducer 函數來實現 state 管理。對於本地組件 state,能夠導入它們並應用 useReducer React hook。

若是須要將 state 提高到由 Redux 這樣的 state 管理器來管理,那麼在開始單元測試以前就已經完成了一半。

首先,我將爲 state reducers 建立一個新的測試文件。我將把它放在同一個文件夾中,但使用不一樣的文件名。將這個測試文件命名爲 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 將產生一個有效的初始 state。若是你稍後決定使用 Redux,它將調用每一個沒有 state 的 reducer,以生成存儲的初始 state。這也使得你在任什麼時候候須要一個有效的初始 state 來進行單元測試或者初始化你的組件 state 變得很是容易。

固然,咱們須要建立一個相應的 reducer 文件。將其命名爲 click-counter/click-counter-reducer.js:

const click = () => {};

const reducer = () => {};

export { reducer, click };
複製代碼

我將從生成簡單的空 reducer 和 action 生成器開始。想要了解更多關於 action 生成器和選擇器等的內容,請閱讀文章 「10 Tips for Better Redux Architecture」。咱們如今不會深刻研究 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() action 生成器做爲 reducer 的公共 API。我認爲你須要明白 reducer 並不會直接與你的應用進行交互。應用使用 action 生成器和選擇器做爲公共 API 暴露給 reducer。

我也不會爲 action 生成器和選擇器分別編寫測試用例。我老是將它們和 reducer 放在一塊兒進行測試,測試 reducer 就是測試 action 生成器和選擇器,反之亦然。 若是你也遵循這個經驗法則,你就會少作不少測試。可是若是你分開測試它們,仍舊能夠得到相同的測試和用例覆蓋率。

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 管理鏈接起來,並經過 prop 將 state 傳遞到用做單元測試的純組件中。要想測試它,只須要將其加載到瀏覽器並點擊 click 按鈕。

截至目前,咱們尚未在瀏覽器中查看任何組件,也沒有設置任何樣式。爲了使咱們的計數變得更加清晰,下面將添加一些標記和空間到 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 的交互。在你的應用中你須要使用兩種測試(單元測試和功能測試),而且我以爲將單元測試應用到容器組件(這些容器組件通常是起鏈接做用的組件,好比上面鏈接咱們 reducer 的容器組件)與將功能測試應用到容器組件相比,前者不只有些冗餘,還不容易進行單元測試。一般,你必須模擬各類容器組件之間的依賴關係,以使它們正常工做。

同時,咱們已經對全部不依賴反作用的重要單元進行了單元測試:測試了數據是否被正確的渲染以及 state 是否被正確管理。你還應該在瀏覽器中加載該組件,並親自查看該按鈕是否工做以及 UI 是否有改變。

功能/端到端測試在 React 上的實現與其它框架上的實現類似,在此不作詳細討論,感興趣的讀者能夠查看 TestCafeTestCafe StudioCypress.io 在沒有 Selenium dance 的狀況下進行端到端測試。

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索