RexHung's Blog

塵世中一個前端迷途小書僮!

0%

[React Note] — React.js Test 深入淺出 ft. TypeScript

Introduction & 前言

Test

背景來至 unsplash.com 的 Christopher Gower

開始寫程式之後,是不是常常被問你該怎麼確保程式碼沒問題?

開始寫程式之後,是不是總想著怎麼不用再透過手動的方式確保程式碼沒問題?

開始寫程式之後,是不是常常被問,你會不會寫測試?

來!看這篇,這是一篇關於 React.js + TypeScript 為主的測試

整篇文章是筆者自己的心得,如果文內有錯誤的地方,還請盡情指出。

該篇文章參考了其他的教學,加上自己碰到的坑,整理出心得並作為筆記釋出,其中也補足了其他教學文中少的部分,而看文章的你一定會有自己的需求,請先別急,看完文章後請截取自己需要的部分,如果你覺得這篇文章對你有幫助,還請將文章分享給更多人知道,或者結合自己的心得,在發布其他更多的文章幫助更多的開發者。

由於筆者使用的專案建構工具為 Vite ,但因為先前曾經試過一次 Vitest 碰到些許問題,這邊就使用較為單純的 jest 進行測試,如果各位有興趣,筆者之後也會再繼續研究並且發布相關筆記介紹關於 Vitest ,但是本篇文章不會跟 Vite 有什麼關係

如果你的專案不是使用 TypeScript ,中間會有些設定需要改掉,例如檔案結尾為 .ts,請自行改為 .js,如果有使用到相關套件例如 @babel/preset-typescript 可以不用安裝,但整體建議專案是 TypeScript 的朋友在完全按照文章步驟走


Summary & 摘要

本篇文章預設學習前的基本條件需求:

  • 會 Node.js(NPM)、終端機、及 React.js 的使用
  • 會 TypeScript(以下將會開始簡稱 TS)
  • 一顆熱忱的心 -> 超級重要,碰到坑千萬不要氣餒

本篇文章將有以下幾個步驟:

  1. 前前前言
  2. 測試介紹
  3. 安裝環境
  4. 環境設置
  5. 撰寫基礎測試
  6. 撰寫 React.js 測試
  7. 額外問題
  8. 測試覆蓋率
  9. React.js 架構探討

前前前言

在開始寫前端後的幾年,常常被問到是不是有在寫測試,加上筆者目前所待的公司過去曾經盛行測試文化,在後端的開發幾乎是以 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
  • 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. 錯誤

這些套件都可以在指令加上 -D--save-dev 安裝到 devDependencies 去,我們只有在開發中會使用到

註2:babel 是什麼?

註3:文件物件模型 (DOM)


環境設置

環境安裝結束後,我們要開始設置一些設定配置,首先打開 package.json 然後加上指令到 scripts

1
2
3
4
5
6
7
8
9
{
//...略
"scripts": {
//...其他指令
"test": "jest",
"test:watch": "jest --watch"
},
//...略
}

基本上我們通常只會用到 npm run test,不太會用 jest –watch ,後者是會一直啟動測試監聽,只要改一行測試程式碼,就會重跑一次測試,會耗費比較多資源

