1. ํตํฉ ํ ์คํธ๋?
๋ ๊ฐ ์ด์์ ๋ชจ๋์ด ์ฐ๊ฒฐ๋ ์ํ๋ฅผ ๊ฒ์ฆ.
๋ชจ๋๋ค์ด ์ํธ ์์ฉํ์ฌ ๋ฐ์ํ๋ ์ํ๋ฅผ ๊ฒ์ฆ.
๊ทธ๋์ ์ค์ ์ฑ์ด ๋์ํ๋ ๋น์ฆ๋์ค ๋ก์ง์ ๊ฐ๊น๊ฒ ๊ธฐ๋ฅ์ ๊ฒ์ฆํ ์ ์๋ค.
ํตํฉ ํ ์คํธ ํญ๋ชฉ
- ํน์ ์ํ๋ฅผ ๊ธฐ์ค์ผ๋ก ๋์ํ๋ ์ปดํฌ๋ํธ ์กฐํฉ
- API์ ํจ๊ป ์ํธ์์ฉ ํ๋ ์ปดํฌ๋ํธ ์กฐํฉ
- ๋จ์ UI ๋ ๋๋ง ๋ฐ ๊ฐ๋จํ ๋ก์ง์ ์คํํ๋ ์ปดํฌ๋ํธ(๋จ์ ํ ์คํธ๋ฅผ ํ์ง ์์๋)๊ฐ ์ ๋๋ก ๋ ๋๋ง ๋๋์ง ํ๋ฒ์ ๊ฒ์ฆ
์์
์์
์๋ฃ์ ProductList์ ProductCard ์ปดํฌ๋ํธ๋ฅผ ๋ณด์.
const ProductCard = ({
product,
onClickAddCartButton,
onClickPurchaseButton,
}) => {
const navigate = useNavigate();
if (!product) {
return null;
}
const { title, images, price, category, id } = product;
const handleClickItem = () => {
navigate(pathToUrl(pageRoutes.productDetail, { productId: id }));
};
const handleClickAddCartButton = ev => {
onClickAddCartButton(ev, product);
};
const handleClickPurchaseButton = ev => {
onClickPurchaseButton(ev, product);
};
return (.....);
};
ํ๋ก๋ํธ ์นด๋๋ ์์ ์ ํธ์ถํ๋ ํ๋ก๋ํธ๋ฆฌ์คํธ ์ปดํฌ๋ํธ์์ ์ ๋ฌ๋ฐ์ prop๋ค(product ๊ฐ์ฒด, ์ด๋ฒคํธ ํธ๋ค๋ฌ๋ค)์ ๊ทธ๋๋ก ์ฌ์ฉํ๋ค.
๊ทธ๋ฆฌ๊ณ ์ด prop๋ค์ ๊ฐ์ ธ์ค๊ณ ์ ์ํ๋ ๊ณณ์ ํ๋ก๋ํธ๋ฆฌ์คํธ ์ปดํฌ๋ํธ๋ค.
const ProductList = ({ limit = PRODUCT_PAGE_LIMIT }) => {
...
...
const products =
data?.pages.reduce((acc, cur) => [...acc, ...cur.products], []) ?? [];
const handleClickCart = (ev, product) => {
ev.stopPropagation();
if (isLogin) {
addCartItem(product, user.id, 1);
toast.success(`${product.title} ์ฅ๋ฐ๊ตฌ๋ ์ถ๊ฐ ์๋ฃ!`, { id: TOAST_ID });
} else {
navigate(pageRoutes.login);
}
};
const handleClickPurchase = (ev, product) => {
ev.stopPropagation();
if (isLogin) {
addCartItem(product, user.id, 1);
navigate(pageRoutes.cart);
} else {
navigate(pageRoutes.login);
}
};
return (
<Grid container spacing={1} rowSpacing={1} justifyContent="center">
{products.map((product, index) => (
<ProductCard
key={`${product.id}_${index}`}
product={product}
onClickAddCartButton={handleClickCart}
onClickPurchaseButton={handleClickPurchase}
/>
))}
...
...
);
};
๋จผ์ ProductCard๋ฅผ ๋จ์ํ
์คํธ ํ์ ๋ ๊ฒ์ฆํ ์ ์๋ ํญ๋ชฉ๋ค์ ๋ค์๊ณผ ๊ฐ๋ค :
- prop ๊ธฐ์ค์ผ๋ก ์ ํ ์ ๋ณด๊ฐ(price, name ..) ์ ๋๋ก ๋ ๋๋ง ๋๋์ง
- ์ํ์ ํด๋ฆญ ํ์ ๋(
handleClickItem) navigate ๋ชจํน์ ํตํด ์์ธํ๋ฉด์ผ๋ก ์ด๋ํ๋์ง - ์ฅ๋ฐ๊ตฌ๋, ๊ตฌ๋งค ๋ฒํผ์ ๋๋ ์ ๋ spy ํจ์๋ฅผ ํตํด ๊ฐ ํธ๋ค๋ฌ๊ฐ ํธ์ถ๋๋์ง
- ํธ์ถ์ด ๋๋์ง๋ ๊ฒ์ฆํ ์ ์์ง๋ง ์๋ํ๋์ง ๊ฒ์ฆํ ์ ์๋ค.
๋ฐ๋ฉด ProductList๋ฅผ ํตํฉํ
์คํธ ํ์ ๋ ๊ฒ์ฆํ ์ ์๋ ํญ๋ชฉ๋ค์ ๋ค์๊ณผ ๊ฐ๋ค :
- ์ํ ๋ฆฌ์คํธ ์กฐํ api์ ๋ง๊ฒ ์ํ ์ ๋ณด๊ฐ ์ ๋๋ก ๋ ๋๋ง ๋๋์ง
- ์ํ์ ํด๋ฆญ ํ์ ๋ navigate ๋ชจํน์ ํตํด ์์ธํ๋ฉด์ผ๋ก ์ด๋ํ๋์ง
- ์ฅ๋ฐ๊ตฌ๋ ํน์ ๊ตฌ๋งค ๋ฒํผ์ ๋๋ ์ ๋ ๋ค์๊ณผ๊ฐ์ด ์๋ ํ๋์ง
- ๋ก๊ทธ์ธ : ์ํ ์ถ๊ฐ ํ ์ฅ๋ฐ๊ตฌ๋๋ก ์ด๋
- ๋น๋ก๊ทธ์ธ : ๋ก๊ทธ์ธ ํ์ด์ง๋ก ์ด๋
- ์ํ ๋ฆฌ์คํธ๊ฐ ๋ ์๋ ๊ฒฝ์ฐ show more ๋ฒํผ์ด ๋ ธ์ถ๋๊ณ , ์ด๋ฅผ ํตํด ๋ฐ์ดํฐ๋ฅผ ๋ ๊ฐ์ ธ์ฌ ์ ์๋์ง
์์ ๋ ํญ๋ชฉ๋ค์ ๋น๊ตํด๋ณด๋ฉด 1,2,3๋ฒ์ ์ค๋ณต๋๋ค๊ณ ๋ณผ ์ ์๋ค. (ํต1,2,3 ์ด ์ฑ๊ณตํ๋ค๋๊ฒ์ ๋จ1,2,3 ์ด ์ฑ๊ณตํจ์ ํจ์ํ๋ค) ๊ทธ๋์ ํ๋ก๋ํธ ์นด๋์ ๋ํ ๋จ์ํ ์คํธ๋ฅผ ํ์ง ์๊ณ ํ๋ก๋ํธ๋ฆฌ์คํธ์ ํตํฉํ ์คํธ์์ ํ๋ฒ์ ๊ฒ์ฆํ๋๊ฒ์ด ๋ ํจ๊ณผ์ ์ด๋ค.
2. ํตํฉ ํ ์คํธ ๋์ ์ ์ ํ๊ธฐ
๋จ์ ํ
์คํธ๋ ์์กด์ฑ์ด ์ ๊ฑฐ๋ ์๋ ๋จ์ํ ์ปดํฌ๋ํธ๋ฅผ ๋์์ผ๋ก ํ๋ค.
๊ทธ๋ฌ๋ ํตํฉ ํ
์คํธ๋ API, ์ํ ๊ด๋ฆฌ ์คํ ์ด, ๋ฆฌ์กํธ ์ปจํ
์คํธ ๋ฑ ๋ค์ํ ์์๋ค์ด ๊ฒฐํฉ๋ ์ปดํฌ๋ํธ๊ฐ ํน์ ๋น์ฆ๋์ค ๋ก์ง์ ์ฌ๋ฐ๋ฅด๊ฒ ์ํํ๋์ง ๊ฒ์ฆ. (์ฆ ์ปดํฌ๋ํธ๊ฐ์ ์ํธ์์ฉ, api ํธ์ถ ๋ฐ ์ํ ๋ณ๊ฒฝ์ ๋ฐ๋ฅธ UI ๋ณ๊ฒฝ ์ฌํญ ๊ฒ์ฆ)
์์ ์ ๋ฉ์ธ ํ์ด์ง์ ๋น์ฆ๋์ค ๋ก์ง๋ค๋ก๋ ๋ค์๊ณผ ๊ฐ์๊ฒ๋ค์ด ์๋ค :
- ๋ค๋น๊ฒ์ด์ ์์ญ์ ๋ก๊ทธ์ธ ์ฌ๋ถ์ ๋ฐ๋ฅธ ๋์
- api๋ฅผ ํตํด ํํฐ์ ์นดํ ๊ณ ๋ฆฌ ๋ฐ์ดํฐ๋ฅผ ์ฌ๋ฐ๋ฅด๊ฒ ๋ ๋๋ง ํ๋์ง
- ํํฐ ํญ๋ชฉ์ ์์ ํ์ ๋ ์ฌ๋ฐ๋ฅด๊ฒ ๋ฐ์๋๋์ง
- api ์๋ต์ ๋ฐ๋ผ ์ํ ๋ฆฌ์คํธ๊ฐ ์ ์ ํ๊ฒ ๋ ๋๋ง ๋๋์ง
- ๋ฑ๋ฑ
๋น์ฆ๋์ค ๋ก์ง :
- ํ๋ก๊ทธ๋จ์ ํต์ฌ ๊ธฐ๋ฅ์ ๊ตฌํํ๋ ์ฝ๋
- ์ฆ ์ฌ์ฉ์๊ฐ ์ํ๋ ๊ฒฐ๊ณผ๋ฅผ ์ป๊ธฐ ์ํ ๊ณ์ฐ, ์ฒ๋ฆฌ, ์์ฌ ๊ฒฐ์ ์ ์ํํ๋ ์ฝ๋
์ข์ ํตํฉ ํ
์คํธ๋ ๋น์ฆ๋์ค ๋ก์ง์ ๋๋ฉ์ธ ๋จ์๋ก ์ ๋๋์ด ์ํ๋์ด์ผ ํ๋ค.
๋ฉ์ธ ํ์ด์ง์ ๋น์ฆ๋์ค ๋ก์ง๋ค์ ๋ค์๊ณผ ๊ฐ์ด ๋ถ๋ฅ๋ ์ ์๋ค.
- ๋ค๋น๊ฒ์ด์
๋ฐ ์์ญ
- ๋ก๊ทธ์ธ ์ฌ๋ถ์ ๋ฐ๋ฅธ UI ๋ ๋๋ง(์ฅ๋ฐ๊ตฌ๋)๊ณผ ์ํธ์์ฉ(๋ก๊ทธ์ธ, ๋ก๊ทธ์์)
- ์ํ ๊ฒ์ ์์ญ
- ํํฐ ์ค์ ์ ๋ฐ๋ฅธ ๊ฒ์ ์กฐ๊ฑด ์ค์
- ์ํ ๋ฆฌ์คํธ ์์ญ
- ๊ฒ์ ๊ฒฐ๊ณผ์ ๋ฐ๋ฅธ ์ํ ๋ฆฌ์คํธ ๋ ๋๋ง๊ณผ ๋ฒํผ ํด๋ฆญ ์ํธ์์ฉ(์ฅ๋ฐ๊ตฌ๋ ์ถ๊ฐ, ์์ธํ ๋ณด๊ธฐ)
๋ง์ฝ์ ์ํ ๊ฒ์ ์์ญ์์ ๊ฒ์ ํํฐ์ ์๋ก์ด ํ๋๋ฅผ ์ถ๊ฐํ๊ฑฐ๋ ํ๋๋ฑ์ ๋ณ๊ฒฝ์ฌํญ์ด ์๊ฑฐ๋, ์ํ ๋ฆฌ์คํธ์์ ๋ณด์ฌ์ฃผ๋ ์ํ์ ์ ๋ณด๊ฐ ๋ณ๊ฒฝ๋๊ฑฐ๋, ์คํฌ๋กค ๋ก๋ฉ ๋ฐฉ์์์ ํ์ด์ง ๋ฐฉ์์ผ๋ก ๋ณ๊ฒฝํ๋ ๋ฑ์ ๊ฐ ๋๋ฉ์ธ์์์ ๋ณ๊ฒฝ์ฌํญ์ด ์์ด๋ ํด๋น ๋๋ฉ์ธ์ ํ ์คํธ ์ฝ๋๋ง ๋ณ๊ฒฝํ๋ฉด ๋๋ค.
์ฆ ๋ค์๊ณผ ๊ฐ์ด ํ ์คํธ๋ฅผ ์ค๊ณํ๋ฉด ๋๋ค.
- ๋ฉ์ธ ํ์ด์ง๋ฅผ ์ด๋ฃจ๋ ๋ชจ๋ ์์๋ค์ ๋์ดํ๊ณ , ๊ทธ ์์๋ค์ ๋๋ฉ์ธ ๋จ์๋ก ๋ฌถ์ด์ ์์ ์ปดํฌ๋ํธ๋ฅผ ๋ง๋ ๋ค.
- ํ์ด์ง(
page.tsx) -> ์์ ์ปดํฌ๋ํธ(ProductList.tsx)-> ํ์ ์ปดํฌ๋ํธ(๋ฒํผ, ์ํ์ ๋ณด ๋ฑ ๋ ๋๋ง ๋๋ ui ์์)
- ํ์ด์ง(
- ๊ทธ๋ฆฌ๊ณ ๊ทธ ์์ ์ปดํฌ๋ํธ์์ ์์ ์ปดํฌ๋ํธ๋ค์ ์ํ ๊ด๋ฆฌ๋ api ํธ์ถ์ ์์ง์ํจ๋ค.
- ํตํฉ ํ ์คํธ๋ ์์ ์์ ์ปดํฌ๋ํธ ๋จ์๋ก ์ํํ๋ค.
3. ์ํ ๊ด๋ฆฌ ๋ชจํนํ๊ธฐ
์ํ : ์๊ฐ์ ๋ฐ๋ผ ๋ณํ ์ ์๋ ๋ฐ์ดํฐ. ํ๋ก ํธ์์ ํนํ '์ํ'๊ฐ ์ค์ํ๊ฒ ๋ค๋ค์ง๋ ์ด์ ๋ UI๊ฐ ์ํ์ ์ง์ ์ ์ผ๋ก ์์กดํ๊ธฐ ๋๋ฌธ์ด๋ค.
๊ฐ์ ์์ ์์ CartTable ์ปดํฌ๋ํธ๋ ๋ค์๊ณผ ๊ฐ์ ํ์ ์ปดํฌ๋ํธ๋ค๋ก ๊ตฌ์ฑ๋์ด ์๋ค.
const CartTable = () => {
return (
<>
<PageTitle />
<ProductInfoTable />
<Divider sx={{ padding: 2 }} />
<PriceSummary />
</>
);
};
์ฌ๊ธฐ์ PageTitle๊ณผ Divider ์ปดํฌ๋ํธ๋ ๋๋ฌด ๋จ์ํ๊ธฐ ๋๋ฌธ์ ํ
์คํธ๊ฐ ํ์ํ์ง ์๊ณ
ProductInfoTable : ์ฅ๋ฐ๊ตฌ๋์ ๋ด๊ธด ์ํ ๋ชฉ๋ก
PriceSummary : ๊ฐ๊ฒฉ ์ดํฉ
์์ ๋ ์ปดํฌ๋ํธ๋ ํ
์คํธ๊ฐ ํ์ํ๋ค.
์ด๋ป๊ฒ ํด์ผํ ๊น? ์ผ๋จ ๋ ์ปดํฌ๋ํธ๋ ์๋ก ์์กดํ๊ณ ์์ง ์๊ณ ๋ชจ๋ zustand ์คํ ์ด์์ ์ํ๋ ์ก์
์ ์ฌ์ฉํ๋ค.
๊ทธ๋์ ์ด ์ํ์ ์ก์
์ ๋ชจํนํ๋ค๋ฉด ๋ ์ปดํฌ๋ํธ ๋ชจ๋ ํ
์คํธํ ์ ์๊ฒ ๋๋ค.
์ฃผ๋ก ๋ฃจํธ์ __mocks__ ํด๋๋ฅผ ๋ง๋ค๊ณ ํ์ ๊ฒฝ๋ก์ ๋ชจํนํ์ผ์ ์์ฑํด๋๋ฉด vitest๋ jest ์์ ์๋์ผ๋ก ๋ชจํน์์ ์ฌ์ฉํ๋ค.
// __mocks__/zustand.js
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()));
});
// src/utils/test/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);
};
์ฃผ์คํ ๋ ๋ชจํน ํจ์๋ค์ ๋ณด๋ฉด ๋ฐ๋ก ์ฃผ์คํ ๋ ์คํ ์ด์ ์ํ๋ฅผ ์ง์ ๋ณ๊ฒฝ(hook.setState())ํ๋ ๊ฒ์ ๋ณผ ์ ์๋ค.
์ด๊ฒ ๊ฐ๋ฅํ ์ด์ ๋ /__mocks__/zustand.js์์ ํ
์คํธ ์คํ์ ์์ ์คํ ์ด๋ฅผ ๋ง๋ค๊ธฐ ๋๋ฌธ.
๊ทธ๋ฐ๋ฐ ์ด์ฐจํผ ์์ ์คํ ์ด๋ฅผ ๋ง๋ ๋ค๋ฉด ๊ตณ์ด ๋ชจํน ํจ์ mockZustandStore.jsx๊ฐ ํ์ํ ๊น?
export const useUserStore = create(set => ({
isLogin: Cookies.get('access_token'),
user: null,
setIsLogin: isLogin => set(state => ({ ...state, isLogin })),
setUserData: user => set(state => ({ ...state, user })),
}));
์๋ ์ฃผ์คํ ๋ ์ก์
์ ํตํด isLogin์ ๋ณ๊ฒฝํ๋ ค๋ฉด setIsLogin()์ ์ฌ์ฉํด์ผ ํ์ง๋ง, ์์ ๋ฐฉ์์ฒ๋ผ ์ง์ ์ํ๋ฅผ ๋ณ๊ฒฝํ๋ฉด ๋ค์๊ณผ ๊ฐ์ด ํ๋ฒ์ ์ฌ๋ฌ ์ํ๋ฅผ ๋ณ๊ฒฝํ ์ ์๋ค.
mockUseUserStore({ isLogin: true, user: { id: 10 } });
// useUserStore.setIsLogin(true);
// useUserStore.setUserData({id:10});
// ์์ ๋ ์ก์
์ ๋์์ ํ๋ ํจ๊ณผ
์คํ์ด ํจ์ ์ฌ์ฉ์ ์ํด
๋ฌด์๋ณด๋ค ์ฃผ์คํ ๋ ์คํ ์ด ๋ชจํน ํจ์๊ฐ ํ์ํ ์ด์ ๋ ์คํ์ด ํจ์ ์ฌ์ฉ์ ์ฉ์ด์ฑ์ ์ํด์๋ค.
์์ธํ ๋ด์ฉ์ ์๋ "6. RTL ๋น๋๊ธฐ ์ ํธ ํจ์๋ฅผ ํตํ ๋
ธ์ถ ํ
์คํธ ์์ฑ" ๋ถ๋ฌธ์ ์๋ค.
4. ํตํฉ ํ ์คํธ ์์ฑํ๊ธฐ - ์ํ ๊ด๋ฆฌ ๋ชจํน
์์์ ๋งํ ๋ ์ปดํฌ๋ํธ๋ฅผ ํ ์คํธํ๊ธฐ ์ํด ์ํ ๊ด๋ฆฌ๋ฅผ ๋ชจํนํ๋ ๋ฐฉ๋ฒ์ ์์์ผ๋ ์ด์ ์ ์ฉํด๋ณธ๋ค.
ProductInfoTable
const ProductInfoTable = () => {
const { cart, removeCartItem, changeCartItemCount } = useCartStore(state =>
pick(state, 'cart', 'removeCartItem', 'changeCartItemCount'),
);
const { user } = useUserStore(state => pick(state, 'user'));
return (
<TableContainer component={Paper} sx={{ wordBreak: 'break-word' }}>
<Table aria-label="์ฅ๋ฐ๊ตฌ๋ ๋ฆฌ์คํธ">
<TableBody>
{Object.values(cart).map(item => (
<ProductInfoTableRow
key={item.id}
item={item}
user={user}
removeCartItem={removeCartItem}
changeCartItemCount={changeCartItemCount}
/>
))}
</TableBody>
</Table>
</TableContainer>
);
};
๋จ์ ํ ์คํธ?
ํ์ ์ปดํฌ๋ํธ์ธ ProductInfoTableRow๋ ์ฌ๋ฌ ๊ธฐ๋ฅ์ ๊ฐ์ง๊ณ ์๋ ์ปดํฌ๋ํธ์ธ๋ฐ ๋ ์ ํํ ํ
์คํธ๋ฅผ ์ํด์๋ ์ด ์ปดํฌ๋ํธ๋จผ์ ํ
์คํธ๋ฅผ ํด์ผํ์ง ์์๊น? ์๋๊น?
๊ฐ์์์๋ ์ด์ฐจํผ ProductInfoTableRow๋ฅผ ๊ฒ์ฆํ ๋ ํ ์ ์๋๊ฑด ์ธ์๋ก ์ ๋ฌ๋ฐ์ ์ํ๋ ํจ์๋ฅผ ์ ๋๋ก ๋ ๋๋งํ๊ณ ํธ์ถํ๋์ง์ ๋ํ ์ฌ๋ถ ๋ฐ์ ๊ฒ์ฆํ ์ ์์ผ๋๊น ํตํฉํ
์คํธ์ ํฌํจํ์ฌ, ์ํ ๋ชฉ๋ก์ ์ ๋๋ก ์ถ๋ ฅํ๋์ง๋ฅผ ํ์ธํ๋๊ฒ ๋ ํจ๊ณผ์ ์ด๋ผ๊ณ ํ๋ค.
์ฌ๊ธฐ์ ์๋ฆฌ์กํด์ง๋๊ฒ ๊ทธ๋ฌ๋ฉด ์ ์ด์ ๋จ์ ํ
์คํธ๊ฐ ์ ํ์ํ๊ฑฐ์๋๋ผ?
์ ํธํจ์๋ ์ปค์คํ
ํ
๋ง๊ณ ๋ ๋๋ง ์ปดํฌ๋ํธ๋ฅผ ๋จ์ ํ
์คํธ๋ก ๊ฒ์ฆ ํ ํ์๊ฐ ์ ์๋๊ฑฐ์ง?
์ฐพ์๋ณด๋ ์ฌ๋ฌ ๊ณณ์์ ์ฌ์ฉ๋์ด ๋ณต์กํ ์กฐ๊ฑด๋ถ ๋ ๋๋ง์ ํ๋ ์ปดํฌ๋ํธ์์๋ (<Button variant={} size={} disabled={} onClick={} ...... />) ๋จ์ ํ
์คํธ๋ฅผ ํ๋๊ฒ์ด ์ข๊ณ ๊ทธ ์ธ์ ๋๋ถ๋ถ์ ๋ ๋๋ง ์ปดํฌ๋ํธ๋ค์ ์ฌ์ค ํตํฉํ
์คํธ์ ๊ฒ์ฆํ๋ค๊ณ ํ๋ค.
ํตํฉ ํ ์คํธ ์์ฑ
// ProductINfoTable.spec.jsx
beforeEach(() => {
mockUseUserStore({user: {id: 1}});
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',
],
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',
],
count: 4,
},
},
totalCount: 7,
totalPrice: 500,
});
});
์ด๋ ๊ฒ ์
์
์ ํตํด ์ํ๋ฅผ ๋ชจํนํ๋ค.
๊ทธ๋ฆฌ๊ณ ๋ค์๊ณผ ๊ฐ์ ํ์ํ ํ
์คํธ๋ค์ ์งํํ๋ฉด ๋๋ค.
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();
});
getBy, queryBy
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);
expect(screen.queryByText('Awesome Concrete Shirt')).not.toBeInTheDocument();
});
getByText๋ ๋์ ํด๋น ํ
์คํธ๊ฐ ์กด์ฌํ๋์ง ํ์ธํ๋ค.
๊ทธ๋ฐ๋ฐ ์ญ์ ๋ฒํผ์ ๋๋ฅด๋ฉด ํด๋น ์์๊ฐ ์ฌ๋ผ์ ธ์ผํ๋๋ฐ ์ฌ๋ผ์ง๊ฒ์ ํ์ธํ๋ ค๋ฉด getBy๊ฐ ์๋ queryBy ๋งค์ฒ๋ฅผ ์ฌ์ฉํด์ผ ํ๋ค๊ณ ํ๋ค.
๊ณต์๋ฌธ์์ ์ค๋ช
getBy...: Returns the matching node for a query, and throw a descriptive error if no elements match or if more than one match is found (usegetAllByinstead if more than one element is expected).queryBy...: Returns the matching node for a query, and returnnullif no elements match. This is useful for asserting an element that is not present. Throws an error if more than one match is found (usequeryAllByinstead if this is OK).
getBy๋ ์์๋ฅผ ์ฐพ์ง ๋ชปํ๋ฉด ์๋ฌ๋ฅผ ๋ฐ์์ํค๋ ๋ฐ๋ฉดqueryBy๋null์ ๋ฐํํ๋ค.
๊ทธ๋ฌ๋ฉด ์
queryBy๋ก ํต์ผํ์ง ์๊ณgetBy๋งค์ฒ๋ ์ฌ์ฉํ ๊น?
5. msw๋ก API ๋ชจํนํ๊ธฐ
Mock Service Worker
๊ฐ์์์๋ ๋๋ฌด ๊ฐ๋จํ๊ฒ๋ง ์ค๋ช
ํ๊ณ ๋์ด๊ฐ์ ๋ฐ๋ก ์ฐพ์๋ดค๋ค.1 2
๊ธฐ์กด ๋ชจํน ๋ฐฉ์
์ผํ๋ชฐ์ ๊ฐ๋ฐ์ค์ด๋ผ๋ฉด, ์ ํ์ ๋ํ ๋ฐ์ดํฐ๊ฐ ์์ด์ผ ui๋ฅผ ๊ตฌํํ ์ ์๋ค. ๊ทธ๋ฆฌ๊ณ ์ ํ์ ๋ํ ๋ฐ์ดํฐ๋ ๋น์ฐํ ์๋ฒ์ ์๋ค. ์ด ์๋ฒ ๋ฐฑ์๋๊ฐ ์์ง ๊ฐ๋ฐ๋์ง ์์๊ฑฐ๋ ๋ค๋ฅธ ์ฌ์ ๋ก ์ธํด ์ฌ์ฉํ ์ ์๋ ๊ฒฝ์ฐ์๋ ์ด๋ป๊ฒ ํด์ผํ ๊น?
๊ฐ์ฅ ๋จผ์ ๋ ์ค๋ฅด๋ ์๊ฐ์ ๊ทธ๋ฅ ๋ชจํน์ฉ ๋ฐ์ดํฐ๋ฅผ ์ฑ ์์ ์ง์ ์์ฑํ๋ ๊ฒ์ด๋ค. ์ด๋ ๊ฒ๋ UI๊ตฌํ๊น์ง๋ ๋ฌธ์ ์์ด ์งํํ ์ ์๋ค.
๊ทธ๋ฐ๋ฐ ์๋ฒ์ ์ํธ์์ฉํ๋ api๊ฐ ์ ๋๋ก ์๋ํ๋์ง๋ ๊ฒ์ฆ์ ํด์ผํ๋ค๋ฉด?
๋จผ์ ์์์ ๋ชจํน ์๋ฒ๋ฅผ ๋ง๋ค์ ์๋ค. ์ด๋ ๊ฒ ํ๋ฉด ์ง์ ์๋ฒ์ ์ํธ์์ฉํ๋ ์๋น์ค ๋ก์ง์ ๋ณ๊ฒฝํ ํ์ ์์ด ๊ทธ๋๋ก ์ฌ์ฉํ ์ ์๋ค. ํ์ง๋ง ์ด ๋ฐฉ๋ฒ์ ๋น์ฉ(์๊ฐ, ์ธ๋ ฅ ๋ฑ)์ด ๋ง์ด ์๋ชจ๋๋ ๋ฐฉ๋ฒ์ด๋ผ ์ ํธ๋์ง ์๋๋ค.
๊ทธ๋ฆฌ๊ณ ๋ชจํน ์๋ฒ์ ๋จ์ ์ ์์ธ ์ฒ๋ฆฌ์ ๋ํ ํ
์คํธ๊ฐ ์ด๋ ต๋ค๋ ๋จ์ ์ด ์๋ค.
์ค๋ฅ๊ฐ ๋ฐ์ํ๊ณ , ์ด๋ค ์ค๋ฅ์ธ์ง, ๋ก๋ฉ์ด ๊ธธ์ด์ง๋ฉด ์ด๋ป๊ฒ ํด์ผํ๋์ง ๋ฑ๋ฑ ์ฌ์ฉ์ ๊ฒฝํ๊ณผ ๊ด๋ จ๋ ์ฌ๋ฌ ui์ ๋ก์ง์ ๊ฐ๋ฐํ๋ ค๋ฉด ๊ฐ๋ฐ์๊ฐ ์ฝ๊ฒ ์๋ฒ ์๋ต ์ํ๋ฅผ ๋ฐ๊ฟ ์ ์์ด์ผํ๋๋ฐ, ๋ชจํน ์๋ฒ ์ฌ์ฉ์์๋ ์๋ฒ์ ์ฝ๋๋ฅผ ๋ฐ๊พธ๊ฑฐ๋ ํน์ ๊ธฐํ ๋ค๋ฅธ ๋ฐฉ๋ฒ์ ์ฌ์ฉํด์ผ ํ๋ค.
MSW
MSW ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ ์ด๋ฐ ๋จ์ ์ ์์ฝ๊ฒ ํด๊ฒฐํด์ค๋ค.
์ด ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ ์ญํ ์, ๋ธ๋ผ์ฐ์ ๊ฐ ์ ์กํ๋ api ์์ฒญ์ ๊ฐ๋ก์ฑ์ ๋ชจํน๋ ์๋ต์ ๋ธ๋ผ์ฐ์ ์๊ฒ ์ ๋ฌํ๋ค.
๋ธ๋ผ์ฐ์ ๋ฅผ ๋ ๋ ๋ค์ ์๋ต์ด ์ค๋๊ฒ์ด๊ธฐ ๋๋ฌธ์ ์๋ฒ๋ฅผ ์ฌ์ฉํ๋๊ฒ๊ณผ ๋์ผํ ๋ฐฉ์์ผ๋ก ํ
์คํธ๋ฅผ ์งํํ ์ ์๋ค.
๊ทธ๋ฆฌ๊ณ ์์ฝ๊ฒ ์๋ต์ ์ํ๋ฅผ ๋ณ๊ฒฝํ ์ ์์ด ์์ธ ์ฒ๋ฆฌ ๋ก์ง์ ๊ฐ๋ฐํ ๋ ์ฉ์ดํ๋ค.
์ด ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ ๋ธ๋ผ์ฐ์ ์ Service Worker๋ผ๋ ๊ธฐ์ ์ ํ์ฉํด ์ด๋ฐ ํธ๋ฆฌ๋ฅผ ์ ๊ณตํ๊ณ ์๋ค.
์ผ๋จ Worker๋ผ๋ ๊ฒ์ด ์๋๋ฐ, ์ด๊ฒ์ ๊ฐ๋จํ ๋ธ๋ผ์ฐ์ ๊ฐ ์น ์ฑ์ ์คํํ๊ณ ์๋ ๋ฉ์ธ ์ค๋ ๋๊ฐ ์๋ ๋ฐฑ๊ทธ๋ผ์ด๋ ์ค๋ ๋์์ ์๋ฐ์คํฌ๋ฆฝํธ ๋ฌธ์๋ฅผ ์คํํ ์ ์๋ค.
๊ทธ๋ฆฌ๊ณ Service Worker๋ ์ฑ๊ณผ ๋ธ๋ผ์ฐ์ ๊ทธ๋ฆฌ๊ณ ๋คํธ์ํฌ ์ฌ์ด์์ ํ๋ก์ ์๋ฒ ์ญํ ์ ํ๋ Worker๋ฅผ ๋งํ๋ค.
๊ตฌ์ฒด์ ์ธ ์ฌ์ฉ๋ฒ์ ๋ค์ ๊ฐ์์์ ๋ค๋ฃฌ๋ค.
6. RTL ๋น๋๊ธฐ ์ ํธ ํจ์๋ฅผ ํตํ ๋ ธ์ถ ํ ์คํธ ์์ฑ
์ด์ ProductList ์ปดํฌ๋ํธ์์ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ค๋ api(useProducts)๊ฐ ์ ๋๋ก ์๋ํด์ ํ๋ฉด์ ์ฌ๋ฐ๋ฅด๊ฒ ๋ ๋๋ง ๋๋์ง ํ์ธํ๊ธฐ ์ํ ์ฝ๋๋ฅผ ์์ฑํ๋ค.
it('๋ก๋ฉ์ด ์๋ฃ๋ ๊ฒฝ์ฐ ์ํ ๋ฆฌ์คํธ๊ฐ ์ ๋๋ก ๋ชจ๋ ๋
ธ์ถ๋๋ค', async () => {
await render(<ProductList limit={PRODUCT_PAGE_LIMIT} />);
const productCards = screen.getByTestId('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();
});
});
getByTestId : ํ๋ก๋ํธ์นด๋ ์ปดํฌ๋ํธ๋ ์ง๊ธ๊น์ง ์ฌ์ฉํด์จ ๋งค์ฒ getByText๋ getByRole๋ฅผ ํตํด ํ์ธํ๊ธฐ ํ๋ค๊ธฐ ๋๋ฌธ์ ๋ฏธ๋ฆฌ ProductCard์ปดํฌ๋ํธ์ ํ
์คํธ ์์ด๋๋ฅผ ์ง์ ํด์ ๊ทธ๊ฒ์ผ๋ก ์ฐพ์ ์ ์๋ค.
- ์ปดํฌ๋ํธ์ ์์ฑ์ ์ด๋ ๊ฒ ์ถ๊ฐํด์ ์ ์ํ๋ค - data-testid="product-card"
๊ทธ๋ฐ๋ฐ ์์ ํ
์คํธ์๋ ๋ฌธ์ ๊ฐ ์๋๋ฐ, getByTestId ๋ฅผ ์ฌ์ฉํ๋ ๊ฒ์ด๋ค.
ํ
์คํธ ์ฝ๋๋ ๊ธฐ๋ณธ์ ์ผ๋ก ๋๊ธฐ์ ์ผ๋ก ์คํ๋์ด ๋น๋๊ธฐ(Promise) ์ฝ๋๋ ์คํํ์ง ์๋๋ค.
๊ทธ๋ฌ๋ ์ํ ๋ชฉ๋ก์ ๊ฐ์ ธ์ค๋ API ํธ์ถ์ ๋น๋๊ธฐ ๋ฐฉ์์ผ๋ก ์๋ํ๊ณ promise๋ฅผ ๋ฐํํ๊ธฐ ๋๋ฌธ์ getBy๋ก ๋ ๋๋ง ๊ฒฐ๊ณผ๋ฅผ ํ์ธํ ์ ์๊ฒ ๋๋๊ฒ์ด๋ค.
๋์ ์ findBy... ํจ์๋ฅผ ์ฌ์ฉํ๋ฉด ์ ํด์ง ์๊ฐ๋์ ์ฌ๋ฌ๋ฒ ์ฌ์๋๋ฅผ ๋ฐ๋ณตํด api ์๋ต์ด ์๋ฃ๋์ด ์์ ํ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ฌ ๋ ๊น์ง ๊ธฐ๋ค๋ ธ๋ค๊ฐ ์ฑ๊ณต ์ฌ๋ถ๋ฅผ ํ์ธํ ์ ์๋ค.
ํตํฉํ ์คํธ์์์ ์คํ์ด ํจ์
๋จ์ ํ
์คํธ์์ ์คํ์ด ํจ์๋ฅผ ์ฌ์ฉํด ํจ์์ ํธ์ถ ์ฌ๋ถ์ ์ ๋ฌ๋๋ ์ธ์๋ค์ ๊ฒ์ฆํ ์ ์์๋ค.
ํตํฉ ํ
์คํธ์์๋ ์คํ์ด ํจ์๋ฅผ ์ฌ์ฉํ๋๋ฐ ์์
์์ ์ฌ์ฉํ ๋ฐฉ์์ ๋ค์๊ณผ ๊ฐ๋ค.
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();
});
});
addCartItem์ด๋ผ๋ ์คํ ์ด ์ก์
์ ์คํ์ดํจ์๋ก ๋์ฒดํ๊ณ ์๋ค. src/utils/test/mockZustandStore.jsx ์ ์ฃผ์คํ ๋ ๋ชจํน ํจ์๋ฅผ ์์ฑํด ๋์๋๋ฐ
// ProductList.jsx
const { addCartItem } = useCartStore(state => pick(state, 'addCartItem'));
...
const handleClickCart = (ev, product) => {
ev.stopPropagation();
if (isLogin) {
addCartItem(product, user.id, 1);
toast.success(`${product.title} ์ฅ๋ฐ๊ตฌ๋ ์ถ๊ฐ ์๋ฃ!`, { id: TOAST_ID });
} else {
navigate(pageRoutes.login);
}
};
ํ
์คํธ์ฉ์ผ๋ก ๋ ๋๋ง ๋ ์ปดํฌ๋ํธ๋ ์ handleClickCart๊ฐ ์คํ๋ ๋ ์ฃผ์คํ ๋์ ์ ์๋์ด ์๋ ๊ธฐ์กด์ addCartItem์ด ์๋ ์คํ์ด ํจ์๋ฅผ ํธ์ถํ๋ค.