我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。
本文作者:佳嵐
The more your tests resemble the way your software is used, the more confidence they can give you.
您的測試越接近軟體的使用方式,它們就越能給您帶來信心。
什麼是 testing-library?
在瞭解 testing-library
前,我們可以看看使用原生方法是如何進行 React 元件測試的。
import Header from ".."
import client from 'react-dom/client'
import { act } from 'react-dom/test-utils'
let container;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container)
});
afterEach(() => {
document.body.removeChild(container);
container = null;
})
test('test render', () => {
act(() => {
client.createRoot(container!).render(<Header />)
});
const button = container!.querySelector('button');
const count = container!.querySelector("span[title='count']");
act(() => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// or Simulate.click(button!)
});
expect(count?.textContent).toEqual('1')
})
在上面案例中,我們需要自行建立一個根容器來渲染 React 元件, 且我們必須及時清除該根容器,避免對其他測試用例產生影響。
在 Header 元件中,我們每次點選按鈕都會進行計數+1,當我們要進行點選時,需要自行建立事件例項,且由於合成事件的原因需要增加 bubbles
屬性,這使得對開發者也就有了些許能力要求。
除此之外,事件觸發必須包裹在 act
方法中,render
方法中相同。
什麼是 act
?
正常情況下,我們的單測程式碼是同步執行的,即程式碼執行完畢則單測完成。React 中的渲染並不是同步的,當我們進行事件觸發,導致了 rerender
後,並不能立馬獲取新的頁面結果,導致後續的斷言失敗。act
是由 React 官方在 react-dom/test-utils
中提供的一個方法,它能夠讓回撥函式呼叫後,立即執行 React 內部 pending 中的非同步佇列。
我們再使用 RTL
去實現程式碼,RTL
為我們簡化了 render 所需要的重複模板程式碼,模擬事件也不再需要包裹 act
,並提供了通用的查詢方法。
test('test by RTL', () => {
const { getByRole, getByTitle } = render(<Header />);
const button = getByRole("button")
const count = getByTitle('count')
fireEvent.click(button);
expect(count.textContent).toEqual('1');
})
設計理念
testing-library 是以使用者為中心
的方式進行 UI 元件的測試。什麼是 以使用者為中心
?
即以使用者的角度方式去審視你的 UI 元件,使用者是不關心你的元件內部是如何實現的,只關心最終的功能效果是否正確。你不應該在 testing-library 中去測試元件的 props 與內部 state,生命週期等是否正確。
Why ?
組成架構
testing-library 的核心部分是 DOM Testing Library
即 @testing-library/dom
, 它提供了通用的DOM 查詢功能,如getByRole
getByText
與事件行為的基本實現。
在此基礎上,再衍生出各自框架的專有包,如React Testing Library
、Vue Testing Library
,對於不同的前端框架,其使用方法是基本一致的, 提供不同實現方式的 render
與 fireEvent
方法。
除了每個前端框架提供各自的 fireEvent
介面外,還額外提供了一個@testing-library/user-event
的通用包,不依賴於所選框架實現,它能夠對使用者事件的真實模擬,下文會詳細說到。
除此之外,還針對 Jest
測試框架開發了一個斷言庫 @testing-library/jest-dom
,如我們平常經常使用的 expect().toBeInTheDocument()
就是該庫為我們實現的,其透過 jest
提供的自定義斷言器expect.extend(matchers)
將斷言庫注入到我們所使用的的 jest
上下文中
查詢方法
查詢作為testing-library
的核心,它主要以使用者的角度去查詢,如根據元件展示的文字,Title 等資訊。
查詢內容
從查詢內容上來講它提供了8種型別
- ByRole
透過可訪問性、語義化查詢元素, 如checkbox
,menu
,navigation
, 對於實際場景並不常用。 ByLabelText
透過 label 找到 label 所對應的元素,通常在表單中使用,如下render( <label> username <input /> </label> ); const el = screen.getByLabelText('username'); expect(el.tagName).toBe('INPUT')
- ByPlaceHolder
透過佔位符查詢元素,當查詢表單元素等,但又沒有 label 標識的話,可以使用這個,但不推薦。 ByText
最常用的查詢方法,根據textContent
進行查詢,通常可配合正則一起使用,如下render(<div>Text Content is: 1</div>); const el = screen.getByText(/Text Content is/); expect(el.textContent).toBe("Text Content is: 1");
ByDisplayValue
透過數值進行查詢包含該值的元素,如 input 與 select 會有 value 屬性const {container} = render( <div> <select> <option value="state">state</option> <option value="prop">prop</option> </select> </div> ); const select = container.querySelector('select')!; select.value = 'state' const el = screen.getByDisplayValue('state'); expect(el).toEqual(select)
- ByAltText
根據alt
屬性進行查詢,如<img alt=”img1” src=”xxx” />
- ByTitle
根據title
屬性進行查詢,同上 - ByTestId
如果以上查詢方式都不容易查詢到節點,則最終可以考慮 testid,這種方式會侵入原始碼,但不會對頁面效果產生影響,透過在元素上新增data-testid
屬性來查詢,查詢時相當於container.querySelector([data-testid="${yourId}"])
有這麼多方式,我該選哪個最好?
官方推薦優先使用使用者頁面可視的查詢方式,如 byRole
, byText
等可以在頁面上看到的;其次是語義話查詢方式,byAltText
與 ByTitle
,這是在頁面上基本看不到,但是易於機器讀懂的;最後才應該考慮byTestId
。如果 byTestId
也無法實現,那你只是使用原生的 querySelector
也沒什麼問題
查詢方式
testing-library 一共提供了三種查詢型別getBy
、findBy
、queryBy
,這三種型別定義了對查詢結果的處理方式。
透過 getBy
方式查詢, 當查詢不到元素時會直接丟擲一個錯誤, 則導致測試失敗。
我們看看下面這個案例,上面部分額外的顯式使用了斷言,其執行結果最終是一模一樣的。
但應該採用哪種寫法最好?
test("test getBy with assertion", () => {
const { getByRole } = render(
<div>
<button>按鈕</button>
</div>
);
expect(getByRole("list")).toBeInTheDocument();
});
test("test getBy", () => {
const { getByRole } = render(
<div>
<button>按鈕</button>
</div>
);
getByRole("list")
});
如果這段測試的意義是為了測試元素是否存在,則最佳實踐應是採用使用顯式斷言的方式。
透過 queryBy
方式查詢,與 getBy
的唯一不同就是查詢不到元素時不會丟擲錯誤。
那麼什麼情況下該使用 queryBy
還是 findBy
?
事實上,絕大多數情況下應直接使用 getBy
,只在想測試元素不存在這種場景時使用 queryBy
test("test queryBy", () => {
const { queryByRole } = render(
<div>
<button>按鈕</button>
</div>
);
expect(queryByRole("list")).not.toBeInTheDocument()
});
透過 findBy
進行查詢,他與 getBy
一致,查詢不到時會丟擲錯誤,但是它能夠用來查詢非同步元素。
何為非同步元素?
在前面我們講過,setState 導致的非同步渲染,我們已經透過React提供的 act
方法解決了,對於某些場景,比如上傳檔案後,顯示已上傳的檔案列表,上傳檔案操作是非同步的,我們需要在一定時間後才能拿到檔案列表元素;又或者說 setTimeout
或者promise
中進行了setState
操作,渲染的元素也需要非同步獲取。
在講 findBy
之前,我們先了解下 waitFor
, waitFor
也是testing-library 提供的一個非同步方法,它提供了一種對於不確定程式碼執行時間的處理方法。在使用時,必須使單測塊變為非同步的,否則就沒了使用意義,因此 waitFor
一般都與 await
一起使用。
使用方式如下:
test("test waitFor", async () => {
const Foo = () => {
const [text, setText] = useState('text1')
useEffect(() => {
setTimeout(() => {
setText('text2')
}, 300);
}, [])
return <span>{text}</span>
}
const { getByText } = render(<Foo />);
await waitFor(() => {
expect(getByText('text2')).toBeInTheDocument()
})
})
其原理也很簡單,不斷的去執行傳入的回撥函式,直到回撥函式沒有丟擲錯誤或者超出最大等待時間。expect
斷言失敗,本質上也是丟擲個錯誤, 因此一般會把斷言寫在 waitFor
中。
waitFor
預設超時時間為1000ms,每50ms執行一次回撥。但在測試環境我們也不可能真去等1秒時間,其內部做了額外處理。
- 預設會優先採用
jest
的fakeTimers
來略過時間等待,但這個前提是在執行waitFor
前,需要進行fakeTimers
的註冊,也就是執行jest.useFakeTimers()
。
但很多情況下我們是沒有使用fakerTimers
, 且 testing-library 是測試框架無關的,所以在其他情況下會使用MutationObserver
來作為重複執行callback
的時機。 - 在迴圈開始前會新增一個超時時間的定時器
overallTimeoutTimer
,定時器回撥被呼叫則說明超時,直接reject
掉。 - 當採用
fakeTimers
方案時,會在每次迴圈時透過jest.advanceTimersByTime
等待一定的時間interval
(並非真正的等待) - 在
checkCallback
方法中,會呼叫callback
,並進行異常捕獲,如捕獲到異常則會進行下一次的迴圈,如果正常則在onDone
方法中將finished
置為true
,並結束當前promise。 當使用
MutationObserver
方案時,會監聽document
DOM 節點的變化(包括其自己節點)。除此之外,由於MutationObserver
是監聽DOM樹
來實現的,某些場景會有限制,如 CSS 屬性的變化,因此還會啟用一個setInterval
原始的定時器來做輔助執行,保證回撥一定會被執行。function waitFor( callback, { container = getDocument(), timeout = getConfig().asyncUtilTimeout, interval = 50, // 其他引數略 }, ) { return new Promise(async (resolve, reject) => { let lastError, intervalId, observer let finished = false let promiseStatus = 'idle' // 超時時間的timerid const overallTimeoutTimer = setTimeout(handleTimeout, timeout) const usingJestFakeTimers = jestFakeTimersAreEnabled() // 如果使用了jest的fakeTimers,則採用advanceTimersByTime快速略過時間 if (usingJestFakeTimers) { const {unstable_advanceTimersWrapper: advanceTimersWrapper} = getConfig() checkCallback() // 不斷的等待一定時間後並檢測回撥是否透過檢測 while (!finished) { await advanceTimersWrapper(async () => { jest.advanceTimersByTime(interval) }) // 呼叫callback並檢測是否跑錯 checkCallback() if (finished) { break } } } // 如果沒有使用fakeTimers,則退化為使用MutationObserver觀測元素變化時執行一遍 else { intervalId = setInterval(checkRealTimersCallback, interval) const {MutationObserver} = getWindowFromNode(container) observer = new MutationObserver(checkRealTimersCallback) observer.observe(container, mutationObserverOptions) checkCallback() } function checkCallback() { if (promiseStatus === 'pending') return try { const result = callback() // 處理callback為非同步函式的情況 if (typeof result?.then === 'function') { promiseStatus = 'pending' result.then( resolvedValue => { promiseStatus = 'resolved' onDone(null, resolvedValue) }, rejectedValue => { promiseStatus = 'rejected' lastError = rejectedValue }, ) } else { onDone(null, result) } } catch (error) { lastError = error } } function onDone(error, result) { finished = true clearTimeout(overallTimeoutTimer) if (!usingJestFakeTimers) { clearInterval(intervalId) observer.disconnect() } if (error) { reject(error) } else { resolve(result) } } }
從原始碼中看其實現還是很巧妙的,額外還需要注意的點是,回撥函式是支援傳入 async 函式的,當傳入 async 函式時,會等待 promise 狀態改變後才會再次執行。
再回到 findBy
, 它其實就是對 waitFor
的一個封裝,類似於下面這種程式碼。
await waitFor(() => getByXXX())
在使用時也必須加上 await
關鍵字,並且當你想要使用 findBy
時,請確保元素最終一定會存在,如果你想要測試元素是否存在,請使用waitFor + expect
的形式保證其具有足夠的語義。
test("test findBy", async () => {
const Foo = () => {
const [text, setText] = useState('text1')
useEffect(() => {
setTimeout(() => {
setText('text2')
}, 300);
}, [])
return <span>{text}</span>
}
const { findByText } = render(<Foo />);
const span = await findByText('text2');
expect(span.nodeName).toBe('SPAN')
})
除此之外,所有查詢方法都是嚴格區分數量的,如果查詢結果數量返回大於1,即使是 queryBy
型別,也會報錯導致測試失敗,對於多個返回的,需要使用getAllBy
, queryAllBy
, findAllBy
。
貼一張文件上的區別圖
事件觸發
testing-library 提供了兩種觸發事件的方式,fireEvent
與 userEvent
fireEvent
fireEvent
是從 React Testing LIbrary 中引入的,其內部又是基於 DOM Testing Library 的 fireEvent
為 React
做了一些相容性改動。
其使用方式非常方便, 有 fireEvent(node, event)
或者 fireEvent(node, eventProperties)
兩種使用方式
fireEvent.change(getByLabelText(/picture/i), {
target: {
files: [new File(['(⌐□_□)'], 'chucknorris.png', {type: 'image/png'})],
},
});
fireEvent(getByLabelText(/picture/i), new Event('change',
{bubbles: true, cancelable: false})
);
在 DOM Testing Library 中 fireEvent
的實現, 也是透過 dispatchEvent
來做的
function fireEvent(element, event) {
return getConfig().eventWrapper(() => {
if (!event) {
throw new Error(
`Unable to fire an event - please provide an event object.`,
)
}
if (!element) {
throw new Error(
`Unable to fire a "${event.type}" event - please provide a DOM element.`,
)
}
return element.dispatchEvent(event)
})
}
那我們看看 React Testing Library 中又做了啥,它其實是對 fireEvent
加了層 act
包裹,這也是我們能直接使用的原因, fireEvent
時切記不要再手動包裹 act
了
configureDTL({
eventWrapper: cb => {
let result
act(() => {
result = cb()
})
return result
},
// 略
})
我們實際開發中經常會時不時的遇到 act
的飄紅報錯,看到報錯提示我們不經意間就加了個 act
上去,但這樣其實是沒用的。
比如下面這個案例:
test("test act warning", () => {
const Foo = () => {
const [text, setText] = useState('text1')
useEffect(() => {
Promise.resolve().then(() => setText('text2'))
}, [])
return <div>
<span>{text}</span>
<div>haha</div>
</div>
}
const { getByText, debug } = render(<Foo />);
const text = getByText('haha');
expect(text).toBeInTheDocument()
debug()
})
當我們程式碼中進行非同步請求時,並在測試完成後或者 act 執行完成後,再在回撥中進行 setState
則會導致報錯。
上面這段程式碼想要修復報錯,有很多方式,如可以在測試結束前進行等待來解決,或者直接乾脆把非同步請求的返回mock
掉,不進行setState
。
最佳化後的程式碼
test("test act warning", async () => {
const Foo = () => {
const [text, setText] = useState('text1')
const fn =
useEffect(() => {
Promise.resolve().then(() => setText('text2'))
}, [])
return <div>
<span>{text}</span>
<div>haha</div>
</div>
}
const { getByText, findByText } = render(<Foo />);
const text = getByText('haha');
expect(text).toBeInTheDocument()
// 等待後再結束測試
await findByText('text2')
})
又或者使用非同步的act
, 在初次 render
時 手動包裹一層 act
, act
是支援巢狀使用的。這在初始化元件時請求非同步資料很有用
await act(async () => render(<Foo />) )
const text = screen.getByText('text2');
expect(text).toBeInTheDocument()
如果我再加入個非同步任務,結果又如何?
useEffect(() => {
Promise.resolve().then(() => setText('text2')).then(() => setText('text3'))
}, [])
答案是:tex3
userEvent
userEvent
是 testing library 的單獨一個測試包,需要從@testing-library/user-event
中引入。
與 fireEvent
不同的是,該包是完全以模擬使用者的真實行為去觸發事件的。
fireEvent
是瀏覽器低階dispatchEvent
API 的輕量級包裝器,它允許開發人員觸發任何元素上的任何事件。問題在於,瀏覽器通常不僅僅為一次互動觸發一個事件。例如,當使用者在文字框中鍵入內容時,必須聚焦該元素,然後觸發鍵盤的輸入事件。userEvent
其實就是真實模擬了使用者使用時的互動方式。
下面是個簡單的輸入案例。
test("test userEvent", async () => {
const onChange = jest.fn();
const onFocus = jest.fn();
const onClick = jest.fn();
const { getByPlaceholderText } = render(
<input
placeholder="請輸入"
type="textarea"
onChange={onChange}
onFocus={onFocus}
onClick={onClick}
/>
);
const input = getByPlaceholderText("請輸入");
await userEvent.type(input, 'hello');
expect(onChange).toHaveBeenCalled();
expect(onFocus).toHaveBeenCalled();
expect(onClick).toHaveBeenCalled();
expect(input).toHaveDisplayValue('hello')
})
需要注意的是,userEvent
由於模擬了一系列操作,需要以非同步的形式呼叫才能獲取結果。
userEvent
還提供了很多其他的模擬操作,如複製貼上,模擬鍵盤打字,模擬檔案上傳等等使用者互動場景。
值得注意的是,testing-library 官方是推薦我們在大多數情況下應優先考慮使用 userEvent
而非 fireEvent
的,因為您的測試越接近軟體的使用方式,它們就越能給您帶來信心。
產品中的一些反模式
- 不要再在無用的地方加
cleanup
了**。
首先為何需要有 cleanup
清除函式?
在一個單測檔案中我們可能有多個test
,每個測試例項渲染自己的元件,但是window.document
只有一個,每次 render()
都會往body
下新增一個div
作為根容器,我們保證測試自己的元件時 body
下是無子節點的避免影響。cleanup
會對當前所有掛載的React根元件進行unmount
, 並移除對應的元素。
function cleanup() {
mountedRootEntries.forEach(({root, container}) => {
act(() => {
root.unmount()
})
if (container.parentNode === document.body) {
document.body.removeChild(container)
}
})
mountedRootEntries.length = 0
mountedContainers.clear()
}
為何不需要再 cleanup
了?RTL中自動幫我們調了。
何時才需要 cleanup
?
同一個測試塊中,render
了多次使其掛載了多個元件根節點, 也就是在test
程式碼塊內進行呼叫。
錯誤的使用
waitFor
waitFor
中語句要有拋錯的能力才有實際意義let el = null await waitFor(() => { el = document.querySelector('.xxx') })
await waitFor(() => { fireEvent.click(el); expect(el).toBeDisabled(); })
為
fireEvent
包裹無意義的act
// 錯誤的 act(() => { fireEvent.click(el) }) // 可能正確的方式 await act(async () => { fireEvent.click(el) })
tips: 更多的反模式參考一些常見的RTL錯誤
參考:
https://www.robinwieruch.de/react-testing-library/
https://testing-library.com/docs/queries/about#priority
最後
歡迎關注【袋鼠雲數棧UED團隊】\~\
袋鼠雲數棧 UED 團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎 star