從零開始的前端測試

June Hsieh
AsiaYo Engineering
Published in
13 min readJul 1, 2022

若干年前,AsiaYo 網站開始了前後端分離的重構大業。前端技術由原先的 coffee + pug 逐步以 React 翻新。從第一頁以 React 完整重現的旅宿頁面開始,同時逐步建立起共用的元件規範與元件庫(storybook),至今已翻新了 95% 以上。(狂賀🎉)

在新架構與元件庫逐漸完善,行為也收斂到可控範圍的此時,我們開始了本次針對 asiayo.com 前端元件撰寫單元測試 (unit test) 與組合元件的整合測試 (integration test) 的研究!

這篇文章紀錄了從一開始的構想,到中途怎麼思考,如今解決了什麼,哪些仍然懸而未解的,那麼就開始吧!

以 asiayo.com 搜尋元件為起點,開啟元件的測試覆蓋率增長之旅!

對測試的假想

以單元測試來說,我們期望每個基本單位都能符合預期結果。以 AsiaYo 使用 React function component 為主體的架構來看,「元件」是為最基本的運作單位。除了輸入輸出需要符合結果之外,render 的結果、互動的反饋等,都與「元件是否符合預期」有關。

執行前端驗證時,不僅得驗證 1+1=2,還得驗證它是藍底白字的方形,尺寸40*28px,hover 會變色,click 後該跳出警示訊息…等行為,比起驗證 fn(1)=2 要來得複雜。

一般來說,寫測試不外乎是想預防各種錯誤。探究 bug 好發的情境,第一是元件的外觀,在不同使用條件下,部分情境容易出錯 (註1);第二是複雜情境改動時,邊界條件容易出錯且不易察覺 (註2)。那麼想藉由單元測試來預防錯誤的目標看來,除了確保正常流程都能 work,更需要聚焦在上述兩點,以達成:

  1. 元件外觀的各種排列組合,能在測試階段驗證到每一種結果都正確。
  2. 互動流程複雜的元件,覆蓋每條操作路線,確保在改動後不影響原本功能。
註1:以按鈕來說,當元件傳入不同屬性,給予互動後,皆會產生不同 render 結果。
註2:旅宿搜尋框點開來有「地點>日期>人數」三個步驟。第一次點擊台北車站的動作後,下一動是選擇日期。但在選擇人數時按 X 離開後,再次點擊地點搜尋框,選擇台北車站時,將直接前往搜尋結果。這種因為當下資料不同,引發不同行為的情境,較難在開發過程察覺改動後的影響。

工具選擇

由於 AsiaYo 網站是以 React 開發,這次便選了由 React 官方推薦的 Jest React Testing Library 來執行單元/整合測試。該組合以 jsdom 模擬瀏覽器的工作方式,能夠在測試環境中 render 出元件的結構,模擬使用者的操作行為,並 assert 其行為正確。該組合無需開啟瀏覽器即可執行測試,開發和迭代的速度會比較快。

Jest 提供了 mock function 及 expect assertion,可驗證按鈕按下後,該被執行的 callback 有被正確地執行。React Testing Library 則提供了元件 render 方法,並且模擬各種操作行為。

而「元件必須正確 render」這點,則以 react-test-renderer 提供的 snapshot 來做,它的 renderer 能夠將元件當下的 dom 結構轉換為 JSON 記錄成 snapshot 檔來做比對。

不過,影響外觀另一個因素:style,並不包含在其中。若我想確保「按鈕是 48*20px,hover 時會變色」等結果時,雖然能夠利用 jest-dom 提供的 toHaveStyle 來驗證「background: blue; width: 48px; 」這些 style 是否正確,但這種做法過於繁瑣,不太實際。於是又找來 jest-styled-components 輔助,它會根據 snapshot 檔的 dom 結構分派 className,並詳細紀錄對應的 style。

加上 jest-styled-components 的助陣,snapshot 中不僅有 dom 結構,更會重新命名元件的 className,以此對應到該元件吃到的 style 內容。

然而,jsdom 不會完整重現瀏覽器的 render 效果。無論模擬環境中的按鈕是否被 hover,toHaveStyle 的 background 皆不會真正變色。當我想驗證「hover 時按鈕會變成某顏色」這件事時,僅能確定「style 中記錄了 :hover 時是某顏色」,做不到「hover 前後 getComputedStyle() 的顏色有所變化」這件事。多次搜尋這個議題,得到的建議是:關於互動性的外觀變化,還是建議由 visual test 來做。因此決定,對於外觀的驗證,將以 snapshot 的比對為主就好。

