iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 16
1
Modern Web

循序漸進學習 Javascript 測試系列 第 16

Day 16 測試 React 元件:render 元件及使用 Jest DOM & dom-testing-library

使用 ReactDOM 來 Render 被測試元件

假設現在有一個 <FavoriteNumber /> 元件,我們要測試它是否有 render 出如我們預期的 Dom。

我們預期的 <FavoriteNumber /> 元件,應該要有一個 label tag,裡面的文字是 Favorite Number ;還要有一個 input tag, type 屬性是 number ,如下面的 HTML:

<label for="favorite-number">Favorite Number</label>
<input id="favorite-number" type="number" value="0">

現在準備在 __test__/react-dom.js 裡面寫測試。給定一個測試名稱 'renders a number input with a label "Favorite Number"' ,然後,我們需要在測試中 render 元件,對 render 出來的結果驗證兩件事情:

  1. input tag 的 type 屬性是 number
  2. label tag 裡面的文字是 Favorite Number

首先,先 import React 才可以在測試裡使用 JSX,接著 import 要測試的 FavoriteNumber 進來。我們使用 ReactDOM 來 render <FavoriteNumber /> 元件,先創建一個 div 元素,然後將 <FavoriteNumber /> render 在裡面:

test/react-dom.js

import React from 'react'
import ReactDOM from 'react-dom'
import {FavoriteNumber} from '../favorite-number'

test('renders a number input with a label "Favorite Number"', () => {
	const div = document.createElement('div')
	ReactDOM.render(<FavoriteNumber />, div)
	console.log(div.innerHTML) // 可以 console.log 看看是不是有成功 render
})

我們將 div 裡面的 HTML 印出來看看,如果成功 render <FavoriteNumber /> 元件的話,可以在 console 看到:

Console Output

<label for="favorite-number">Favorite Number</label><input id="favorite-number" type="number" value="0">

成功在測試中 render 出元件之後,可以加上 assertions:

test/react-dom.js

import React from 'react'
import ReactDOM from 'react-dom'
import {FavoriteNumber} from '../favorite-number'

test('renders a number input with a label "Favorite Number"', () => {
  const div = document.createElement('div')
  ReactDOM.render(<FavoriteNumber />, div)
  expect(div.querySelector('input').type).toBe('number') // 驗證 input 的屬性是 number
  expect(div.querySelector('label').textContent).toBe('Favorite Number') // 驗證 label 裡面的文字
})

這個小測試就完成了。

使用 Jest DOM 強化 Assertions

測試工具對於開發者來說,很重要的一項功能就是:提供明確的訊息,好讓開發者可以即時找到錯誤並修正。

回到前面的 assertions,假如我們故意打錯字,將 'input' 打成 'nput'

test/react-dom.js

expect(div.querySelector('nput').type).toBe('number')

這時候應該會得到錯誤訊息 'type error cannot read property type of null' ,因為 div.querySelector('nput') 回傳的是 null 。但在真實的開發情境,我們並無法馬上就能有頭緒清楚 null 是哪裡出現的,所以如果有工具能提供更明確的訊息,可以減少很多 debug 的時間。

我們可以使用 testing library 家族裡的 jest-dom 來輔助:

npm install --save-dev @testing-library/jest-dom

或

yarn add --dev @testing-library/jest-dom

import @testing-library/jest-dom ,同時要加上 expect.extend({toHaveAttribute}) ,意思就是:告訴 expect 我們要將 jest-domtoHaveAttribute 擴充到它的功能裡面,讓我們可以像 expect().toHaveAttribute() 這樣使用。

將前面的測試使用 toHaveAttribute method 來改寫 assertions,將 toBe 改寫成 toHaveAttribute

test/react-dom.js

...
import {toHaveAttribute} from '@testing-library/jest-dom'

expect.extend({toHaveAttribute})

test('renders a number input with a label "Favorite Number"', () => {
  const div = document.createElement('div')
  ReactDOM.render(<FavoriteNumber />, div)
  expect(div.querySelector('nput')).toHaveAttribute('type', 'number') // 'nput' 是故意的 typo
  expect(div.querySelector('label').textContent).toBe('Favorite Number'))
})

現在 console 裡的錯誤訊息更明確了,我們得知錯誤是 expect() 裡接收到的值為 null ,所以可以很快地回去察看並發現 typo:

Console Output

expect(received).toHaveAttribute()

received value must be an HTMLElement or an SVGElement. Received has value: null

加上 toHaveTextContent method 來改寫:

test/react-dom.js

...
import {toHaveAttribute, toHaveTextContent} from '@testing-library/jest-dom'

expect.extend({toHaveAttribute, toHaveTextContent})

test('renders a number input with a label "Favorite Number"', () => {
  const div = document.createElement('div')
  ReactDOM.render(<FavoriteNumber />, div)
  expect(div.querySelector('input')).toHaveAttribute('type', 'number') // 使用 toHaveAttribute 改寫
  expect(div.querySelector('label').textContent).toHaveTextContent('Favorite Number')) // 使用 toHaveTextContent 改寫
})

