Featured image of post [Redux] 30. 테스팅과 실전 프로젝트 - E-Commerce 앱 (시리즈 마무리)

[Redux] 30. 테스팅과 실전 프로젝트 - E-Commerce 앱 (시리즈 마무리)

리듀서·thunk·RTK Query·컴포넌트 테스트 작성법을 정리하고, 1~29편의 개념을 모두 조합한 장바구니·상품 목록·주문 E-Commerce 미니 프로젝트로 시리즈를 마무리합니다. 전체 30편 대응표를 포함합니다.

30. 테스팅과 실전 프로젝트 - E-Commerce 앱

Redux 시리즈의 마지막 편입니다. 테스트 작성법을 정리하고, 1~29편의 개념을 모두 동원해 상품 목록·장바구니·주문이라는 세 기능을 가진 E-Commerce 미니 프로젝트를 완성합니다.

학습 목표

  • 리듀서, thunk, RTK Query 엔드포인트, 컴포넌트 각각에 맞는 테스트 전략을 구사할 수 있다.
  • 여러 slice와 RTK Query API가 얽힌 실전 규모의 프로젝트를 처음부터 조립할 수 있다.
  • 1~30편의 어느 개념이 실전 프로젝트의 어느 부분에 대응하는지 스스로 설명할 수 있다.

리듀서 테스트: 순수 함수의 이점

07·08편에서 리듀서가 “입력이 같으면 출력이 같은 순수 함수"라고 배웠습니다. 이 특성 덕분에 리듀서 테스트는 mock이 전혀 필요 없습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// cartSlice.test.js
import cartReducer, { itemAdded, itemRemoved } from "./cartSlice";

test("itemAdded는 새 상품을 장바구니에 추가한다", () => {
  const initialState = { items: [] };
  const action = itemAdded({ id: "p1", price: 10000, quantity: 1 });

  const newState = cartReducer(initialState, action);

  expect(newState.items).toHaveLength(1);
  expect(newState.items[0]).toEqual({ id: "p1", price: 10000, quantity: 1 });
  expect(initialState.items).toHaveLength(0); // 08편: 원본 state는 절대 변경되지 않아야 한다
});

initialState.items가 여전히 빈 배열인지 확인하는 마지막 줄이 중요합니다. 08편에서 배운 불변성 원칙이 실제로 지켜지고 있는지를 테스트가 직접 검증합니다.

Thunk 테스트: 가짜 dispatch/getState 주입

22편의 thunk는 (dispatch, getState)를 받는 함수이므로, 실제 Store 없이도 가짜 함수를 직접 주입해 테스트할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// checkoutThunk.test.js
import { submitOrder } from "./checkoutThunk";
import { checkoutRejected } from "./checkoutSlice";

test("장바구니가 비어있으면 checkoutRejected를 dispatch한다", async () => {
  const dispatch = jest.fn(); // 실제 Store 없이 함수 호출만 기록하는 가짜 dispatch
  const getState = () => ({ cart: { items: [] } });

  await submitOrder()(dispatch, getState); // thunk 함수를 직접 호출

  expect(dispatch).toHaveBeenCalledWith(checkoutRejected("장바구니가 비어 있습니다"));
});

RTK Query 테스트: MSW로 네트워크 계층 모킹

24편의 RTK Query 엔드포인트는 실제 fetch를 수행하므로, **MSW(Mock Service Worker)**로 네트워크 계층에서 가짜 응답을 준비하는 것이 표준적인 방법입니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// productsApi.test.js
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { productsApi } from "./productsApi";
import { setupApiStore } from "../../test-utils/setupApiStore"; // configureStore로 테스트 전용 store를 만드는 헬퍼

