假設現在有一個 <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')
})