至此,我們已經選用了下列工具來執行單元測試/整合測試:

  • Jest
  • React Testing Library
  • react-test-renderer
  • jest-styled-components

執行策略

這次的階段性目標是城市頁搜尋功能整合測試,bottom-up 覆蓋各個基礎元件的單元測試後,接著完成複合元件的整合測試。在實作此核心且互動複雜的功能測試過程中,確立測試的可行範圍。

以搜尋框的第三步驟「選擇入住人數」為例,從 Button 和 NumberInput 元件的單元測試開始,接著驗證 NumberPicker 這種複合元件,最後再看 Modal 內的互動和輸出。

對於基礎元件如:button,input,checkbox 等,設想的驗證項目有:

  • 正確地 render
  • 互動後的 callback 是否如期執行
  • 輸出是否符合預期

而對於一個大型複合元件,如旅宿搜尋的進階篩選條件(見下圖),則預期會有以下方向:

  • 針對可見的操作方式與預期結果進行驗證,比如說篩選按鈕按了必須打開,重設條件後操作內容必須清空,選擇的選項總數必須統計在標題。
  • 幾個從頭操作到尾的流程。從初始狀態,經歷多步驟的操作,在每個變化時驗證狀態,確保每一步都符合預期(如圖)。

實踐與困難

照著策略執行就對了——雖然知道事情不可能跟用想的一樣順利,但碰到的問題著實不少,這邊分享幾點我覺得比較重要的問題和解決過程。

客製自己的 render

在一個行之有年的線上產品中,免不了各種在 global 存取資料的需求,如 global style、i18n、session、router…等。asiayo.com 在 ReactDOM render 的當下直接將上述工具以 Provider 的形式包裝在應用的最外層,為所有元件提供各式不需 import 即可使用的方法。當測試的元件使用到這些共用工具時,便會遇到 undefined error。

元件中使用 styled-component 的 ThemeProvider 提供的 global theme 時,存取不到的狀況。

第一直覺的解法很簡單,只要幫每個元件的測試包上他們需要的 provider 即可解決。

但每寫一個 render 就要包一次,太重工了!這時可以利用 React Testing Library 為 render() 提供的 wrapper 方法來打造測試環境的 customRender

另開一個 test-utils.js,將所有要用到的 provider 包成 customRender,取代原本的 render export。

如此一來,只要在撰寫測試時,改變 render 的引用路徑,便可以得到一組已經帶有各式 Provider 的 render 了!

關於 mock

單元測試的重點是被測試的目標本身,其中引用的套件、方法等並非測試重點的部分,都該被 mock 起來。以下列舉了幾項實作過程中,不 mock 起來便無法進行下去的類型。

  1. window 系列

jest 官方文件提過,某些 jsdom 尚未實作的 dom 方法,必須由 Manual Mock 來讓測試過關。文件中以 window.matchMedia 作為範例,這邊則以 window.scrollTo 為例,見以下程式碼。

window.scrollTo(0, window.pageYOffset)

在執行測試時,會遇到 window.scrollTo 尚未實作的錯誤。

(Error: Not implemented: window.scrollTo)

這時我們只要在測試環境中為 window 補上 scrollTo 方法:

Object.defineProperty(window, 'scrollTo', { value: () => {} })

window.scrollTo 便被 mock 起來了!其他 jsdom 未提供的 window.xxx 也可以如法炮製。

2. CSSTransition

React Transition Group 所提供的 CSSTransition 為漸變動畫提供了簡單好用的方法。下列是 Tooltip 元件的浮現動畫應用。

當我們直接對該元件進行測試時,會跳出由 CSSTransition 來的 undefined error。(TypeError: Cannot read properties of undefined (reading ‘baseVal’))

由於我們不打算驗證動畫的漸變過程,是故上述被 <CSSTransition /> 包裹起來的元件,我們只關注它「出現與否」。Testing Library 對此提供了 mock 的方法:

FakeCSSTransition 僅模擬了 props.in 的結果

3. getBoundingClientRect()

