隨著 React 在前端開發中的廣泛應用,我們往往需要進行一系列測試,來確保應用程式的正常運作。
而在 React 測試生態系統中,React Testing Library 成為了方便好用的選擇,因其強調測試應該關注於使用者的操作與觀察元件行為,而不是測試細節實現。
本文將分享如何結合 Redux Toolkit 與 React Testing Library,來有效測試 Redux 應用程式。無論你是剛學習測試還是已經熟悉 Redux,希望本文能幫助讀者更了解 Redux Toolkit 和 React Testing Library 的實際用法,讓您的測試寫起來更加輕鬆。
現在,讓我們開始探索這個令人興奮的主題吧!
在 Redux 官方網站就曾經提到,他們其實是鼓吹「整合測試」。因為多數的情況下,使用者並不在意背後是否使用 Redux,重點在於元件的功能是否正確作動。因此雖然每一個 pure function 一樣可以使用 unit test,但實際上這些內容都能被整合測試覆蓋到。
這裡也推薦兩篇文章,解釋為什麼整合測試特別適合 Redux 測試:
React Testing Library 可以被任何的 Test Runner 執行。因此如果使用 CRA 就會預設 Jest;Vite 就會預設為 Vitest 等。而 React Testing Library,就是在模擬 DOM 實際上的顯示與運作。
因此我們可以使用任何的 Test Runner,搭配 Testing Library 來模擬並抓取實際上的 DOM,就能達到測試 React 元件的目的。
在測試的過程中,我們希望實際的邏輯、Action 與 Reducer 都能夠被順利測試,因此我們需要做的方式如下。
接著,就讓我們根據上面提到的三個方向,拆解成三個步驟,一步一步整理出完整的測試邏輯吧!
為了達到這個狀態,我們總共需要設定兩個部分的程式碼,分別是:
因為我們需要在每一個測試時,都重複生成一個全新的 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
})
}
在這個階段,可以透過 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 的測試前置作業。接著,我們就來開始撰寫我們的測試吧!
為了讓大家更完整了解整個 Redux 測試的運作,採用了 Redux 中最重要的兩個環節:
為了更詳細說明如何將測試放到專案中,我們先了解基礎專案的程式碼背景,以及如何導入測試。此處先簡單利用專案部分的程式碼,說明示範的程式碼架構。
該專案是一個簡單電商平台的狀態管理,使用 Redux Toolkit 來管理。主要有 Cart
元件和 cartSlice
這個 store 共兩個文件。
該元件是位於購物車(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;
該 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。
接著,我們就可以實際來撰寫我們的測試檔案啦!我們主要會使用的套件有 React Testing Library
、Jest
和 Mock Service Worker
三個套件。
其中 Mock Service Worker 主要用於模擬 Server 的活動與回應,而不需要真的使用到 Server。可以避免直接使用 Server 導致意外寫入資料,或是讓伺服器擁有額外的壓力。
下面的測試前提,總共做了兩件事,第一件事是設定 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())
接著,我們使用 Jest 來撰寫我們的測試。Jest 的測試條件式,是以 test 開頭,並在裡面執行測試 AAA 三步驟。
下面的程式碼,主要做的三組測試,分別是:
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 或任何狀態管理的套件。重點在於畫面「是否顯示」正確的資訊。
顯示正確的資訊,才是整合測試中,最重要的關鍵。
如果我需要在元件渲染前,就確定資料有被正確的傳入呢?那我們就可以在客製化後的 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
}
})
})
一開始在導入測試時,最不習慣的便是將心態,從「確認函式的返回值」轉換成「實際畫面上的資料」;但以整合測試(Integration Test)作為核心心態,能夠避免雖然 Redux store 運作正常,畫面卻沒有正確顯示的問題。
測試真的是一們很深的學問,無論是如何提升測試覆蓋率,以及在「時間有限」的情況下,哪一些元件需要優先寫測試、哪一些往後放,甚至哪一些僅寫整合測試。
最近也正在研究測試驅動開發(Test-Driven Development),也了解到測試不是所有的功能都寫,而是要反映真正重要,且容易改壞的內容做測試,且以實際結果而非每一個函式來做測試。
如果讀者在閱讀後,也有其他的想法,也歡迎留言或來信交流!