【Redux Toolkit】 React Testing Library 測試最完整中文指南

2023/07/21閱讀時間約 21 分鐘
Redux with RTL

Redux with RTL

隨著 React 在前端開發中的廣泛應用,我們往往需要進行一系列測試,來確保應用程式的正常運作。

而在 React 測試生態系統中,React Testing Library 成為了方便好用的選擇,因其強調測試應該關注於使用者的操作與觀察元件行為,而不是測試細節實現

本文將分享如何結合 Redux Toolkit 與 React Testing Library,來有效測試 Redux 應用程式。無論你是剛學習測試還是已經熟悉 Redux,希望本文能幫助讀者更了解 Redux Toolkit 和 React Testing Library 的實際用法,讓您的測試寫起來更加輕鬆。

現在,讓我們開始探索這個令人興奮的主題吧!



零、測試的核心原則

在 Redux 官方網站就曾經提到,他們其實是鼓吹「整合測試」。因為多數的情況下,使用者並不在意背後是否使用 Redux,重點在於元件的功能是否正確作動。因此雖然每一個 pure function 一樣可以使用 unit test,但實際上這些內容都能被整合測試覆蓋到。

這裡也推薦兩篇文章,解釋為什麼整合測試特別適合 Redux 測試:


Redux 如何被測試?

React Testing Library 可以被任何的 Test Runner 執行。因此如果使用 CRA 就會預設 Jest;Vite 就會預設為 Vitest 等。而 React Testing Library,就是在模擬 DOM 實際上的顯示與運作。

因此我們可以使用任何的 Test Runner,搭配 Testing Library 來模擬並抓取實際上的 DOM,就能達到測試 React 元件的目的。

在測試的過程中,我們希望實際的邏輯、Action 與 Reducer 都能夠被順利測試,因此我們需要做的方式如下。

  • 將所有的邏輯、Action、Reducer 都使用實際函式。
  • 使用特定導入的 initialState 來模擬初始狀態。
  • 使用 userEvent 來模擬使用者行為。

接著,就讓我們根據上面提到的三個方向,拆解成三個步驟,一步一步整理出完整的測試邏輯吧!




一、設定測試環境

store 設定

store 設定

為了達到這個狀態,我們總共需要設定兩個部分的程式碼,分別是:

  1. 設定 Store 的生成函式
  2. 設定可重複使用 Test Render Function




1.設定 Store 的生成函式

因為我們需要在每一個測試時,都重複生成一個全新的 store,因此我們可以將重新生成的邏輯抽離出來,放在 store 裡面作為備用。

import { combineReducers, configureStore } from '@reduxjs/toolkit'

import cartReducer from '../features/cartSlice'

// 此處我們單純使用 combineReducers,讓我們可以客製化調整 RootState 的種類
const rootReducer = combineReducers({
user: userReducer
})

export const setupStore = preloadedState => {
return configureStore({
reducer: rootReducer,
preloadedState
})
}



2.設定可重複使用的 Render Function

在這個階段,可以透過 Testing Library (後續簡稱 TL)的 render 函式,就像 React 一樣,實際渲染目標的測試元件。但為了避免影響實際 Redux store 的資料,我們需要額外創建一個新的 store 來模擬。

具體的方式,是透過 TL 提供的 render 函式,裡面提供的 Wrapper 屬性,將原本直接渲染在 App.js 裡的 Provider 取代,並模擬傳入的資料。

以下是在 utils 裡程式碼:

// src/utils/test-util.js

import { render } from '@testing-library/react'
import { Provider } from 'react-redux'

// 導入剛剛製作的 store 生成函式
import { setupStore } from '../app/store'

// 導入所需的 Reducer
import cartReducer from '../features/cartSlice'

// 未來會使用的客製化 Render 函式
// render 函式會傳入 ui, options 兩個參數
export function renderWithProviders(
ui, // 此處指的是如 <App /> 等元件
{
// 設定初始化的值
preloadedState = {},
// 當沒有 store 傳入時,自動創建一個新的 store
store = setupStore(preloadedState),
...renderOptions
} = {}
) {
// 建立 Wrapper 元件
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>
}

// 將整個 store 和 render 的資料都回傳
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
}


其中,preloadedState = {} 之所以可以在物件裡使用等號,是因為這是解構賦值的預設寫法,當沒有 preloadedState 被傳入時,就將 preloadedState 預設為 {}

