Introduction & 前言
開始寫程式之後,是不是常常被問你該怎麼確保程式碼沒問題?
開始寫程式之後,是不是總想著怎麼不用再透過手動的方式確保程式碼沒問題?
開始寫程式之後,是不是常常被問,你會不會寫測試?
來!看這篇,這是一篇關於 React.js + TypeScript 為主的測試
整篇文章是筆者自己的心得,如果文內有錯誤的地方,還請盡情指出。
該篇文章參考了其他的教學,加上自己碰到的坑,整理出心得並作為筆記釋出,其中也補足了其他教學文中少的部分,而看文章的你一定會有自己的需求,請先別急,看完文章後請截取自己需要的部分,如果你覺得這篇文章對你有幫助,還請將文章分享給更多人知道,或者結合自己的心得,在發布其他更多的文章幫助更多的開發者。
由於筆者使用的專案建構工具為 Vite ,但因為先前曾經試過一次 Vitest 碰到些許問題,這邊就使用較為單純的 jest 進行測試,如果各位有興趣,筆者之後也會再繼續研究並且發布相關筆記介紹關於 Vitest ,但是本篇文章不會跟 Vite 有什麼關係
如果你的專案不是使用 TypeScript ,中間會有些設定需要改掉,例如檔案結尾為
.ts
,請自行改為.js
,如果有使用到相關套件例如 @babel/preset-typescript 可以不用安裝,但整體建議專案是 TypeScript 的朋友在完全按照文章步驟走
Summary & 摘要
本篇文章預設學習前的基本條件需求:
- 會 Node.js(NPM)、終端機、及 React.js 的使用
- 會 TypeScript(以下將會開始簡稱 TS)
- 一顆熱忱的心 -> 超級重要,碰到坑千萬不要氣餒
本篇文章將有以下幾個步驟:
前前前言
在開始寫前端後的幾年,常常被問到是不是有在寫測試,加上筆者目前所待的公司過去曾經盛行測試文化,在後端的開發幾乎是以 TDD(註1) 為核心
在現職的公司前陣子與主管討論後,決定在前端導入測試,一直沒有導入的原因其實一部分是因為團隊專案在初期,很多東西還沒有制式規格,滿長會碰到需要更改核心或商業邏輯的情況
在團隊人員開始增長情況下,變得比較有餘力去做這件事情,同時因為近期面臨到需要帶領前端團隊人員一起成長的情況,決定透過測試下手
因為我們是先開發專案到一半,才決定導入測試,所以這邊我們就不講 TDD ,但是在文章末可以提及一下目前團隊專案 React.js 的架構概念
註1:TDD 全名為 Test-Driven Development,中文翻譯為『測試驅動開發』,簡單說核心概念為『先寫測試,在寫開發』,相關文章可參考 TDD 是什麼?認識 Test-Driven Development(測試驅動開發)
測試介紹
這邊要先說一下對於測試通常我們有幾個測試的點,其中我們最想確保的是程式碼沒有問題,所以我們應該要測試的邏輯部分是在比較核心的商業邏輯層,不應該是 View 層,除非今天你很確定畫面不會再改變了,不然測試寫好了,突然老闆覺得這樣比較好,改版後,客戶又覺得那樣比較好,你會開始覺得測試是不是只是絆腳石
如果有興趣想知道測試通常有哪些分類,可以上網查詢一下,資源非常的多,這邊就提幾個比較長被拿來討論的
單元測試(Unit testing)
- 最小的測試單位,盡量撰寫最基本的測試,不應該過於複雜。單元測試主要針對單一功能或模組進行測試,確保其獨立運作正常。
整合測試(Integration testing)
- 測試多個模組或系統組件之間的互動,確保它們能夠正確地協同工作。整合測試通常在單元測試之後進行。
端對端測試(End-to-end testing、E2E testing)
- 模擬真實用戶操作,從頭到尾測試整個應用程式的工作流程。端對端測試通常涵蓋前端和後端,確保整個系統的功能正常。
冒煙測試(Smoke Test)
- 簡單且快速的測試,檢查應用程式的基本功能是否正常運作。冒煙測試通常在每次新版本發布前進行,以確保沒有重大問題。
回歸測試(Regression Test)
- 在應用程式進行修改或更新後,重新執行先前的測試案例,確保新變更沒有引入新的錯誤或破壞現有功能。回歸測試有助於維持系統的穩定性。
在本篇文章內我們是從單元測試為目標去進行操作的,如果你的目的不同,那測試的內容也會不一樣
安裝環境
先說一下筆者的環境,以及建議使用的環境
- npm >=10.7.0
- node >=20.14.0
- typescript >=5.6.2
這邊就不講解怎麼建立一個新的環境,預設情況下你已經有一個 React.js 專案,我們就直接開始安裝相關的測試套件吧
1 | npm i -D jest ts-jest babel-jest @types/jest @babel/preset-env @babel/preset-react @babel/preset-typescript ts-node @testing-library/react @testing-library/user-event identity-obj-proxy @testing-library/jest-dom jest-environment-jsdom |
因為一次安裝全部的相關套件,所以可能會覺的非常多,但接下來會解釋一下這些套件作用
- jest
- 測試的主要核心,如果不安裝,無法使用
npm run jest
- 測試的主要核心,如果不安裝,無法使用
- ts-jest
- 在需要測試的專案我們會有一份 jest.config.js(ts) 的檔案,如果你不是使用 ts 可以不用使用這個,請安裝 jest
- @types/jest
- 在測試中我們會寫 describe、it、test、jest… 等等,如果不安裝 babel-jest 會導致這些 TS 定義都找不到,IDE 上會出現紅色蚯蚓,編譯也不會成功,如果不想要安裝這個套件可以改用 @jest/globals 取代
- @babel/preset-env
- 放在 babel.config.js(ts) 中的,透過 babel(註2) 主要用來轉譯比較新的 JS(ES) ,讓舊版瀏覽器能夠讀懂
- @babel/preset-react
- 同 @babel/preset-env
- @babel/preset-typescript
- 同 @babel/preset-env
- ts-node
- 用於在 Node.js 環境中直接執行 TS 代碼
- @testing-library/react
- 測試中我們會用到類似於 render、screen 這種方法,它是 React 測試庫,提供操作 Jest 以及網頁 DOM(註3) 的方法
- @testing-library/user-event
- 模擬使用者操作的事件,例如 React-5 测试模拟用户事件
- identity-obj-proxy
- 轉譯 CSS 相關資源,用來講 CSS 模組映射為簡單對象,詳細套件解釋可參考 identity-obj-proxy github
- @testing-library/jest-dom
- 用來解決
Property ‘toBeInTheDocument’ does not exist on type ‘Matchers’
- 用來解決
- jest-environment-jsdom
- Jest 的測試環境,模擬瀏覽器 DOM 環境,如果不安裝,會出現
Test environment jest-environment-jsdom cannot be found. Make sure the testEnvironment configuration option points to an existing node module.
錯誤
- Jest 的測試環境,模擬瀏覽器 DOM 環境,如果不安裝,會出現
這些套件都可以在指令加上 -D
或 --save-dev
安裝到 devDependencies 去,我們只有在開發中會使用到
註2:babel 是什麼?
註3:文件物件模型 (DOM)
環境設置
環境安裝結束後,我們要開始設置一些設定配置,首先打開 package.json 然後加上指令到 scripts
1 | { |
基本上我們通常只會用到 npm run test
,不太會用 jest –watch ,後者是會一直啟動測試監聽,只要改一行測試程式碼,就會重跑一次測試,會耗費比較多資源
之後我們在本地建立 jest.config.ts 或者在終端機輸入 jest --init
(註4),前者的方式請貼下以下內容,如果是後者的話可以按照問答一步一步回答,之後就會新增一個 jest.config.ts 的檔案,內容也可以補上跟下面差不多的內容
1 | /** |
上面內容主要修改部分為
- 如果你使用 TS 請把 Config 引入定義改為 JestConfigWithTsJest ,如果不是可以繼續使用 jest 引入的 Config
- moduleNameMapper 的部分之後有用到第三方套件也都可以往內補資訊,功用是在跑每一個測試的時候,都會先去跑過一次這邊的檢查,有 Mapping 到的 key ,就會去使用 value 處引入的檔案,等等我們會繼續新增這塊,這邊可以先照上面寫
- transform 的部分是我們要帶入 babel 轉譯,因為我們測試檔案內會有許多較新的 ES 寫法
再來我們需要建立幾個檔案,首先一樣在專案根目錄建立 babel.config.ts
再次提醒,如果不是使用 TS ,檔案跟設定請自行修改為 .js 以及非 ts 的設定,後續將不會再繼續提醒
如果專案是使用 .ts 請輸入
1 | export default { |
如果專案是使用 .js 請輸入
1 | module.exports = { |
提醒:如果專案有使用 TS ,記得要把該檔案加入到 tsconfig.json 的 include
再來建立資料夾 test/__mocks__
然後在這兩層的底下再新增一個檔案 fileMock.ts ,內容為下
1 | // TS 請使用 |
新增這個檔案用意主要是碰到 \\.(gif|ttf|eot|svg|png)$
這些靜態資源的時候,通常這些不是我們測試的重點,可以直接使用模擬的方式將靜態資源載入。
接下來一樣在 test
資料夾底下新增 jest.setup.ts ,內容為下
1 | // 解決 Property ‘toBeInTheDocument’ does not exist on type ‘Matchers’ |
這個部分主要是想避免使用 toBeInTheDocument()
的時候會出現錯誤
註4:關於
jest --init
的配置介紹,可以參考 Jest:建置測試環境 (包含 Babel) 的章節 Jest Config
撰寫基礎測試
這時候我們可以先來小試身手,先來一個簡單的測試
在專案根目錄底下建立 __test__/utils
資料夾,並且在底下建立 sum.test.ts ,內容輸入
1 | const sum = (a: number, b: number) => a + b; |
如果有出現 describe、it 找不到的情況,需要再檢查一下 @types/jest 是否正常安裝成功,如果有問題可以刪掉 node_modules 重新安裝試試看
如果真的不行,下下策可以考慮安裝 @jest/globals ,但需要在測試檔案開頭處都引入該套件
1 | import { describe, expect, it } from '@jest/globals'; |
接著可以執行 npm run test
跑跑看測試
如果 IDE 有提供測試功能,也可以直接在 IDE 上面點擊,如 VSCode 為例,可以點擊綠色勾勾的地方,在還沒跑測試之前應該是播放案件,這邊不要去點 Run(Vitest) ,因為我們不是使用 Vitest
1 | Test Suites: 1 passed, 1 total |
如果看到 1 passed, 1 total
就代表通過了,如果有寫錯,在終端機也會爆出錯誤
撰寫 React.js 測試
基本上普通的測試應該大家都能寫也會寫,真正難的通常都是自己開始動工後,就像以前考試老師說的我都懂…
首先我們先建立一支普通的 React.js Component ,這邊筆者使用專案已經存在的 Link 元件作為範例,同時也會告訴你碰到的坑
先在專案內建立該元件,並填入以下內容
1 | import clsx from 'clsx'; |
同時在 __test__
資料夾底下新增 components
資料夾,並且新增
Link.test.tsx 檔案,內容為下
1 | // 雖然有在設定檔 jest.setup.ts 全域引入,如果 IDE 有出現錯誤,這邊需要再度引入才不會出現紅色蚯蚓,如果沒有出現錯誤就不用在引入 |
在 jest.setup.ts 我們有配置 @testing-library/jest-dom 的引入,跑編譯的情況下會通過,但是 IDE(註6) 可能還是會出錯誤,找不到 toBeInTheDocument()
如果 IDE 有出現錯誤,這邊我們還是必須要加上這個引用,如果沒有可以不用加入這個引入,可以嘗試刪除或者加入跑跑看 npm run test
雖然前面提到我們是想跑單元測試,但這邊我們要先確保元件可以正常執行,且我們可能會測試使用者模擬點擊的情況,所以在測試中我們會去渲染 React Component
接著執行
1 | npm run test |
上面的測試實際執行後會發現測試出錯了,出現了好一大串錯誤,仔細看一下中間的部分,會發現錯誤的部分似乎是落在 clsx(註7) 這個套件
開始寫測試後,你會發現常常會碰到一些第三方套件的引入,對我們來說,第三方套件應該在作者實作推出後,就要確跑程式沒問題,所以基本上我們是持著『程式本善』的心情去看待它,所以在測試時我們大部分時候都會把第三方套件 Mock 掉,並且直接改成我們的預期結果
這邊我們需要直接到 test/__mocks__
資料夾底下,新增 clsx.ts 的檔案,內容貼上
1 | export const mockClsx = jest.fn().mockImplementation((...args) => args.join(' ')); |
接著到 jest.config.ts 去,在中間 moduleNameMapper 設定處補上 clsx 的配置,並把剛剛的檔案引入進來
1 | //...略 |
然後再跑一次
1 | npm run test |
恭喜你,這次就應該能透通過了!
之後如果還有碰到測試需要引入第三方的套件,都可以像這樣去使用,下面再額外問題的部份會提到 i18n 的 Mock
註7:關於 clsx 這個套件是用來組合 class 名稱的,也能讓你在 React.js className 裡面寫判斷,甚至是
true && 'text-red-500'
這種寫法
測試覆蓋率
測試跑完之後,你應該會在專案資料夾跟目錄看見 coverage ,這個資料夾打開後可以找到 lcov-report 資料夾,並打開底下的 index.html ,路徑為 coverage/lcov-report/index.html
,打開它
打開後會發現裡面有一個表格,表格下的資料代表的是你的測試有覆蓋到的檔案
在表格的 Title 有幾個種類,意思分別是
File(檔案/文件)
- 只要有覆蓋到測試的檔案都會被列出來
Statements(陳述式)
- 代表該檔案中的陳述式有多少被執行過
- 例如
if(A === true && B === true) {}
加上if (A === true && B === false)
,那麼要執行到所有陳述式,至少要符合 1. A = true && B = true 2. A = true && B = false
Branches(分支)
- 代表所有條件分支
- 覆蓋率 = 已測試的分支 / 總分支數量。
- 若 if 語句的 true 和 false 都有被測試到,則該 if 語句的分支覆蓋率為 100%
Functions(函式)
- 代表該檔案內的函式(function 或 arrow function)被測試的比例
- 覆蓋率 = 被執行的函式數 / 總函式數量
Lines(行數)
- 代表有執行過的程式碼行數與總行數的比例
- 這與 Statements 相似,但更細緻,通常用來衡量某行內是否真的執行
沒有 Title 的欄位
- 被執行的函式數 / 總函式數量
基本上我們比較關注 Statements 再來是 Branches ,樂觀的情況是我們必須要確保要測試的程式碼都要涵蓋在內,這樣才能確保我們的程式碼都受到保護
但實務上我們還是要看我們的目的,並非只是一昧的提高覆蓋率就是棒
最後記得在 .gitignore 內加上 coverage,不要把這份內容上傳了
額外問題
i18n 的 Mock
如果你用 React.js 跑測試,且元件內有使用到 i18n 的話,可能會出現上面的錯誤
依照前面的說明理解,第三方套件要測試我們基本上都是 Mock Mock Mock ,而 Mock 的流程不外乎就是在 test/__mocks__
底下新增要模擬的內容(註8)
新增一支叫做 reactI18next.ts 的檔案,然後內容貼上
1 | export const useTranslation = () => { |
jest.fn()
其實就是模擬一個函式,並且我們可以模擬函式回傳,有興趣可以參考 JEST 單元測試學習筆記 | Mock Functions
對於 i18n 的 test mock 其實還有很多方式,可以參考 react-i18n documentation - Testing
接著我們再去 jest.config.ts 補上設定,補上 react-i18next 的設定如下
1 | //...略 |
之後再跑一次測試
1 | npm run test |
這時候可以看到測試就能通過了,上面範例有紅色的部分是因為目前測試還沒有涵蓋到這部分,可以略過紅色部分,而有一些黃色的部分也是測試希望你能把涵蓋率補高一點
註8:請注意,如果是測試會常常跑到的第三方套件,建議在新增在這邊,然後透過 jest.config.ts 的 moduleNameMapper 去引入,但如果只是某幾個或是某一個測試會用到,就直接在測試檔案裡面使用 mock 吧
React.js 架構探討
這部分沒興趣可以跳過,這邊比較多部分會講解概念,不會實作太多程式碼
前面有提到,之前有跟主管在探討 React.js 的部分能不能做一些系統性規劃,對於後端來說,他們可以比較輕易的整理出 MVC(註9) 架構,但前端的程式碼大部分的工程師都像是義大利麵一樣,寫成一團
在近幾年開始有 Component 拆分後,有慢慢比較好一點,但很多東西還是混在一起,這就會造成測試困難,耦合太嚴重
在與主管討論之後,其實目前我們的專案最基本最基本就是把 View 跟 邏輯 拆開,這是絕對守則,唯有如此,才能跑測試,後續修改也比較不麻煩
基本上你看到大部分得程式碼,都會把邏輯寫在 Component 內,就算該工程師已經把該檔案拆的十分乾淨了,如下
1 | import { useState } from 'react'; |
來自官網的範例 Basic useState examples
這種情況如果今天你想要單純測試 handleClick ,勢必要 Counter 整個一起放進來測試,但如果今天需求變了,想要把這個畫面做點調整,很大機率會跟你的測試發生碰撞
比較好得做法是都把邏輯丟進去另一支檔案,而進行邏輯操作的部分我們統稱為 Controller ,另外因為 React.js 的 custom hook(註10) 概念,在這邊筆者都將他們命名為 useXXXController
我們可以建立一支 useCounterController.ts 的檔案,內容不涉及任何 View 的部分,把上面檔案改為如下
1 | export default function Counter() { |
在新增的 useCounterController.ts 檔案寫上
1 | import { useState } from 'react'; |
如此一來就能去單獨測試我們的 useCounterController.ts 了,後續要更改 View 的部分也不用擔心會影響到邏輯部分
另外,大部分工程師都習慣在 Controller 直接去 fetch 或者透過 axios 請求資料
依照 SOLID(註11) 的依賴反轉概念,高層次的模組不應該依賴於低層次的模組,所以我們應該將打 API 的部分抽離出來封裝成一個 Server 層,這部分是低層次的模組,當低層次的模組要進行更換時,例如 Fetch 更換為 Axios 或 XHR ,我們都不應該擔心是不是 Controller 的高層次模組會壞掉,只要專心修改 Server 層就好,透過 Redux 的 Thunk 可以做到這點
基本上目前的 Controller 及 View 拆分,應該能滿足基本的單元測試需求
註9:MVC 模試?
Conclusion & 結論
之前一直想了好久要引入測試,途中也嘗試了 Vitest ,但是光配置就被搞死了,後來因為團隊夥伴也剛好安排到測試的這一項目標,所以就一起研究了,沒想到這次單純使用 jest 去跑 test 還滿順利的,環境相對單純多了
在測試的部分其實還缺少對於 Redux 或者 API 的測試,後續如果有做到這一塊,筆者也會慢慢補上來,希望目前的部分能幫助到需要幫助的人
測試的路上你我不孤單,但還是祝福每間公司需求都明確,不要三五一小改,敏捷開發當真敏捷開發