FE 통합 테스트
JeongSeulho
2024년 01월 06일
준비중...
클립보드로 복사
0. 들어가며
FE에서의 통합 테스트와 예시를 정리
1. FE에서 통합 테스트란?
- 통합 테스트 : 2개 이상의 모듈이 상호 작용하여 발생하는 상태를 검증
- FE에서의 통합 테스트 : API와 함께 상호작용 하는 컴포넌트 조합을 테스트, 특정 상태를 기준으로 동작하는 컴포넌트 조합을 테스트
2. 통합 테스트 대상 선정
(1) 거대한 통합 테스트의 단점
- 모킹 코드가 증가하여 테스트 신뢰성이 저하
- 작은 수정사항에도 많은 테스트가 깨질 수 있으므로 유지 보수성이 떨어짐
(2) 비즈니스 로직을 기준으로 분리한 통합 테스트의 장점
- 핵심 비즈니스 로직을 독립적인 관점에서 효율적으로 테스트 가능
- 테스트를 통해 앱의 기능을 이해하기 쉬움
- 불필요한 단위 테스트를 줄일 수 있음
- 각 영역 별로 필요한 부분만 모킹
(3) 통합 테스트 주의사항
- 가능한 모킹을 최소화하여 실제 기능과 유사하게 검증
- 비즈니스 로직을 처리하는 상태 관리나 API 로직은 상위 컴포넌트에서 응집하여 관리하고 상위 컴포넌트를 테스트
3. Zustand 상태 관리 모킹
(1) setupTests.js
에서 모킹 선언
// setupTests.js
...
vi.mock('zustand');
...
(2) zustand.js
에서 스토어 초기화
// __mocks__/zustand.js
// __mocks__ 폴더 내에 파일 이름이 동일한 모듈을 생성하면 자동으로 모킹 가능
const { create: actualCreate } = await vi.importActual('zustand');
import { act } from '@testing-library/react';
// 앱에 선언된 모든 스토어에 대해 재설정 함수를 저장
const storeResetFns = new Set();
// 스토어를 생성할 때 초기 상태를 가져와 리셋 함수를 생성하고 set에 추가합니다.
export const create = createState => {
const store = actualCreate(createState);
const initialState = store.getState();
storeResetFns.add(() => store.setState(initialState, true));
return store;
};
// 테스트가 구동되기 전 모든 스토어를 리셋합니다.
beforeEach(() => {
act(() => storeResetFns.forEach(resetFn => resetFn()));
});
(3) 스토어 상태 변경을 위한 mockZustandStore.jsx
생성
// mockZustandStore.jsx
import { useCartStore } from '@/store/cart';
import { useFilterStore } from '@/store/filter';
import { useUserStore } from '@/store/user';
const mockStore = (hook, state) => {
const initStore = hook.getState();
hook.setState({ ...initStore, ...state }, true);
};
export const mockUseUserStore = state => {
mockStore(useUserStore, state);
};
export const mockUseCartStore = state => {
mockStore(useCartStore, state);
};
export const mockUseFilterStore = state => {
mockStore(useFilterStore, state);
};
4. Zustand 상태 관리 통합 테스트
import { screen, within } from '@testing-library/react';
import React from 'react';
import ProductInfoTable from '@/pages/cart/components/ProductInfoTable';
import {
mockUseCartStore,
mockUseUserStore,
} from '@/utils/test/mockZustandStore';
import render from '@/utils/test/render';
beforeEach(() => {
mockUseUserStore({ user: { id: 10 } });
mockUseCartStore({
cart: {
6: {
id: 6,
title: 'Handmade Cotton Fish',
price: 809,
description:
'The slim & simple Maple Gaming Keyboard from Dev Byte comes with a sleek body and 7- Color RGB LED Back-lighting for smart functionality',
images: [
'https://user-images.githubusercontent.com/35371660/230712070-afa23da8-1bda-4cc4-9a59-50a263ee629f.png',
'https://user-images.githubusercontent.com/35371660/230711992-01a1a621-cb3d-44a7-b499-20e8d0e1a4bc.png',
'https://user-images.githubusercontent.com/35371660/230712056-2c468ef4-45c9-4bad-b379-a9a19d9b79a9.png',
],
count: 3,
},
7: {
id: 7,
title: 'Awesome Concrete Shirt',
price: 442,
description:
'The Nagasaki Lander is the trademarked name of several series of Nagasaki sport bikes, that started with the 1984 ABC800J',
images: [
'https://user-images.githubusercontent.com/35371660/230762100-b119d836-3c5b-4980-9846-b7d32ea4a08f.png',
'https://user-images.githubusercontent.com/35371660/230762118-46d965ab-7ea8-4e8a-9c0f-3ed90f96e1cd.png',
'https://user-images.githubusercontent.com/35371660/230762139-002578da-092d-4f34-8cae-2cf3b0dfabe9.png',
],
count: 4,
},
},
});
});
it('장바구니에 포함된 아이템들의 이름, 수량, 합계가 제대로 노출된다', async () => {
await render(<ProductInfoTable />);
const [firstItem, secondItem] = screen.getAllByRole('row');
expect(
within(firstItem).getByText('Handmade Cotton Fish'),
).toBeInTheDocument();
expect(within(firstItem).getByRole('textbox')).toHaveValue('3');
expect(within(firstItem).getByText('$2,427.00')).toBeInTheDocument();
expect(
within(secondItem).getByText('Awesome Concrete Shirt'),
).toBeInTheDocument();
expect(within(secondItem).getByRole('textbox')).toHaveValue('4');
expect(within(secondItem).getByText('$1,768.00')).toBeInTheDocument();
});
it('특정 아이템의 수량이 변경되었을 때 값이 재계산되어 올바르게 업데이트 된다', async () => {
const { user } = await render(<ProductInfoTable />);
const [firstItem] = screen.getAllByRole('row');
const input = within(firstItem).getByRole('textbox');
await user.clear(input);
await user.type(input, '5');
// 809 * 5 = 4045
expect(screen.getByText('$4,045.00')).toBeInTheDocument();
});
it('특정 아이템의 수량이 1000개로 변경될 경우 "최대 999개 까지 가능합니다!"라고 경고 문구가 노출된다', async () => {
const alertSpy = vi.fn();
// windows.alert를 모킹
vi.stubGlobal('alert', alertSpy);
const { user } = await render(<ProductInfoTable />);
const [firstItem] = screen.getAllByRole('row');
const input = within(firstItem).getByRole('textbox');
await user.clear(input);
await user.type(input, '1000');
expect(alertSpy).toHaveBeenNthCalledWith(1, '최대 999개 까지 가능합니다!');
});
it('특정 아이템의 삭제 버튼을 클릭할 경우 해당 아이템이 사라진다', async () => {
const { user } = await render(<ProductInfoTable />);
const [, secondItem] = screen.getAllByRole('row');
const deleteButton = within(secondItem).getByRole('button');
expect(screen.getByText('Awesome Concrete Shirt')).toBeInTheDocument();
await user.click(deleteButton);
// queryBy~를 사용하여 요소가 존재하지 않아도 에러가 발생하지 않도록 한다
expect(screen.queryByText('Awesome Concrete Shirt')).not.toBeInTheDocument();
});
5. msw으로 API(TanStack query) 모킹
(1) TanStack query 테스트 환경 설정
// render.jsx
// https://tanstack.com/query/v4/docs/react/guides/testing
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// ✅ turns retries off
retry: false,
},
},
logger: {
log: console.log,
warn: console.warn,
// ✅ no more errors on the console for tests
error: process.env.NODE_ENV === 'test' ? () => {} : console.error,
},
});
(2) API 모킹 핸들러 생성
다음과 같은 핸들러에서 API 요청을 가로채서 응답을 설정할 수 있다.
// __mocks__/handlers.js
import { rest } from 'msw';
import response from '@/__mocks__/response';
import { apiRoutes } from '@/apiRoutes';
const API_DOMAIN = 'http://localhost:3000';
export const handlers = [
...[
// URL 경로
apiRoutes.users,
apiRoutes.product,
apiRoutes.categories,
apiRoutes.couponList,
].map(path =>
// 해당 경로에 대한 응답 설정
rest.get(`${API_DOMAIN}${path}`, (_, res, ctx) =>
res(ctx.status(200), ctx.json(response[path])),
),
),
rest.get(`${API_DOMAIN}${apiRoutes.products}`, (req, res, ctx) => {
const data = response[apiRoutes.products];
const offset = Number(req.url.searchParams.get('offset'));
const limit = Number(req.url.searchParams.get('limit'));
const products = data.products.filter(
(_, index) => index >= offset && index < offset + limit,
);
return res(
ctx.status(200),
ctx.json({ products, lastPage: products.length < limit }),
);
}),
rest.get(`${API_DOMAIN}${apiRoutes.profile}`, (req, res, ctx) => {
return res(ctx.status(200), ctx.json(null));
}),
rest.post(`${API_DOMAIN}${apiRoutes.users}`, (req, res, ctx) => {
if (req.body.name === 'FAIL') {
return res(ctx.status(500));
}
return res(ctx.status(200));
}),
rest.post(`${API_DOMAIN}${apiRoutes.login}`, (req, res, ctx) => {
if (req.body.email === 'FAIL@gmail.com') {
return res(ctx.status(401));
}
return res(
ctx.status(200),
ctx.json({
access_token: 'access_token',
}),
);
}),
rest.post(`${API_DOMAIN}${apiRoutes.log}`, (_, res, ctx) => {
return res(ctx.status(200));
}),
];
(3) setupTests.js에서 msw 설정
import { setupServer } from 'msw/node';
import { handlers } from '@/__mocks__/handlers';
// msw 서버 생성
export const server = setupServer(...handlers);
beforeAll(() => {
// msw 서버 시작
server.listen();
});
afterEach(() => {
// msw 서버 초기화
// 일부 테스트는 server.use()를 사용하여 API 응답을 변경하기 때문에 초기화가 필요
server.resetHandlers();
vi.clearAllMocks();
});
afterAll(() => {
vi.resetAllMocks();
// msw 서버 종료
server.close();
});
6. msw으로 API(TanStack query) 통합 테스트
import { screen, within } from '@testing-library/react';
import React from 'react';
import data from '@/__mocks__/response/products.json';
import ProductList from '@/pages/home/components/ProductList';
import { formatPrice } from '@/utils/formatter';
import {
mockUseUserStore,
mockUseCartStore,
} from '@/utils/test/mockZustandStore';
import render from '@/utils/test/render';
const PRODUCT_PAGE_LIMIT = 5;
const navigateFn = vi.fn();
vi.mock('react-router-dom', async () => {
const original = await vi.importActual('react-router-dom');
return {
...original,
useNavigate: () => navigateFn,
useLocation: () => ({
state: {
prevPath: 'prevPath',
},
}),
};
});
it('로딩이 완료된 경우 상품 리스트가 제대로 모두 노출된다', async () => {
await render(<ProductList limit={PRODUCT_PAGE_LIMIT} />);
// findBy~를 사용하면 기본적으로 1초동안 50ms마다 요소를 조회하여 비동기 요소를 기다린다.
const productCards = await screen.findAllByTestId('product-card');
expect(productCards).toHaveLength(PRODUCT_PAGE_LIMIT);
productCards.forEach((el, index) => {
const productCard = within(el);
const product = data.products[index];
expect(productCard.getByText(product.title)).toBeInTheDocument();
expect(productCard.getByText(product.category.name)).toBeInTheDocument();
expect(
productCard.getByText(formatPrice(product.price)),
).toBeInTheDocument();
expect(
productCard.getByRole('button', { name: '장바구니' }),
).toBeInTheDocument();
expect(
productCard.getByRole('button', { name: '구매' }),
).toBeInTheDocument();
});
});
it('보여줄 상품 리스트가 더 있는 경우 show more 버튼이 노출되며, 버튼을 누르면 상품 리스트를 더 가져온다.', async () => {
const { user } = await render(<ProductList limit={PRODUCT_PAGE_LIMIT} />);
await screen.findAllByTestId('product-card');
expect(screen.getByText('Show more')).toBeInTheDocument();
const moreBtn = screen.getByText('Show more');
await user.click(moreBtn);
expect(await screen.findAllByTestId('product-card')).toHaveLength(
PRODUCT_PAGE_LIMIT * 2,
);
});
it('보여줄 상품 리스트가 없는 경우 show more 버튼이 노출되지 않는다.', async () => {
await render(<ProductList limit={20} />);
await screen.findAllByTestId('product-card');
expect(screen.queryByText('Show more')).not.toBeInTheDocument();
});
describe('로그인 상태일 경우', () => {
beforeEach(() => {
mockUseUserStore({ isLogin: true, user: { id: 10 } });
});
it('구매 버튼 클릭시 addCartItem 메서드가 호출되며, "/cart" 경로로 navigate 함수가 호출된다.', async () => {
const addCartItemFn = vi.fn();
mockUseCartStore({ addCartItem: addCartItemFn });
const { user } = await render(<ProductList limit={PRODUCT_PAGE_LIMIT} />);
await screen.findAllByTestId('product-card');
// 첫번째 상품을 대상으로 검증한다.
const productIndex = 0;
await user.click(
screen.getAllByRole('button', { name: '구매' })[productIndex],
);
expect(addCartItemFn).toHaveBeenNthCalledWith(
1,
data.products[productIndex],
10,
1,
);
expect(navigateFn).toHaveBeenNthCalledWith(1, '/cart');
});
it('장바구니 버튼 클릭시 "장바구니 추가 완료!" toast를 노출하며, addCartItem 메서드가 호출된다.', async () => {
const addCartItemFn = vi.fn();
mockUseCartStore({ addCartItem: addCartItemFn });
const { user } = await render(<ProductList limit={PRODUCT_PAGE_LIMIT} />);
await screen.findAllByTestId('product-card');
// 첫번째 상품을 대상으로 검증한다.
const productIndex = 0;
const product = data.products[productIndex];
await user.click(
screen.getAllByRole('button', { name: '장바구니' })[productIndex],
);
expect(addCartItemFn).toHaveBeenNthCalledWith(1, product, 10, 1);
expect(
screen.getByText(`${product.title} 장바구니 추가 완료!`),
).toBeInTheDocument();
});
});
describe('로그인이 되어 있지 않은 경우', () => {
it('구매 버튼 클릭시 "/login" 경로로 navigate 함수가 호출된다.', async () => {
const { user } = await render(<ProductList limit={PRODUCT_PAGE_LIMIT} />);
await screen.findAllByTestId('product-card');
// 첫번째 상품을 대상으로 검증한다.
const productIndex = 0;
await user.click(
screen.getAllByRole('button', { name: '구매' })[productIndex],
);
expect(navigateFn).toHaveBeenNthCalledWith(1, '/login');
});
it('장바구니 버튼 클릭시 "/login" 경로로 navigate 함수가 호출된다.', async () => {
const { user } = await render(<ProductList limit={PRODUCT_PAGE_LIMIT} />);
await screen.findAllByTestId('product-card');
// 첫번째 상품을 대상으로 검증한다.
const productIndex = 0;
await user.click(
screen.getAllByRole('button', { name: '장바구니' })[productIndex],
);
expect(navigateFn).toHaveBeenNthCalledWith(1, '/login');
});
});
it('상품 클릭시 "/product/:productId" 경로로 navigate 함수가 호출된다.', async () => {
const { user } = await render(<ProductList limit={PRODUCT_PAGE_LIMIT} />);
const [firstProduct] = await screen.findAllByTestId('product-card');
// 첫번째 상품을 대상으로 검증한다.
await user.click(firstProduct);
expect(navigateFn).toHaveBeenNthCalledWith(1, '/product/6');
});