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

熊咆龍吟發表於2019-04-19

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

單元測試是一門偉大的學科,它可以減少 40% - 80% 的 bug。單元測試的主要好處有:

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

但是有些東西比其他的更容易進行單元測試。具體來說,單元測試對純函式非常有用:純函式是一種給定相同輸入,總是返回相同的值,並且沒有副作用的函式。

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

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

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

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

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

  • 使用純元件編寫 UI 程式碼: 鑑於相同的 props 總是渲染同一個元件,如果你需要從應用中獲取 state,你可以使用一個容器元件來包裹這些純元件,並使用容器元件管理 state 和副作用。
  • 在 reducer 純函式中隔離應用程式邏輯/業務規則
  • 使用容器元件隔離副作用

使用純元件

純元件是一種給定相同的 props,始終渲染出相同的 UI,並且沒有任何副作用的元件。比如:

import React from 'react';

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

export default Hello;
複製程式碼

這種元件一般來說很容易進行測試。你需要知道的是如何定位元件(拿上面的例子來說,我們選擇類名為 greeting 的元件),還要知道輸出的期望值。為了的到純元件我將使用 RITEwayrender-component 方法。

首先安裝 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}!`
  });
});
複製程式碼

但是這樣做沒啥意思,如果你需要測試一個有 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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章