優化 expect.extend()

剛剛我們加了 toHaveTextContent 就要再多加一次到 expect.extend() 裡面,如果再多一個 method 就要再多加一次,有點麻煩。這個情況可以改寫為一次引入:

import * as jestDOM from '@testing-library/jest-dom'

expect.extend(jestDOM)

或者是直接 import '@testing-library/jest-dom/extend-expect' ,它已經幫我們做好了同樣的事:

import '@testing-library/jest-dom/extend-expect'

更好的是,可以直接在 jest.config.js 設定,在每個測試檔案裡自動 import,所以不需要再加上 import '@testing-library/jest-dom/extend-expect'

jest.config.js

module.exports = {
  ...
  setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
}

使用 dom-testing-library 寫出更好維護的測試

剛剛強化了 assertions 來保護我們的測試,但有沒有一種情況是:實際上影響了使用者介面,但測試卻過關了。例如:我們用 labelfor 屬性來連結 input 這個 form control。現在故意改壞程式,將 labelhtmlFor 屬性改成 "favorite-num"inputid 一樣是 "favorite-number" ,它們之間的關聯已經不在了,但測試仍然 pass。

favorite-number.js

...
return (
  <>
    <label htmlFor="favorite-num">Favorite Number</label>
    <input
      id="favorite-number"
      type="number"
      value={number}
      onChange={handleChange}
    />
  </>
)

React element 使用 htmlFor 來替代 for,因為 for 在 JavaScript 是保留字。

雖然可以再寫些 assertions 來確保 labelinput 有沒有正確關聯起來,但有太多方式可以關聯 labelinput 了,花心力在撰寫這些 assertions 非常費工,也徒增未來的維護成本。

使用 testing library 家族裡的 dom-testing-library 就是要幫我們解決這些問題:

npm install --save-dev @testing-library/dom

或

yarn add --dev @testing-library/dom

現在 import @testing-library/dom 裡的 queries 進來,使用 queries.getByLabelText(div, 'Favorite Number') 來改寫測試,意思是:要搜尋 div 的 children  裡文字是 'Favorite Number'label ,它會回傳跟這個 label 有關聯的 form control。

test/react-dom.js

...
import {queries} from '@testing-library/dom'

test('renders a number input with a label "Favorite Number"', () => {
  const div = document.createElement('div')
  ReactDOM.render(<FavoriteNumber />, div)
  const input = queries.getByLabelText(div, 'Favorite Number') // 取得 form control
  expect(input).toHaveAttribute('type', 'number') // expect 裡傳入 input
	// 可以刪除 expect(div.querySelector('label').textContent).toHaveTextContent('Favorite Number'))
})

剛剛我們故意改壞程式碼,讓 inputlabel 沒有關聯,現在跑測試會得到錯誤訊息: Found a label with the text favorite number, however, no form control was found associated to that label. Make sure you're using the "for" attribute or the "aria-labelledby" attribute correctly. 。意思是:它找到了 label ,但沒有找到關聯的 input。

我們用 queries.getByLabelText(div, 'Favorite Number') 改寫後的這行測試,可以幫我們驗證 label 的文字是否正確,以及是否有關聯的 form control。所以不再需要 expect(div.querySelector('label').textContent).toHaveTextContent('Favorite Number') ,可以移除這個 assertion。

現在我們可以來優化一下,改用 regex 忽略大小寫來搜尋 label 文字:

const input = queries.getByLabelText(div, /favorite number/i)

測試的目的在於確保重要的功能正常運作,通常使用者並不太在乎大小寫,況且不影響語意及功能,未來需求端若改變大小寫的顯示,也不需要再另外花時間改測試。

最後可以再優化一下 dom 的 query,改用 getQueriesForElement API,getQueriesForElement(div) 會回傳 div element 的 queries 方法,所以我們直接用拿到的 getByLabelText method 來取得 input,而且現在不需要在 getByLabelText 的第一個參數傳入 div 了:

test/react-dom.js

import React from 'react'
import ReactDOM from 'react-dom'
import {getQueriesForElement} from '@testing-library/dom' // 引入 getQueriesForElement
import {FavoriteNumber} from '../favorite-number'

test('renders a number input with a label "Favorite Number"', () => {
  const div = document.createElement('div')
  ReactDOM.render(<FavoriteNumber />, div)
  const {getByLabelText} = getQueriesForElement(div) // 回傳針對 div 的 queries
  const input = getByLabelText(/favorite number/i) // 不需要傳入 div 當第一個參數了
  expect(input).toHaveAttribute('type', 'number')
})

上一篇
Day15 實戰 Jest 配置:以不同的配置跑測試
下一篇
Day 17 測試 React 元件:使用 React Testing Library 測試元件的狀態
系列文
循序漸進學習 Javascript 測試30

尚未有邦友留言

立即登入留言