const server = setupServer(
  http.get("/api/products", () => HttpResponse.json([{ id: "p1", name: "키보드", price: 50000 }]))
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test("getProducts는 상품 목록을 가져와 캐시한다", async () => {
  const storeRef = setupApiStore(productsApi);
  const result = await storeRef.store.dispatch(productsApi.endpoints.getProducts.initiate());

  expect(result.data).toEqual([{ id: "p1", name: "키보드", price: 50000 }]);
});

MSW는 실제 네트워크 계층(fetch)을 가로채므로, fetchBaseQuery 내부 구현을 몰라도 됩니다. “이 URL로 요청이 오면 이 응답을 준다"는 선언만으로 24편에서 만든 API 슬라이스 전체를 검증할 수 있습니다.

에러 경로도 같은 방식으로 검증합니다. MSW 핸들러가 실패 응답을 반환하도록 재정의하기만 하면, 19편의 rejected 상태와 동일한 원리로 RTK Query의 에러 상태를 확인할 수 있습니다.

1
2
3
4
5
6
7
8
9
test("서버 에러 시 isError가 true가 된다", async () => {
  server.use(http.get("/api/products", () => HttpResponse.json({ message: "서버 오류" }, { status: 500 })));

  const storeRef = setupApiStore(productsApi);
  const result = await storeRef.store.dispatch(productsApi.endpoints.getProducts.initiate());

  expect(result.isError).toBe(true);
  expect(result.error.status).toBe(500);
});

컴포넌트 테스트: React Testing Library

12편의 useSelector/useDispatch를 쓰는 컴포넌트는 실제 Store로 감싸서 테스트합니다. 컴포넌트의 내부 구현이 아니라, 사용자가 보는 화면과 상호작용을 기준으로 검증합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Cart.test.jsx
import { render, screen, fireEvent } from "@testing-library/react";
import { Provider } from "react-redux"; // 11편의 Provider
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "./cartSlice";
import { Cart } from "./Cart";

function renderWithStore(preloadedState) {
  const store = configureStore({ reducer: { cart: cartReducer }, preloadedState });
  return render(
    <Provider store={store}>
      <Cart />
    </Provider>
  );
}

test("삭제 버튼을 클릭하면 해당 상품이 목록에서 사라진다", () => {
  renderWithStore({ cart: { items: [{ id: "p1", name: "키보드", quantity: 1 }] } });

  expect(screen.getByText("키보드")).toBeInTheDocument();
  fireEvent.click(screen.getByRole("button", { name: "삭제" }));
  expect(screen.queryByText("키보드")).not.toBeInTheDocument();
});

preloadedState로 원하는 초기 상태를 직접 주입할 수 있다는 점이 핵심입니다. “장바구니에 상품이 이미 있는 상태"를 재현하기 위해 UI를 여러 번 클릭할 필요 없이, 상태를 바로 설정해 원하는 시나리오만 정확히 테스트합니다.

실전 프로젝트: E-Commerce 미니 앱

지금까지 배운 모든 것을 조합해, 상품 목록·장바구니·주문 세 기능을 가진 앱을 설계합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
src/
  app/
    store.ts            # 18·28편: configureStore + RootState/AppDispatch
    hooks.ts             # 28편: 타입 지정된 useAppSelector/useAppDispatch
  features/
    products/
      productsApi.ts     # 24·27·28편: RTK Query + entityAdapter + TypeScript
      ProductList.tsx
    cart/
      cartSlice.ts        # 17편: createSlice, 08편: 불변 업데이트
      cartSelectors.ts     # 14편: createSelector로 합계 메모이제이션
      Cart.tsx
    checkout/
      checkoutSlice.ts      # 17편: 체크아웃 상태 전이 관리
      checkoutThunk.ts      # 22편: 복잡한 검증+dispatch 조합
      OrderConfirmation.tsx
  shared/
    api/baseApi.ts         # 26편: injectEndpoints 공유 기반
 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
// features/cart/cartSlice.ts — 17·28편 통합
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}
interface CartState {
  items: CartItem[];
}

const cartSlice = createSlice({
  name: "cart",
  initialState: { items: [] } as CartState,
  reducers: {
    itemAdded: (state, action: PayloadAction<CartItem>) => {
      const existing = state.items.find((i) => i.id === action.payload.id);
      if (existing) {
        existing.quantity += action.payload.quantity; // Immer draft — 08편의 원칙이 내부적으로 지켜짐
      } else {
        state.items.push(action.payload);
      }
    },
    itemRemoved: (state, action: PayloadAction<string>) => {
      state.items = state.items.filter((i) => i.id !== action.payload);
    },
  },
});

export const { itemAdded, itemRemoved } = cartSlice.actions;
export default cartSlice.reducer;
1
2
3
4
5
6
7
8
9
// features/cart/cartSelectors.ts — 14편
import { createSelector } from "@reduxjs/toolkit";
import type { RootState } from "../../app/store";

const selectCartItems = (state: RootState) => state.cart.items;

export const selectCartTotal = createSelector([selectCartItems], (items) =>
  items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);