之後我們在本地建立 jest.config.ts 或者在終端機輸入 jest --init(註4),前者的方式請貼下以下內容,如果是後者的話可以按照問答一步一步回答,之後就會新增一個 jest.config.ts 的檔案,內容也可以補上跟下面差不多的內容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
/**
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/

// import type {Config} from 'jest';
import type { JestConfigWithTsJest } from 'ts-jest';

import babelConfig from './babel.config';

const config: JestConfigWithTsJest = {
// All imported modules in your tests should be mocked automatically
// automock: false,

// Stop running tests after `n` failures
// bail: 0,

// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/y9/v8qqkg955_z3wg6qqtsqs0p00000gn/T/jest_dx",

// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,

// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,

// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,

// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',

// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],

// Indicates which provider should be used to instrument code for coverage
// coverageProvider: "babel",

// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],

// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,

// A path to a custom dependency extractor
// dependencyExtractor: undefined,

// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,

// The default configuration for fake timers
// fakeTimers: {
// "enableGlobally": false
// },

// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],

// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,

// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,

// A set of global variables that need to be available in all test environments
// globals: {},

// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",

// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],

// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "mjs",
// "cjs",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],

// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1', // 解決 alias 問題
'\\.(css|less|sass|scss)$': 'identity-obj-proxy',
'\\.(gif|ttf|eot|svg|png)$': '<rootDir>/test/__mocks__/fileMock.ts',
},

// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],

// Activates notifications for test results
// notify: false,

// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",

// A preset that is used as a base for Jest's configuration
// preset: undefined,

// Run tests from one or more projects
// projects: undefined,

// Use this configuration option to add custom reporters to Jest
// reporters: undefined,

// Automatically reset mock state before every test
// resetMocks: false,

// Reset the module registry before running each individual test
// resetModules: false,

// A path to a custom resolver
// resolver: undefined,

// Automatically restore mock state and implementation before every test
// restoreMocks: false,

// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,

// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],

// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",

// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],

// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
setupFilesAfterEnv: ['<rootDir>/test/jest.setup.ts'],

// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,

// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],

// The test environment that will be used for testing
testEnvironment: 'jsdom',

// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},

// Adds a location field to test results
// testLocationInResults: false,

// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],

// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],

// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],

// This option allows the use of a custom results processor
// testResultsProcessor: undefined,

// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",

// A map from regular expressions to paths to transformers
// transform: undefined,

// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],

transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig,
},
],
},

// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,

// Indicates whether each individual test should be reported during the run
// verbose: undefined,

// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],

// Whether to use watchman for file crawling
// watchman: true,
};

export default config;

上面內容主要修改部分為

  • 如果你使用 TS 請把 Config 引入定義改為 JestConfigWithTsJest ,如果不是可以繼續使用 jest 引入的 Config
  • moduleNameMapper 的部分之後有用到第三方套件也都可以往內補資訊,功用是在跑每一個測試的時候,都會先去跑過一次這邊的檢查,有 Mapping 到的 key ,就會去使用 value 處引入的檔案,等等我們會繼續新增這塊,這邊可以先照上面寫
  • transform 的部分是我們要帶入 babel 轉譯,因為我們測試檔案內會有許多較新的 ES 寫法

再來我們需要建立幾個檔案,首先一樣在專案根目錄建立 babel.config.ts

再次提醒,如果不是使用 TS ,檔案跟設定請自行修改為 .js 以及非 ts 的設定,後續將不會再繼續提醒

如果專案是使用 .ts 請輸入

1
2
3
export default {
presets: [['@babel/preset-env', { targets: { node: 'current' }, modules: false }], ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript'],
};

如果專案是使用 .js 請輸入

1
2
3
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' }, modules: false }], ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript'],
};

提醒:如果專案有使用 TS ,記得要把該檔案加入到 tsconfig.jsoninclude

再來建立資料夾 test/__mocks__ 然後在這兩層的底下再新增一個檔案 fileMock.ts ,內容為下

1
2
3
4
5
// TS 請使用
export default 'test-file-stub';

// JS 請使用
// module.exports = 'test-file-stub';

新增這個檔案用意主要是碰到 \\.(gif|ttf|eot|svg|png)$ 這些靜態資源的時候,通常這些不是我們測試的重點,可以直接使用模擬的方式將靜態資源載入。

接下來一樣在 test 資料夾底下新增 jest.setup.ts ,內容為下

1
2
3
// 解決 Property ‘toBeInTheDocument’ does not exist on type ‘Matchers’
// 測試檔案內如果有用到 toBeInTheDocument 需要再另外單獨引入,不然雖然測試會成功,但是編輯器會出現紅色蚯蚓
import '@testing-library/jest-dom';

這個部分主要是想避免使用 toBeInTheDocument() 的時候會出現錯誤

註4:關於 jest --init 的配置介紹,可以參考 Jest:建置測試環境 (包含 Babel) 的章節 Jest Config


撰寫基礎測試

這時候我們可以先來小試身手,先來一個簡單的測試

在專案根目錄底下建立 __test__/utils 資料夾,並且在底下建立 sum.test.ts ,內容輸入

1
2
3
4
5
6
7
const sum = (a: number, b: number) => a + b;

describe('sum module', () => {
it('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
});

如果有出現 describe、it 找不到的情況,需要再檢查一下 @types/jest 是否正常安裝成功,如果有問題可以刪掉 node_modules 重新安裝試試看

如果真的不行,下下策可以考慮安裝 @jest/globals ,但需要在測試檔案開頭處都引入該套件

1
2
3
4
5
import { describe, expect, it } from '@jest/globals';

const sum = (a: number, b: number) => a + b;

//...略

接著可以執行 npm run test 跑跑看測試

SUM Test

如果 IDE 有提供測試功能,也可以直接在 IDE 上面點擊,如 VSCode 為例,可以點擊綠色勾勾的地方,在還沒跑測試之前應該是播放案件,這邊不要去點 Run(Vitest) ,因為我們不是使用 Vitest

RUN Test

1
2
3
4
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 6.251 s

如果看到 1 passed, 1 total 就代表通過了,如果有寫錯,在終端機也會爆出錯誤


撰寫 React.js 測試

基本上普通的測試應該大家都能寫也會寫,真正難的通常都是自己開始動工後,就像以前考試老師說的我都懂…

首先我們先建立一支普通的 React.js Component ,這邊筆者使用專案已經存在的 Link 元件作為範例,同時也會告訴你碰到的坑

先在專案內建立該元件,並填入以下內容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import clsx from 'clsx';
import { FaExternalLinkAlt } from 'react-icons/fa';

interface Props {
children: React.ReactNode;
link?: string;
className?: string;
showIcon?: boolean;
iconClassName?: string;
target?: '_blank' | '_self' | '_parent' | '_top';
}

const Link = ({ children, link = '', className = '', showIcon = false, iconClassName = '', target = '_self' }: Props) => {
return (
<a href={link ? link : undefined} className={clsx(className, 'text-gray-400 hover:text-white')} target={target}>
<span className='inline-flex items-center'>
{children} {showIcon && <FaExternalLinkAlt className={clsx('text-xs ml-1', iconClassName)} />}
</span>
</a>
);
};

export default Link;

同時在 __test__ 資料夾底下新增 components 資料夾,並且新增
Link.test.tsx 檔案,內容為下

1
2
3
4
5
6
7
8
9
10
11
12
13
// 雖然有在設定檔 jest.setup.ts 全域引入,如果 IDE 有出現錯誤,這邊需要再度引入才不會出現紅色蚯蚓,如果沒有出現錯誤就不用在引入
import '@testing-library/jest-dom';

import { render, screen } from '@testing-library/react';

import Link from '@/components/Link/index';

describe('Link Component Test', () => {
it('should render correctly', () => {
render(<Link link='/'>Home Page</Link>);
expect(screen.getByText('Home Page')).toBeInTheDocument();
});
});

jest.setup.ts 我們有配置 @testing-library/jest-dom 的引入,跑編譯的情況下會通過,但是 IDE(註6) 可能還是會出錯誤,找不到 toBeInTheDocument()

如果 IDE 有出現錯誤,這邊我們還是必須要加上這個引用,如果沒有可以不用加入這個引入,可以嘗試刪除或者加入跑跑看 npm run test

雖然前面提到我們是想跑單元測試,但這邊我們要先確保元件可以正常執行,且我們可能會測試使用者模擬點擊的情況,所以在測試中我們會去渲染 React Component

接著執行

1
npm run test

Third part plugin error

上面的測試實際執行後會發現測試出錯了,出現了好一大串錯誤,仔細看一下中間的部分,會發現錯誤的部分似乎是落在 clsx(註7) 這個套件

開始寫測試後,你會發現常常會碰到一些第三方套件的引入,對我們來說,第三方套件應該在作者實作推出後,就要確跑程式沒問題,所以基本上我們是持著『程式本善』的心情去看待它,所以在測試時我們大部分時候都會把第三方套件 Mock 掉,並且直接改成我們的預期結果

這邊我們需要直接到 test/__mocks__ 資料夾底下,新增 clsx.ts 的檔案,內容貼上

1
2
export const mockClsx = jest.fn().mockImplementation((...args) => args.join(' '));
export default mockClsx;

接著到 jest.config.ts 去,在中間 moduleNameMapper 設定處補上 clsx 的配置,並把剛剛的檔案引入進來

1
2
3
4
5
6
7
8
9
10
11
//...略
const config: JestConfigWithTsJest = {
//...略
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1', // 解決 alias 問題
'\\.(css|less|sass|scss)$': 'identity-obj-proxy',
'\\.(gif|ttf|eot|svg|png)$': '<rootDir>/test/__mocks__/fileMock.ts',
'^clsx$': '<rootDir>/test/__mocks__/clsx.ts', // 新增這行
},
//...略
}

然後再跑一次

1
npm run test

Run Test after clsx setting

恭喜你,這次就應該能透通過了!

之後如果還有碰到測試需要引入第三方的套件,都可以像這樣去使用,下面再額外問題的部份會提到 i18n 的 Mock

註6:什麼是 IDE (整合開發環境)?

註7:關於 clsx 這個套件是用來組合 class 名稱的,也能讓你在 React.js className 裡面寫判斷,甚至是 true && 'text-red-500' 這種寫法


測試覆蓋率

Coverage

測試跑完之後,你應該會在專案資料夾跟目錄看見 coverage ,這個資料夾打開後可以找到 lcov-report 資料夾,並打開底下的 index.html ,路徑為 coverage/lcov-report/index.html,打開它

Coverage Report

打開後會發現裡面有一個表格,表格下的資料代表的是你的測試有覆蓋到的檔案

在表格的 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

I18n Error

如果你用 React.js 跑測試,且元件內有使用到 i18n 的話,可能會出現上面的錯誤

依照前面的說明理解,第三方套件要測試我們基本上都是 Mock Mock Mock ,而 Mock 的流程不外乎就是在 test/__mocks__ 底下新增要模擬的內容(註8)

新增一支叫做 reactI18next.ts 的檔案,然後內容貼上

1
2
3
4
5
6
7
8
9
10
11
12
13
export const useTranslation = () => {
return {
t: (key: string) => key,
i18n: {
changeLanguage: jest.fn(),
language: 'en', // 取決於你的 I18n key 叫什麼,這行也可以不用
},
};
};

export default {
useTranslation,
};

jest.fn() 其實就是模擬一個函式,並且我們可以模擬函式回傳,有興趣可以參考 JEST 單元測試學習筆記 | Mock Functions

對於 i18ntest mock 其實還有很多方式,可以參考 react-i18n documentation - Testing

接著我們再去 jest.config.ts 補上設定,補上 react-i18next 的設定如下

1
2
3
4
5
6
7
8
9
10
11
12
//...略
const config: JestConfigWithTsJest = {
//...略
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1', // 解決 alias 問題
'\\.(css|less|sass|scss)$': 'identity-obj-proxy',
'\\.(gif|ttf|eot|svg|png)$': '<rootDir>/test/__mocks__/fileMock.ts',
'^clsx$': '<rootDir>/test/__mocks__/clsx.ts',
'react-i18next': '<rootDir>/test/__mocks__/reactI18next.ts', // 新增這行
},
//...略
}

之後再跑一次測試

1
npm run test

Npm run test after i18n setting

這時候可以看到測試就能通過了,上面範例有紅色的部分是因為目前測試還沒有涵蓋到這部分,可以略過紅色部分,而有一些黃色的部分也是測試希望你能把涵蓋率補高一點

註8:請注意,如果是測試會常常跑到的第三方套件,建議在新增在這邊,然後透過 jest.config.tsmoduleNameMapper 去引入,但如果只是某幾個或是某一個測試會用到,就直接在測試檔案裡面使用 mock


React.js 架構探討

這部分沒興趣可以跳過,這邊比較多部分會講解概念,不會實作太多程式碼

React Flow

前面有提到,之前有跟主管在探討 React.js 的部分能不能做一些系統性規劃,對於後端來說,他們可以比較輕易的整理出 MVC(註9) 架構,但前端的程式碼大部分的工程師都像是義大利麵一樣,寫成一團

在近幾年開始有 Component 拆分後,有慢慢比較好一點,但很多東西還是混在一起,這就會造成測試困難,耦合太嚴重

在與主管討論之後,其實目前我們的專案最基本最基本就是把 View邏輯 拆開,這是絕對守則,唯有如此,才能跑測試,後續修改也比較不麻煩

基本上你看到大部分得程式碼,都會把邏輯寫在 Component 內,就算該工程師已經把該檔案拆的十分乾淨了,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useState } from 'react';

export default function Counter() {
const [count, setCount] = useState(0);

function handleClick() {
setCount(count + 1);
}

return (
<button onClick={handleClick}>
You pressed me {count} times
</button>
);
}

來自官網的範例 Basic useState examples

這種情況如果今天你想要單純測試 handleClick ,勢必要 Counter 整個一起放進來測試,但如果今天需求變了,想要把這個畫面做點調整,很大機率會跟你的測試發生碰撞

比較好得做法是都把邏輯丟進去另一支檔案,而進行邏輯操作的部分我們統稱為 Controller ,另外因為 React.jscustom hook(註10) 概念,在這邊筆者都將他們命名為 useXXXController

我們可以建立一支 useCounterController.ts 的檔案,內容不涉及任何 View 的部分,把上面檔案改為如下

1
2
3
4
5
6
7
8
9
export default function Counter() {
const { count, handleClick } = useCounter();

return (
<button onClick={handleClick}>
You pressed me {count} times
</button>
);
}

在新增的 useCounterController.ts 檔案寫上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useState } from 'react';

const useCounterController = () => {
const [count, setCount] = useState(0);

function handleClick() {
setCount(count + 1);
}

return {
count,
handleClick
}
};

如此一來就能去單獨測試我們的 useCounterController.ts 了,後續要更改 View 的部分也不用擔心會影響到邏輯部分

另外,大部分工程師都習慣在 Controller 直接去 fetch 或者透過 axios 請求資料

依照 SOLID(註11) 的依賴反轉概念,高層次的模組不應該依賴於低層次的模組,所以我們應該將打 API 的部分抽離出來封裝成一個 Server 層,這部分是低層次的模組,當低層次的模組要進行更換時,例如 Fetch 更換為 AxiosXHR ,我們都不應該擔心是不是 Controller 的高層次模組會壞掉,只要專心修改 Server 層就好,透過 ReduxThunk 可以做到這點

基本上目前的 ControllerView 拆分,應該能滿足基本的單元測試需求

註9:MVC 模試?

註10:Reusing Logic with Custom Hooks

註11:使人瘋狂的 SOLID 原則:目錄


Conclusion & 結論

之前一直想了好久要引入測試,途中也嘗試了 Vitest ,但是光配置就被搞死了,後來因為團隊夥伴也剛好安排到測試的這一項目標,所以就一起研究了,沒想到這次單純使用 jest 去跑 test 還滿順利的,環境相對單純多了

在測試的部分其實還缺少對於 Redux 或者 API 的測試,後續如果有做到這一塊,筆者也會慢慢補上來,希望目前的部分能幫助到需要幫助的人

測試的路上你我不孤單,但還是祝福每間公司需求都明確,不要三五一小改,敏捷開發當真敏捷開發


參考網站