到這裡,我們已經完成 Redux Toolkit 的測試前置作業。接著,我們就來開始撰寫我們的測試吧!




二、為元件撰寫整合測試

Test Your Redux with RTL

Test Your Redux with RTL

為了讓大家更完整了解整個 Redux 測試的運作,採用了 Redux 中最重要的兩個環節:

  • 測試資料渲染
  • 測試異步資料獲取

為了更詳細說明如何將測試放到專案中,我們先了解基礎專案的程式碼背景,以及如何導入測試。此處先簡單利用專案部分的程式碼,說明示範的程式碼架構。



專案基礎介紹

該專案是一個簡單電商平台的狀態管理,使用 Redux Toolkit 來管理。主要有 Cart 元件和 cartSlice 這個 store 共兩個文件。

1. Cart 元件介紹

該元件是位於購物車(Cart.js)的元件,主要的作用是將 store 中的 cartItems,透過 map 渲染至 cartItemList,並將元件渲染出來。

const Cart = (props) => {
const isCartShow = useSelector((state) => state.ui.isCartShow);
const cartItems = useSelector((state) => state.cart.cartItems);

const cartItemList = cartItems.map((item) => (
<CartItem
key={item.id}
item={{
id: item.id,
title: item.title,
quantity: item.quantity,
total: item.quantity * item.price,
price: item.price,
}}
/>
));

return {isCartShow && (
<Card className={classes.cart}>
<h2>Your Shopping Cart</h2>
<ul>{cartItemList}</ul>
</Card>
)};
};

export default Cart;



2. cartSlice 介紹

該 Thunk 是將資料從雲端 fetch 後,將資料更新至 cartItems 這個元素中。主要的檔案來自管理購物車(Cart)的 slice,以下是 cartSlice.js 的程式碼:

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

// 通常 Thunk Creator 會獨自放在 cartAction 這個檔案,但為了簡化就移至 cartSlice 裡
export const fetchOrderData = createAsyncThunk('user/fetchOrderData', async () => {
const response = await fetch('link here...')
return response.data
})

const initialState = {
cartItems: [],
totalQuantity: 0,
status: "idle",
}

const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
addToCart(state, action) {
state.cartItems.push(newItems);
},
removeFromCart(state, action) {
state.cartItems = state.cartItems.filter((item) => item.id !== id);
},
extraReducers: (builder) => {
builder.addCase(fetchCartDataThunk.pending, (state, action) => {
state.status = "loading";
})
.addCase(fetchCartDataThunk.fulfilled, (state, action) => {
state.cartItems = action.payload?.cartItems || [];
state.totalQuantity = action.payload?.totalQuantity || 0;
state.status = "success";
});
},
})

export default userSlice.reducer;


為了不讓程式碼模糊焦點,因此刻意簡化過加入購物車清單中,相同品項的邏輯處理,僅專注在測試結果是否符合預期。

上述的程式碼,主要初始化了用於儲存 cartItems 的陣列、購物車總數 totalQuantity,和目前讀取的狀態 status。

Thunk 一開始會執行 Pending Reducer,將狀態改成 loading。後於 Thunk 完成後,就會觸發後續的 Reducer fulfilled,將資料上傳並更新至 store。




三、RTL 的實際測試流程

接著,我們就可以實際來撰寫我們的測試檔案啦!我們主要會使用的套件有 React Testing LibraryJestMock Service Worker 三個套件。

其中 Mock Service Worker 主要用於模擬 Server 的活動與回應,而不需要真的使用到 Server。可以避免直接使用 Server 導致意外寫入資料,或是讓伺服器擁有額外的壓力。

1. 測試檔案的基礎設定

下面的測試前提,總共做了兩件事,第一件事是設定 Mock Service 預期會獲得的假資料。第二是初始化與強制在每次測試時,重置 Mock Service 的設定。

// 導入 Mock server worker 模擬後端 API
import { rest } from 'msw'
import { setupServer } from 'msw/node'

// 導入 RTL 所需要用的套件
import { screen } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'

// 使用自己創見的 Render Function 而非 RTL 內建的 Render Function
import { renderWithProviders } from '../../../utils/test-utils'
import Cart from '../Cart'

// 建立 DB 的假資料
const fakeData = { cartItems: [...], totalQuantity: 10 };