features/products/productsApi.ts는 24편의 RTK Query와 27편의 createEntityAdapter를 결합합니다. 쿼리가 반환한 배열을 transformResponse로 정규화된 { ids, entities } 형태로 변환해 캐시에 저장하고, getSelectors()로 표준 selector를 뽑아 씁니다.

 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
// features/products/productsApi.ts — 24·27·28편 통합: RTK Query + createEntityAdapter
import { createEntityAdapter, createSelector, EntityState } from "@reduxjs/toolkit";
import { baseApi } from "../../shared/api/baseApi"; // 26편: injectEndpoints 공유 기반
import type { RootState } from "../../app/store";

interface Product {
  id: string;
  name: string;
  price: number;
}

const productsAdapter = createEntityAdapter<Product>();

export const productsApi = baseApi.injectEndpoints({
  endpoints: (builder) => ({
    getProducts: builder.query<EntityState<Product>, void>({
      query: () => "/products",
      transformResponse: (response: Product[]) =>
        productsAdapter.setAll(productsAdapter.getInitialState(), response), // 배열 → 정규화된 캐시 형태
      providesTags: ["Product"],
    }),
  }),
});

export const { useGetProductsQuery } = productsApi;

// 쿼리 캐시 결과에서 정규화된 데이터만 추출한 뒤 표준 selector로 감싼다
const selectProductsResult = productsApi.endpoints.getProducts.select();
const selectProductsData = createSelector(selectProductsResult, (result) => result.data);

export const { selectAll: selectAllProducts, selectById: selectProductById } =
  productsAdapter.getSelectors(
    (state: RootState) => selectProductsData(state) ?? productsAdapter.getInitialState()
  );

selectAllProducts(state)처럼 호출하면, 컴포넌트는 쿼리가 아직 로딩 중인지 캐시가 비었는지 신경 쓸 필요 없이 항상 정규화된 배열(또는 빈 배열)을 받습니다. productsAdapter.getSelectors()가 반환하는 selectAll/selectById는 15편·20편에서 손으로 만들었던 selector와 동일한 역할을, 정규화된 쿼리 캐시 위에서 자동으로 제공합니다.

 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
// features/checkout/checkoutSlice.ts — 17편: createSlice로 체크아웃 상태 전이 관리
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

interface CheckoutState {
  status: "idle" | "loading" | "succeeded" | "failed"; // 19편과 동일한 상태 모델
  error: string | null;
}

const checkoutSlice = createSlice({
  name: "checkout",
  initialState: { status: "idle", error: null } as CheckoutState,
  reducers: {
    checkoutStarted: (state) => {
      state.status = "loading";
      state.error = null;
    },
    checkoutSucceeded: (state) => {
      state.status = "succeeded";
    },
    checkoutFailed: (state, action: PayloadAction<string>) => {
      state.status = "failed";
      state.error = action.payload;
    },
    checkoutRejected: (state, action: PayloadAction<string>) => {
      state.status = "failed";
      state.error = action.payload;
    },
  },
});

export const { checkoutStarted, checkoutSucceeded, checkoutFailed, checkoutRejected } = checkoutSlice.actions;
export default checkoutSlice.reducer;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// features/checkout/checkoutThunk.ts — 22·28편
import type { AppDispatch, RootState } from "../../app/store";
import { itemRemoved } from "../cart/cartSlice";
import { checkoutStarted, checkoutSucceeded, checkoutFailed, checkoutRejected } from "./checkoutSlice";

export const submitOrder = () => async (dispatch: AppDispatch, getState: () => RootState) => {
  const { items } = getState().cart;
  if (items.length === 0) {
    dispatch(checkoutRejected("장바구니가 비어 있습니다"));
    return;
  }
  dispatch(checkoutStarted());
  try {
    const response = await fetch("/api/orders", { method: "POST", body: JSON.stringify({ items }) });
    if (!response.ok) throw new Error("주문 처리에 실패했습니다");
    dispatch(checkoutSucceeded());
    items.forEach((item) => dispatch(itemRemoved(item.id))); // 09편: 성공 후 후속 액션 조합
  } catch (error) {
    dispatch(checkoutFailed((error as Error).message));
  }
};

전체 30편 대응표