當我在測試環境中試圖以 element.getBoundingClientRect 取得元件的即時寬高時,會發現回傳內容全數為 0。

由於 jest-dom 並不會真的去 render 這些物件,自然不會有寬高。當我需要其中的 width 大於 0 時才能進一步測試時,便可以直接在測項中給予該 element 一份 mock 的 getBoundingClientRect() 結果。

從 act 到 waitFor

Warning: An update to MobileModal inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */

寫到旅宿搜尋框的整合測試時,出現了以上 warning,僅僅是 render 元件的動作也會出現,且照著提示將 render() 內容以 act() 包起也無法解決。

細究 act 的用法,它將 render 或是 update 這類單位行為以 act 包起來,確保在每次 assert 之前,變化已經反應在 dom 上。而 React Testing Library 所提供的 helper 皆已經以 act() 包起,所以對這個引用自 React Testing Library 的 render() 來說,多包一層 act() 毫無助益。

逐步刪減內容比對後,發現 warning 是由 Apollo Client 發出 query 取用 session 的行為所導致。參照 Data Fetching 的作法,非同步的內容必須用非同步的 await act (async () => {})包起,如下。

將 15 行的 await waitFor 用在非同步的結果變動、或是需要一段等待時間的互動,同樣也能夠避免上述 warning 的發生,且能 expect 到正確的結果!

搜尋框的整合測試

以下是前文提過的「旅宿搜尋框」三步驟行為整合測試的其中一條路。雖然選擇目的地的步驟中,沒能成功 mock Algolia Search 的地點搜尋結果,還是能利用了搜尋框本身的行為走到下一步,進而走完三個步驟。

旅宿搜尋框在不帶預設資料時的操作路徑。

心得

寫到這,我認為理解測試工具的難點在於工具太多。光是 React 官方文件裡便介紹了 jest、利用 React Testing Library 來 render,直接使用 react-dom 來 render、react-test-renderer 可以製作 snapshot 的 renderer 等方法,其中互相糾纏的基底,也導致了同檔案內的不同 render 會互相影響出錯的情境,至今還沒有找到原因QQ

因為測試的寫法沒有明確的規範,尤其對前端測試來說,即便某元件的覆蓋率達到 100% ,也不代表覆蓋了所有的交互情境。一開始我相當執著要把所有交互情境都寫好寫滿,但在經歷了窮舉情境導致跑測試時間過長、寫測試與寫功能的時間不成比例等過程後,我開始參照覆蓋率作為完成度的指標——起碼,行數清楚的指出了哪條路徑還需要測試來覆蓋它。

而關於 mock 與 assert 的寫法,其自由程度簡直是門藝術了。由於可調用的方法超多,在某條路囿於限制時,總是能找到其他解法作為替代。這時,瞻仰第三方套件的 Testing 寫法,也是很棒的選擇!

最後想說,剛開始寫測試時,對於撰寫這種「按鈕按了會動」的測試感到無比懷疑,在怎麼寫都會通過的情境下,即便我看了再多次測試的基本教義依然無法說服自己,這些測試真的有用嗎?於是我請示了一位 QA 大大:

有用。但有用的時候不是在測試都跑過的時候,而是在發生錯誤的時候。
這時候發現成本是最小的,而且它在上線後可能很難被發現。

期待測試為我們抓到問題的那一天到來☺️

感謝您的收看,如有任何問題,或是上述問題的解答,都歡迎留言批評指教!

最後是文末跑馬燈!

AsiaYo 中階/資深前端工程師持續擴編招募中(CakeResume),我們的工程師文化是:

  • Technique enthusiasts 熱愛技術,精進自我
  • Play fun 運用所學做出又酷又好玩的東西
  • Problem solving 成為一個 problem solver
  • Team working 透過團隊集思廣益,選擇最好的開發語言及系統架構
  • Give it a try 擁抱改變,錯了就修正

期待你/妳的加入,跟我們一起做出很酷又好玩的東西!

參考資料

  1. React testing documents
  2. Jest documents
  3. Testing Library documents
  4. jest-styled-components
  5. React Testing Library and the “not wrapped in act” Errors
  6. Algolia React InstantSearch github

--

--

June Hsieh
AsiaYo Engineering

前端工程師。兩隻貓。中阮。每天都應該散步。