// 使用 MSW 來截斷發送的 Request,並模擬回傳的資料
// 使用 delay 讓 loading 狀態可以出現
export const handlers = [
rest.get('fetch link here...', (req, res, ctx) => {
return res(ctx.json(fakeData), ctx.delay(150))
})
]

// 設定初始化 Server
const server = setupServer(...handlers)

// 開啟 Mock Server API
beforeAll(() => server.listen())

// 強制在每一次測試時,都重設 Mock Server 狀態
afterEach(() => server.resetHandlers())

// 在結束測試後關閉 Mock Server API
afterAll(() => server.close())



2. 加入測試程式碼

接著,我們使用 Jest 來撰寫我們的測試。Jest 的測試條件式,是以 test 開頭,並在裡面執行測試 AAA 三步驟。

  • Arrange|設定需要測試的元件
  • Act|執行模擬使用者的行為
  • Assert|斷言測試結果是否與預期相同


實際測試範例

下面的程式碼,主要做的三組測試,分別是:

  1. 確認 fetch 前是否正確顯示 “no item”,且 “Fetching items” 不在元件中。
  2. 確認點擊 Submit 按鈕後,畫面的 “no item” 文字消失。
  3. 等待資料 fetch 完,畫面會顯示正確資料。
test('fetches & receives a user after clicking the fetch user button', async () => {
renderWithProviders(<Cart />)

// 確認存在 "no item",但不存在 "Fetching items" 文字
expect(screen.getByText(/no item/i)).toBeInTheDocument()
expect(screen.queryByText(/Fetching items\\.\\.\\./i)).not.toBeInTheDocument()

// 確認點擊後,不存在 "no item" 文字
userEvent.click(screen.getByRole('button', { name: /submit/i }))
expect(screen.getByText(/no item/i)).toBeInTheDocument()

// 等待資料 fetch 完,顯示第一個物件的名稱,且不存在 "Fetching items"、"no item" 文字
expect(await screen.findByText(/First item/i)).toBeInTheDocument()
expect(screen.queryByText(/no item/i)).not.toBeInTheDocument()
expect(screen.queryByText(/Fetching items\\.\\.\\./i)).not.toBeInTheDocument()
})


看到這裡,可能會有讀者想說,為什麼都沒有針對 Redux 進行測試,而是單純測試畫面內容而已?

那是因為,Redux 的測試邏輯,就是希望開發者不要在乎測試時,背後是否使用 Redux 或任何狀態管理的套件。重點在於畫面「是否顯示」正確的資訊

顯示正確的資訊,才是整合測試中,最重要的關鍵。




如何在元件渲染前測試 store?

如果我需要在元件渲染前,就確定資料有被正確的傳入呢?那我們就可以在客製化後的 Render 函式中,傳入我們預設的 initiateState,並傳入 preloadedState 中。

接著,再從 render 函式中,輸出 getByText 等方法,確保資料可以被正確讀取。當然 find 或 query 等類型的方法,也都能夠從中導出。

test('Uses preloaded state to render', () => {
const initialState = [{ cartItems: [], , status: "idle" }]

const { getByText } = renderWithProviders(<TodoList />, {
preloadedState: {
cart: initialState
}
})
})




RTL 與 Redux 的測試結論

一開始在導入測試時,最不習慣的便是將心態,從「確認函式的返回值」轉換成「實際畫面上的資料」;但以整合測試(Integration Test)作為核心心態,能夠避免雖然 Redux store 運作正常,畫面卻沒有正確顯示的問題。

測試真的是一們很深的學問,無論是如何提升測試覆蓋率,以及在「時間有限」的情況下,哪一些元件需要優先寫測試、哪一些往後放,甚至哪一些僅寫整合測試。

最近也正在研究測試驅動開發(Test-Driven Development),也了解到測試不是所有的功能都寫,而是要反映真正重要,且容易改壞的內容做測試,且以實際結果而非每一個函式來做測試。

如果讀者在閱讀後,也有其他的想法,也歡迎留言或來信交流!



延伸閱讀:

技術文章

學習成長




參考資料:

此處作為整理前端(Frontend)和相關的 HTML、CSS、JavaScript、React 等前端觀念與技巧,全部都會收錄在這個專題之中。同時也會將相關的技術與反思記錄在此,歡迎各位讀者互相交流。
留言0
查看全部
發表第一個留言支持創作者!