주제이 프로젝트에서의 위치
01-05JS/TS 기초PayloadAction<T>, 구조 분해, reduce(), async/await 전반
06-10Redux 핵심 개념createStore 원리, 불변성, 데이터 흐름, DevTools
11-15React-Redux 연동Provider, useSelector/useDispatch, 컴포넌트 최적화, selector 패턴
16-20Redux ToolkitcartSlice.tscreateSlice, configureStore
21-25미들웨어와 사이드 이펙트checkoutThunk.ts, productsApi.ts의 RTK Query
26-28고급 패턴과 아키텍처폴더 구조, productsApi의 정규화, 전체 코드의 TypeScript화
29-30실무 마스터 레벨DevTools 디버깅 워크플로, 이 편의 테스트 전략

시리즈를 마치며

01편의 var/let/const 스코프 규칙에서 시작해, 30편의 타입 안전한 E-Commerce 프로젝트까지 왔습니다. 돌아보면 이 시리즈는 하나의 질문을 계속 반복해서 던졌습니다. “이 상태 변화가 예측 가능한가?”

  • 07편의 순수 리듀서 규칙은 “같은 입력엔 같은 출력"을 보장했습니다.
  • 08편의 불변성은 “무엇이 바뀌었는지 참조 비교만으로 알 수 있게” 했습니다.
  • 09편의 단방향 데이터 흐름은 “상태가 어디서 왜 바뀌었는지 추적 가능하게” 했습니다.
  • 21~24편의 미들웨어 체계는 “부수 효과가 있어야만 하는 곳"을 명확한 경계 안에 가뒀습니다.
  • 27편의 정규화와 28편의 타입은 “데이터가 커져도 그 예측 가능성이 무너지지 않게” 지탱했습니다.

Redux Toolkit이 보일러플레이트를 줄여준 것은 사실이지만, 이 시리즈가 순수 Redux(Phase 1-3)부터 시작한 이유는 그 예측 가능성이 어디에서, 왜 나오는지를 먼저 손으로 확인하길 바랐기 때문입니다. RTK가 감춘 것이 무엇인지 아는 개발자와 모르는 개발자는, 문제가 생겼을 때 정확히 그 지점에서 갈립니다.

이제 새로운 프로젝트에서 상태 관리가 복잡해지기 시작할 때, “정말 Redux가 필요한가”(10편), “필요하다면 어떤 도구 조합이 적절한가”(2224편), “구조를 어떻게 잡을 것인가”(2627편)를 스스로 판단할 수 있는 기반을 갖췄을 것입니다. 다음은 실제 프로젝트에 이 판단을 적용해볼 차례입니다.

실무 체크리스트

  • 리듀서는 mock 없이, thunk는 가짜 dispatch/getState로, RTK Query는 MSW로, 컴포넌트는 실제 Store를 감싸는 방식으로 테스트 전략을 구분하고 있는가?
  • 컴포넌트 테스트가 내부 구현이 아니라 사용자가 보는 화면과 상호작용을 기준으로 작성되어 있는가?
  • 새 프로젝트를 시작할 때 이 시리즈의 판단 기준(10편, 2224편, 2627편)을 참고해 도구와 구조를 선택하고 있는가?

연습 과제

기초(★☆☆)

  • cartSliceitemAdded가 이미 담긴 상품의 수량을 올바르게 누적하는지 리듀서 단위 테스트로 검증해보세요.

중급(★★☆)

  • submitOrder thunk가 성공 시 장바구니의 모든 항목에 대해 itemRemoved를 dispatch하는지, 가짜 dispatch로 호출 횟수와 인자를 검증해보세요.

고급(★★★)

  • ProductListCartOrderConfirmation으로 이어지는 전체 흐름(상품 추가 → 장바구니 확인 → 주문 제출)을 하나의 통합 테스트로 작성해보세요.

요약

  • 리듀서는 순수 함수이므로 mock 없이, thunk는 가짜 dispatch/getState로, RTK Query는 MSW로, 컴포넌트는 실제 Store로 감싸 테스트한다.
  • E-Commerce 프로젝트는 1~29편의 거의 모든 개념이 하나의 코드베이스 안에서 자연스럽게 조합될 수 있음을 보여준다.
  • Redux 학습의 핵심은 도구(RTK)의 사용법이 아니라, 상태 변화의 예측 가능성을 지키는 원칙(순수성·불변성·단방향 흐름)이다.

참고 문헌 및 출처(추천)

  • Redux 공식 문서, “Writing Tests”
  • Redux Toolkit 공식 문서, “Usage With TypeScript, Testing”
  • Mock Service Worker(MSW) 공식 문서, “Redux Toolkit Integration”
  • Testing Library 공식 문서, “Guiding Principles”

시리즈 목차