假設現在有一個 <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 出來的結果驗證兩件事情:
input
tag 的 type 屬性是 number
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 裡面的文字
})
這個小測試就完成了。
測試工具對於開發者來說,很重要的一項功能就是:提供明確的訊息,好讓開發者可以即時找到錯誤並修正。
回到前面的 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-dom
的 toHaveAttribute
擴充到它的功能裡面,讓我們可以像 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 改寫
})
剛剛我們加了 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'],
}
剛剛強化了 assertions 來保護我們的測試,但有沒有一種情況是:實際上影響了使用者介面,但測試卻過關了。例如:我們用 label
的 for
屬性來連結 input
這個 form control。現在故意改壞程式,將 label
的 htmlFor
屬性改成 "favorite-num"
, input
的 id
一樣是 "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 來確保 label
跟 input
有沒有正確關聯起來,但有太多方式可以關聯 label
跟 input
了,花心力在撰寫這些 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'))
})
剛剛我們故意改壞程式碼,讓 input
跟 label
沒有關聯,現在跑測試會得到錯誤訊息: 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')
})