dispatch({ type: 'FETCH_USER_SUCCESS' })가 정확한 payload 형태로 호출되었는지 검증하는 테스트를 작성한 적이 있다면, 누군가 상수 이름을 바꿀 때마다 깨지는 테스트를 작성한 것이다.
이건 상태 로직을 테스트하는 게 아니다. 손가락이 올바른 문자열을 입력했는지 테스트하는 것이다.
Redux 테스트 튜토리얼은 종종 Jest mock으로 시작한다. dispatch를 감시하고, action creator가 호출되었는지 단언하고, type이 일치하는지 확인하면 끝이다. 튜토리얼에서는 잘 작동한다. 하지만 실제 코드베이스에서는 action type이 변경되고, payload가 리팩토링되며, 테스트가 앱의 모든 dispatch 호출에 대한 할 일 목록이 되면서 무너진다.
대안은 action creator를 개별적으로 테스트하는 것이 아니라 store를 하나의 단위로 테스트하는 것이다.
왜 action 단위 테스트는 규모가 커지면 무너지는가
데이터를 가져오고, 로딩 상태를 처리하며, 에러를 표시한다고 상상해보자. 순진한 테스트는 다음과 같다.
// 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' },
});
});
이 테스트는 통과한다. 하지만 누군가 success action에 meta 필드를 추가하거나, payload를 data 키로 감싸거나, createAsyncThunk로 전환하는 순간 실패한다.
더 중요한 것은, 이 테스트가 reducer가 실제로 그 action으로 무언가를 했는지 전혀 알려주지 않는다는 것이다. action은 이를 무시하는 reducer로 날아갈 수 있고, 테스트는 여전히 초록색일 것이다.
통합 테스트 접근법: store를 블랙박스로 다루기
store는 이미 순수 함수다. state와 action이 주어지면 새 state를 반환한다. 가장 정직한 테스트는 action을 넣고 state를 확인하는 것이다.
reducer를 mock할 필요도 없다. action creator를 mock할 필요도 없다. 실제 store를 세팅하고, 실제 action(또는 action creator)을 dispatch하고, 결과 state를 읽으면 된다.
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' });
});
이것은 dispatch 한 줄과 assertion 하나다. UI가 실제로 의존하는 계약을 테스트한다: 무언가가 일어난 후, state가 특정한 모습을 한다는 것.
실제 store로 thunk와 비동기 로직 테스트하기
비동기 미들웨어를 테스트하는 것은 대부분의 팀이 mock을 찾게 되는 지점이다. 그럴 필요가 없다.
Redux Toolkit을 사용하고 있다면, configureStore는 이미 redux-thunk와 devtools 미들웨어를 포함한다. 동일한 미들웨어 스택을 가진 테스트 store는 production store와 동일하게 동작한다.
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');
});
fetchUser가 API를 호출한다면, 여전히 네트워크 계층을 처리해야 한다. 하지만 그것은 fetch 문제이지 Redux 문제가 아니다. HTTP 요청을 가로채기 위해 msw를 사용하거나, thunk의 extraArgument를 통해 커스텀 API 클라이언트를 전달하라. 어느 쪽이든, assertion은 상태에 집중하고, 어떤 함수가 어떤 순서로 호출되었는지가 아니다.
mock할 때와 하지 말아야 할 때
나는 mock을 전혀 쓰지 말자고 주장하는 게 아니다. mock은 시스템 경계에서 적절하다: HTTP 요청, 브라우저 API, 타이머, 부수 효과가 있는 외부 라이브러리.
reducer는 시스템 경계가 아니다. 자신의 코드베이스 안의 결정론적 함수다. 이를 mock하는 것은 filter 호출을 mock하는 것과 같다.
action creator, 특히 thunk는 orchestration 로직이다. 내부 dispatch 호출 대신 state에 미치는 영향을 통해 테스트할 수 있다. 이렇게 하면 테스트와 action의 형태 사이의 결합이 사라진다.
트레이드오프는 속도다. 전체 reducer 스택과 미들웨어를 실행하는 테스트는 mock을 단언하는 테스트보다 느리다. 실제로, 차이는 밀리초 단위다. 테스트 스위트가 느리다면, 범인은 거의 Redux store 테스트가 아니다. 모든 파일마다 JSDOM 인스턴스를 시작하거나, 실제 데이터베이스를 호출하거나, 타이머를 정리하지 않는 것이다.
Redux Toolkit을 사용하지 않는다면?
Plain Redux도 같은 방식으로 작동한다. createStore로 store를 만들고, 미들웨어를 적용하고, 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);
});
복잡한 미들웨어 체인(로깅, 분석, saga)이 있다면, 테스트 store에 포함하거나 제거하라. 목표는 production을 정확히 복제하는 것이 아니라, 컴포넌트가 의존하는 동작을 테스트하는 것이다.
selector 테스트: 계약의 다른 반쪽
store를 격리해서 테스트하는 것은 이야기의 절반에 불과하다. 컴포넌트는 selector를 통해 state를 읽는다. selector 테스트는 똑같이 간단하고 똑같이 가치 있다.
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');
});
selector 테스트는 store를 전혀 건드리지 않는다는 점에 주목하라. state 형태를 받아 파생 값을 반환한다. 이것이 그것을 빠르고 정확하게 만든다. reducer와 selector 사이의 계약은 state 형태이며, 양쪽은 독립적으로 테스트할 수 있다.
큰 코드베이스를 위한 패턴
slice가 많은 코드베이스에서는 테스트 파일마다 테스트 store를 만드는 것이 반복적이다. 중앙 집중화하라.
// 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,
}),
});
}
그런 다음 모든 테스트에서 createTestStore를 import하라. 일관성은 팀원들이 동일한 방식으로 동작하는 테스트를 작성하기 쉽게 만들고, 미들웨어를 조정하거나 테스트 전용 reducer를 추가할 수 있는 한 곳을 제공한다.
어디까지가 한계인가
action 수준에서 테스트하고 싶은 경우가 있다. action을 변형하는 커스텀 미들웨어가 있다면, 변형된 action을 단언해야 한다. action creator를 내보내는 라이브러리를 만든다면, action shape는 공개 API다.
하지만 애플리케이션 코드에서는 action은 구현 세부사항이다. state가 중요하다.
FAQ
connected React 컴포넌트를 테스트해야 하나?
mock된 props로 컴포넌트를 격리해서 테스트하거나, @testing-library/react와 실제 provider로 통합 테스트하라. 둘 다 유효하다. 핵심은 컴포넌트를 통해 Redux plumbing을 테스트하지 않는 것이다. 사용자가 보는 것을 테스트하라.
saga는 어떻게 하나?
Redux-Saga는 자체 제어 흐름을 관리하기 때문에 다른 종류의 문제다. redux-saga-test-plan으로 실행하고 state 변화를 단언하여 saga를 테스트하거나, cloneableGenerator로 saga를 격리해서 테스트할 수 있다. saga는 효과를 mock하는 것이 보통 가치가 있는 유일한 곳이다.
분석과 같은 부수 효과는 어떻게 처리하나?
미들웨어로 추출하여 별도로 테스트하거나, extraArgument 패턴을 사용하여 mock analytics 클라이언트를 thunk에 주입하라.
Zustand나 다른 상태 관리자와도 작동하나?
그렇다. 원리는 동일하다: store를 초기화하고, state 변화를 트리거하고, state를 읽는다. Zustand store는 종종 그냥 함수이기 때문에 더 쉽다.
한 slice로 시도해보기
가장 자주 만지는 slice를 고르고, mock이 많은 테스트 하나를 실제 store 테스트로 바꿔라. 실행해라. 10ms 이상 느리다면 커피를 사겠다.
대부분의 팀은 dispatch 호출을 검증하는 대신 state를 검증하기 시작하면, Redux 테스트가 더 짧아지고, 리팩토링을 견디며, 실제로 버그를 잡는다는 것을 발견한다.