Jika Anda pernah menulis test yang memverifikasi dispatch({ type: 'FETCH_USER_SUCCESS' }) dipanggil dengan bentuk payload yang tepat, Anda telah menulis test yang rusak setiap kali seseorang mengganti nama sebuah konstanta.
Itu bukan menguji logika state Anda. Itu menguji bahwa jari-jari Anda mengetik string yang benar.
Tutorial pengujian Redux sering dimulai dengan mock Jest: memata-matai dispatch, memastikan action creator dipanggil, memastikan type cocok, selesai. Itu berfungsi untuk tutorial. Itu runtuh di codebase nyata di mana action type berubah, payload di-refactor, dan test Anda menjadi daftar tugas untuk setiap pemanggilan dispatch di aplikasi.
Alternatifnya adalah menguji store sebagai satu kesatuan, bukan action creator satu per satu.
Mengapa pengujian action-per-action runtuh dalam skala besar
Bayangkan sebuah komponen profil pengguna yang mengambil data, menangani state loading, dan menampilkan error. Test naif-nya terlihat seperti ini:
// The mock spiral
jest.mock('./api', () => ({ fetchUser: jest.fn() }));
it('dispatches fetch user action', async () => {
const dispatch = jest.fn();
await loadUser(42)(dispatch);
expect(dispatch).toHaveBeenCalledWith({
type: 'FETCH_USER_REQUEST',
});
expect(dispatch).toHaveBeenCalledWith({
type: 'FETCH_USER_SUCCESS',
payload: { id: 42, name: 'Ada' },
});
});
Test ini lulus. Namun ia juga gagal begitu seseorang menambahkan field meta ke action success, atau membungkus payload dalam key data, atau beralih ke createAsyncThunk.
Yang lebih penting, ia tidak memberi tahu Anda apa pun tentang apakah reducer benar-benar melakukan sesuatu dengan action tersebut. Action tersebut bisa masuk ke reducer yang mengabaikannya dan test tetap akan berwarna hijau.
Pendekatan integration test: perlakukan store sebagai black box
Store sudah merupakan pure function. Diberikan state dan action, ia mengembalikan state baru. Test yang paling jujur adalah memberinya action dan memeriksa state-nya.
Anda tidak perlu mock reducer. Anda tidak perlu mock action creator. Anda menyiapkan store nyata, dispatch action nyata (atau action creator), dan membaca state yang dihasilkan.
import { configureStore } from '@reduxjs/toolkit';
import { userReducer } from './userSlice';
function createTestStore(preloadedState) {
return configureStore({
reducer: { user: userReducer },
preloadedState,
});
}
it('loads a user into state', () => {
const store = createTestStore();
store.dispatch({ type: 'user/fetchUser/fulfilled', payload: { id: 42, name: 'Ada' } });
expect(store.getState().user.entities[42]).toEqual({ id: 42, name: 'Ada' });
});
Ini adalah satu baris dispatch dan satu assertion. Ia menguji kontrak yang sebenarnya UI Anda andalkan: bahwa setelah sesuatu terjadi, state terlihat dengan cara tertentu.
Menguji thunk dan logika async dengan store nyata
Menguji async middleware adalah tempat di mana kebanyakan tim menggunakan mock. Anda tidak harus melakukannya.
Jika Anda menggunakan Redux Toolkit, configureStore sudah menyertakan redux-thunk dan devtools middleware. Store test dengan middleware stack yang sama berperilaku identik dengan store produksi Anda.
import { configureStore } from '@reduxjs/toolkit';
import { userReducer, fetchUser } from './userSlice';
function createTestStore(preloadedState) {
return configureStore({
reducer: { user: userReducer },
preloadedState,
middleware: (getDefault) => getDefault(), // same thunk middleware
});
}
it('handles the full fetch lifecycle', async () => {
const store = createTestStore();
// dispatch the thunk
const promise = store.dispatch(fetchUser(42));
// check loading state immediately
expect(store.getState().user.loading).toBe('pending');
await promise;
// check final state
expect(store.getState().user.loading).toBe('idle');
expect(store.getState().user.entities[42].name).toBe('Ada');
});
Jika fetchUser memanggil API, Anda masih perlu menangani network layer. Tapi itu masalah fetch, bukan masalah Redux. Gunakan msw untuk mencegat request HTTP, atau lewatkan custom API client melalui extraArgument thunk. Bagaimanapun, assertion Anda tetap fokus pada state, bukan pada fungsi mana yang dipanggil dalam urutan apa.
Kapan mock, dan kapan tidak
Saya tidak berpendapat untuk nol mock. Mock cocok di batas sistem: request HTTP, browser API, timer, library eksternal dengan side effect.
Reducer bukan batas sistem. Mereka adalah fungsi deterministik di dalam codebase Anda sendiri. Mem-mock mereka seperti mem-mock pemanggilan filter.
Action creator, terutama thunk, adalah logika orchestration. Anda dapat mengujinya melalui efeknya pada state alih-alih pemanggilan dispatch internal mereka. Itu menghilangkan coupling antara test Anda dan bentuk action Anda.
Trade-off-nya adalah kecepatan. Test yang menjalankan full reducer stack dan middleware lebih lambat daripada test yang assertion pada mock. Dalam praktiknya, bedanya adalah milidetik. Jika test suite Anda lambat, pelakunya hampir tidak pernah test Redux store. Itu adalah menjalankan instance JSDOM untuk setiap file, atau memanggil database nyata, atau tidak membersihkan timer.
Bagaimana jika Anda tidak menggunakan Redux Toolkit?
Redux biasa bekerja dengan cara yang sama. Bangun store dengan createStore, terapkan middleware Anda, dan dispatch.
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { rootReducer } from './reducers';
function createTestStore(preloadedState) {
return createStore(rootReducer, preloadedState, applyMiddleware(thunk));
}
it('toggles the feature flag', () => {
const store = createTestStore({ features: { darkMode: false } });
store.dispatch({ type: 'features/TOGGLE_DARK_MODE' });
expect(store.getState().features.darkMode).toBe(true);
});
Jika Anda memiliki middleware chain yang kompleks (logging, analytics, saga), sertakan di store test atau pangkas. Tujuannya adalah menguji perilaku yang komponen Anda andalkan, bukan mereplikasi produksi secara tepat.
Test selector: separuh lain dari kontrak
Menguji store secara terisolasi hanyalah separuh dari cerita. Komponen Anda membaca state melalui selector. Test selector sama sederhana dan sama berharganya.
import { selectUserById, selectActiveUsers } from './userSelectors';
it('selects only active users', () => {
const state = {
user: {
entities: {
1: { id: 1, name: 'Ada', active: true },
2: { id: 2, name: 'Grace', active: false },
},
ids: [1, 2],
},
};
expect(selectActiveUsers(state)).toHaveLength(1);
expect(selectActiveUsers(state)[0].name).toBe('Ada');
});
Perhatikan bahwa test selector sama sekali tidak menyentuh store. Ia mengambil bentuk state dan mengembalikan nilai turunan. Inilah yang membuatnya cepat dan tepat. Kontrak antara reducer dan selector Anda adalah bentuk state, dan kedua sisi dapat diuji secara independen.
Pola untuk codebase yang lebih besar
Dalam codebase dengan banyak slice, membuat test store per file test menjadi repetitif. Pusatkan.
// test-utils/store.js
import { configureStore } from '@reduxjs/toolkit';
import { rootReducer } from '../src/store';
export function createTestStore(preloadedState = {}) {
return configureStore({
reducer: rootReducer,
preloadedState,
middleware: (getDefault) =>
getDefault({
// disable immutable and serializable checks in tests
// to avoid noise when testing edge cases
immutableCheck: false,
serializableCheck: false,
}),
});
}
Kemudian import createTestStore di setiap test. Konsistensi memudahkan rekan tim untuk menulis test yang berperilaku sama, dan memberi Anda satu tempat untuk men-tweak middleware atau menambahkan reducer khusus test.
Di mana pendekatan ini berhenti bekerja
Ada kasus di mana Anda ingin menguji di level action. Jika Anda memiliki custom middleware yang mengubah action, Anda perlu assertion pada action yang telah diubah. Jika Anda membangun library yang mengekspor action creator, bentuk action adalah public API-nya.
Namun untuk kode aplikasi, action adalah detail implementasi. State-lah yang penting.
FAQ
Haruskah saya menguji connected React components?
Uji komponen secara terisolasi dengan mock props, atau integration test dengan @testing-library/react dan provider nyata. Keduanya valid. Kuncinya adalah jangan menguji plumbing Redux melalui komponen. Uji apa yang dilihat pengguna.
Bagaimana dengan saga?
Redux-Saga adalah makhluk yang berbeda karena ia mengelola alur kontrolnya sendiri. Anda dapat menguji saga dengan menjalankannya menggunakan redux-saga-test-plan dan assertion pada perubahan state, atau Anda dapat menguji saga secara terisolasi dengan cloneableGenerator. Saga adalah satu-satunya tempat di mana mem-mock effect biasanya sepadan.
Bagaimana cara menangani side effect seperti analytics?
Ekstrak mereka ke middleware dan uji middleware secara terpisah, atau gunakan pola extraArgument untuk menyuntikkan mock analytics client ke thunk Anda.
Apakah ini berfungsi dengan Zustand atau state manager lain?
Ya. Prinsipnya sama: inisialisasi store, picu perubahan state, baca state. Store Zustand bahkan lebih mudah karena mereka seringkali hanya fungsi.
Cobalah pada satu slice
Pilih slice yang paling sering Anda sentuh. Ganti satu test yang banyak mock-nya dengan test store nyata. Jalankan. Jika ia lebih lambat lebih dari 10ms, saya akan mentraktir Anda kopi.
Kebanyakan tim menemukan test Redux mereka menjadi lebih pendek, bertahan dari refactor, dan benar-benar menangkap bug begitu mereka berhenti memverifikasi pemanggilan dispatch dan mulai memverifikasi state.