-
Notifications
You must be signed in to change notification settings - Fork 2
테스트 코드 작성
jw0097 edited this page Aug 25, 2024
·
10 revisions
- UI가 잘 렌더링이 되는지
import React from 'react';
import { render, screen } from '@testing-library/react';
import MyComponent from './MyComponent';
test('컴포넌트가 올바르게 렌더링되는지 확인', () => {
render(<MyComponent />);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
- User Interaction이 잘 되는지
- 클릭, 입력폼, route 등
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ButtonComponent from './ButtonComponent';
test('버튼 클릭 시 콜백이 호출되는지 확인', async () => {
const handleClick = jest.fn();
render(<ButtonComponent onClick={handleClick} />);
const button = screen.getByRole('button');
await userEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FormComponent from './FormComponent';
test('입력 폼에 텍스트 입력이 잘 되는지 확인', async () => {
render(<FormComponent />);
const input = screen.getByPlaceholderText('이름을 입력하세요');
await userEvent.type(input, '홍길동');
expect(input).toHaveValue('홍길동');
});
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import App from './App';
test('라우팅이 정상적으로 되는지 확인', () => {
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>
);
expect(screen.getByText('홈페이지')).toBeInTheDocument();
});
- state 변경이 잘 되는지
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ButtonComponent from './ButtonComponent';
test('버튼 클릭 시 상태가 증가하는지 확인', async () => {
render(<ButtonComponent />);
const button = screen.getByRole('button', { name: /증가/i });
const counter = screen.getByText('0');
await userEvent.click(button);
expect(counter).toHaveTextContent('1');
});
- API 호출이 잘 되는지
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import axios from 'axios';
import FetchComponent from './FetchComponent';
jest.mock('axios');
test('API 호출이 정상적으로 이루어지는지 확인', async () => {
const data = { data: { message: '성공' } };
axios.get.mockResolvedValueOnce(data);
render(<FetchComponent />);
const button = screen.getByRole('button', { name: /데이터 가져오기/i });
await userEvent.click(button);
await waitFor(() => {
expect(screen.getByText('성공')).toBeInTheDocument();
});
expect(axios.get).toHaveBeenCalledTimes(1);
});
- 반응형이 제대로 동작하는지
이러한 사항들에 대해 블랙박스 테스트를 수행하여, 내부 로직보다는 사용자의 관점에서 테스트 해야 한다.
- AAA 패턴으로 작성하여 읽고 이해하기 쉽게 작성한다.
- 하나의 테스트에 하나의 동작을 테스트한다.
- 예상치 못한 UI 변경에 대비해 스냅샷 테스트를 사용한다.
- cleanup-after-each등을 사용해서 공유되는 state가 없도록 한다.
- 실제 구현 코드를 import 하지 않고 jest.spyon을 사용한다.
- custom wrapper를 사용한다.
- toBeTruthy() 보다 toBeVisible, toBeInTheDocument를 사용한다.
- toContain보다는 toHaveTextContent를 사용한다.
- toBeDisabled, toBeEnabled를 사용한다.
<form className="form" onSubmit={onSubmit}>
<div className="field">
<label>Name</label>
<input
id="name"
onChange={onFieldChange}
placeholder="Enter your name"
/>
</div>
다음과 같은 예제에서 input을 가져오기 위해 다음과 같이 작성할 수 있다.
describe("Form", () => {
it("should submit correct data", async () => {
...
const inputElement = screen.getByPlaceholderText("Enter your name");
...
});
});
하지만 접근성, 유지보수성(텍스트가 바뀌어도 input을 가져올 수 있음) 같은 이유로 getByRole로 가져오는 것이 더 권장된다. 다음과 같이 코드를 수정하면 getByRole을 사용할 수 있다.
<form className="form" onSubmit={onSubmit}>
<div className="field">
<label htmlFor="name">Name</label>. // <-- htmlFor 추가
<input
id="name" // <-- id 와 맞춰준다.
onChange={onFieldChange}
placeholder="Enter your name"
/>
</div>
describe("Form", () => {
it("should submit correct data", async () => {
...
const inputElement = screen.getByRole("textbox", { name: "Name" });
...
});
});
이같은 수정으로 label을 클릭하면 해당 입력 필드로 포커스가 이동하고, 스크린 리더에 더 좋은 접근성을 제공할 수 있다.
- 어떤 비동기 작업 후, 요소가 사라지는 것을 테스트하고 싶을 때,
waitForElementToBeRemoved
를 사용한다.
it("renders without breaking", async () => {
render(<ListPage />);
await waitForElementToBeRemoved(() => screen.queryByText("Loading..."));
});
it("renders without breaking", async () => {
render(<ListPage />);
await waitForElementToBeRemoved(screen.queryByText("Loading..."));
});
기존 애니매이션 테스트를 하기 위해서는 클래스 이름이 있는지를 확인했었다.
describe("ColorToggleButton", () => {
it("toggles the 'toggled' class on click", () => {
const { getByText } = render(<ColorToggleButton />);
const button = getByText("Toggle Color");
expect(button).not.toHaveClass("toggled");
fireEvent.click(button);
expect(button).toHaveClass("toggled");
});
});
하지만 이러한 방식은 애니메이션 세부 사항이 바뀐다면 테스트 코드도 하나하나 수정해야 하는 단점이 존재하고, 클래스 이름이 존재하는지가 애니메이션이 수행되었는지 테스트 한다는 것이 조금 이상하게 다가왔다. 따라서 Web Animation API
를 사용하여 테스트 하는 방법을 생각해보았다.
Element.prototype.animate
함수를 mock 하여 이 함수가 올바른 keyframe, option과 실행되었는지를 확인하는 방식으로 코드를 작성하였다.
import React from "react";
import { render, screen } from "../../utils/test-utils";
import FCFSEventPage from "./FCFSEventPage";
import { userEvent } from "@storybook/test";
import { fadeInUp, fadeOutDown } from "../../styles/keyframes";
import { FCFSHintOptions } from "../../styles/options";
Element.prototype.animate = jest.fn();
describe("FCFSEventPage Component", () => {
beforeEach(() => {
jest.clearAllMocks();
});
test("아래 힌트 화면을 호버하면 퀴즈 힌트가 올라와야 한다.", async () => {
render(<FCFSEventPage />);
const hintContainer = screen.getByRole("dialog");
await userEvent.hover(hintContainer);
expect(hintContainer.animate).toHaveBeenCalledWith(
fadeInUp,
FCFSHintOptions,
);
});
});
이렇게 함으로써, styles폴더에 있는 keyframe과 option가 변할 때 마다 테스트 코드를 변경할 필요가 없어지도록 하였다.
- Jest는 기본적으로 node 환경이기 때문에 DOM API를 사용할 수 없다. 따라서 IntersectionObserver도 mocking 해주어야 한다.
IntersectionObserver = jest.fn().mockImplementation((callback) => {
return {
observe: jest.fn((element) => {
callback([
{
isIntersecting: false,
target: element,
intersectionRect: { top: 0, left: 0 },
},
]);
}),
unobserve: jest.fn(),
disconnect: jest.fn(),
};
});
- Glow에서 애니메이션 1개가 실행된 후(finished가 true가 된 후) 다른 애니메이션이 실행되는지를 검사해야 했다. 따라서 다음과 같이
Promise.all
로 첫 번째 애니메이션이 모두 실행되는 것을 기다린 후, 두 번째 애니메이션을 검사해주었다.
test("글로우 밝기가 +- 10%에서 랜덤으로 밝아졌다 어두워졌다 해야 한다.", async () => {
render(<Glow />);
const glowElemntArray = screen.getAllByRole("img", {
name: /glow-effect/i,
});
await Promise.all( // <--- 모든 애니메이션 실행될 때 까지 대기
glowElemntArray.map((glowElemnt, index) => {
expect(glowElemnt.animate).toHaveBeenCalledWith(fadeIn, {
...glowFadeOptions,
delay: (index + 1) * 1000,
});
}),
);
glowElemntArray.map((glowElemnt) => {
expect(glowElemnt.animate).toHaveBeenCalledWith(randomGlow, glowOptions);
});
});
- AppProvider를 사용하는 컴포넌트에 대해 MockProvider를 작성해주어야 했다. 현재 우리의 프로젝트에서는 Provider를 사용하는 곳에서
useAppProvider
를 사용하고 있으므로 이 함수를 mockImplementation으로 작성해주고, MockProvider도 직접 생성해주었다. 이때 내가 원하는 상태 값을 넣을 수 있도록 MockProvider에서 initialState를 매개변수로 받을 수 있게 하였다.
jest.mock("../../providers/AppProvider", () => ({
useAppContext: jest.fn().mockImplementation(() => {
return useContext(MockContext); // <--- MockContext를 반환
}),
}));
interface MockState {
isAuth: boolean;
isFCFSEnd: boolean;
isRandomEnd: boolean;
setIsAuth: (isAuth: boolean) => void;
setIsFCFSEnd: (isFCFSEnd: boolean) => void;
setIsRandomEnd: (isRandomEnd: boolean) => void;
}
interface MockProviderInterface {
children: ReactNode;
initialState?: Partial<MockState>;
}
export const MockContext = createContext<MockState | undefined>(undefined);
export const MockProvider = ({
children,
initialState,
}: MockProviderInterface) => {
const [isAuth, setIsAuth] = useState(initialState?.isAuth || false);
const [isFCFSEnd, setIsFCFSEnd] = useState(initialState?.isFCFSEnd || false);
const [isRandomEnd, setIsRandomEnd] = useState(
initialState?.isRandomEnd || false,
);
return (
<MockContext.Provider
value={{
isAuth,
setIsAuth,
isFCFSEnd,
setIsFCFSEnd,
isRandomEnd,
setIsRandomEnd,
}}
>
{children}
</MockContext.Provider>
);
};
const Providers = ({
children,
initialState,
}: {
children: ReactNode;
initialState?: Partial<MockState>;
}) => {
return (
<BrowserRouter>
<MockProvider initialState={initialState}>{children}</MockProvider>
</BrowserRouter>
);
};
const customRender = (
ui: ReactElement,
options?: RenderOptions,
initialState?: Partial<MockState>,
) =>
render(ui, {
wrapper: (props) => <Providers initialState={initialState} {...props} />,
...options,
});
export * from "@testing-library/react";
export { customRender as render };
- jest.fn(), 또는 mockImplementation으로 빈 함수를 만들어 테스트 할 때, 내부 로직에 의해 테스트가 막히는 상황이 자주 발생하였다.
const stopAnimation = () => {
const element = elementRef.current;
const animation = animationRef.current; // <-- animate가 아무것도 반환하지 않아서 다음 로직이 실행되지 않음
if (!element || !animation) return;
animationRef.current = element.animate(
cancelKeyframes,
cancelOptions ?? startOptions,
);
};
Element.prototype.animate = jest.fn().mockImplementation(() => {
return 1; // <-- id를 반환하여 해결
});
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsInView(true);
} else if (entry.intersectionRect.top !== 0) {. // <-- intersectionRect.top 값이 없어서 오류 발생
setIsInView(false);
if (onViewEscape) onViewEscape();
}
});
},
{ threshold },
);
IntersectionObserver = jest.fn().mockImplementation((callback) => {
return {
observe: jest.fn((element) => {
callback([
{
isIntersecting: false,
target: element,
intersectionRect: { top: 0, left: 0 }, // <-- callback의 매개변수에 intersectionRect을 추가하여 해결
},
]);
}),
unobserve: jest.fn(),
disconnect: jest.fn(),
};
});
-
.mock
을 사용하면 Mock 함수를 함수가 호출된 방법과 반환된 함수에 대한 데이터가 담긴 객체를 컨트롤 할 수 있다. - 처음에는 intersectionObserver의 is intersecting이 false 였다가, 나중에 true가 될 때를 시뮬레이션 하기 위해
.mock
을 사용하였다.- 처음의 isIntersecting을 false로 설정한다.
- IntersectionObserver의
mock.calls
에는 함수를 실행할 때 넘겨진 매개변수들이 저장된다.mocks.calls[0]
은 처음 함수가 호출될 때 넘겨진 매개변수를 의미하고mocks.calls[0][0]
은 첫번째 호출시 넘겨진 첫번째 매개변수인 callback을 의미한다. 여기서 callback을 isIntersecting이 true인 배열과 함께 넘겨주어서 콜백함수를 수동으로 호출한다. - 콜백 함수가 호출되면
useInView
훅의 setIsInView(true)가 실행되어isInView
상태가 변경되어 클래스도 변경된다.
IntersectionObserver = jest.fn().mockImplementation((callback) => {
return {
observe: jest.fn((element) => {
callback([
{
isIntersecting: false, // 1.
target: element,
intersectionRect: { top: 0, left: 0 },
},
]);
}),
unobserve: jest.fn(),
disconnect: jest.fn(),
};
});
test("ConvenienceSection에 들어오면 스크롤이 좌에서 오른쪽으로 오는 애니메이션이 발생해야 한다.", () => {
render(<ConvenienceSection />);
const items = screen.getAllByRole("article");
items.forEach((item) => {
expect(item).toHaveClass("translate-x-3/4");
expect(item).not.toHaveClass("translate-x-0");
});
act(() => {
(IntersectionObserver as jest.Mock).mock.calls[0][0]([ // 2.
{ isIntersecting: true },
]);
});
items.forEach((item) => {
expect(item).toHaveClass("translate-x-0");
expect(item).not.toHaveClass("translate-x-3/4");
});
});
jest에서 getByRole을 사용할 시, 다양한 role을 설정할 수 있다. aria-label등을 통해서 다양한 정보를 전달이 가능하다.
-
alert
- 긴급 상황을 사용자에게 알리는 메시지. 예:
<div role="alert">
- 긴급 상황을 사용자에게 알리는 메시지. 예:
-
button
- 클릭 가능한 버튼 요소. 예:
<button>
,<input type="button">
,<div role="button">
- 클릭 가능한 버튼 요소. 예:
-
checkbox
- 체크박스 입력 요소. 예:
<input type="checkbox">
- 체크박스 입력 요소. 예:
-
dialog
- 대화 상자, 모달 창. 예:
<dialog>
,<div role="dialog">
- 대화 상자, 모달 창. 예:
-
grid
- 데이터 그리드, 표 형태의 데이터. 예:
<table role="grid">
- 데이터 그리드, 표 형태의 데이터. 예:
-
link
- 하이퍼링크 요소. 예:
<a>
,<area>
,<link>
- 하이퍼링크 요소. 예:
-
listbox
- 선택 가능한 목록 상자. 예:
<select>
,<ul role="listbox">
- 선택 가능한 목록 상자. 예:
-
menu
- 명령 메뉴. 예:
<ul role="menu">
- 명령 메뉴. 예:
-
menubar
- 메뉴 바, 일반적으로 파일 메뉴. 예:
<ul role="menubar">
- 메뉴 바, 일반적으로 파일 메뉴. 예:
-
menuitem
- 메뉴 항목. 예:
<li role="menuitem">
- 메뉴 항목. 예:
-
progressbar
- 진행률 표시기. 예:
<progress>
,<div role="progressbar">
- 진행률 표시기. 예:
-
radio
- 라디오 버튼. 예:
<input type="radio">
- 라디오 버튼. 예:
-
radiogroup
- 라디오 버튼 그룹. 예:
<fieldset role="radiogroup">
- 라디오 버튼 그룹. 예:
-
slider
- 슬라이더, 범위를 조절하는 입력 요소. 예:
<input type="range">
,<div role="slider">
- 슬라이더, 범위를 조절하는 입력 요소. 예:
-
spinbutton
- 숫자 입력 스핀 버튼. 예:
<input type="number">
,<div role="spinbutton">
- 숫자 입력 스핀 버튼. 예:
-
textbox
- 텍스트 입력 상자. 예:
<input type="text">
,<textarea>
,<div role="textbox">
- 텍스트 입력 상자. 예:
-
combobox
- 드롭다운 목록과 결합된 텍스트 입력 상자. 예:
<input list="browsers">
,<select role="combobox">
- 드롭다운 목록과 결합된 텍스트 입력 상자. 예:
-
form
- 폼 요소. 예:
<form>
,<div role="form">
- 폼 요소. 예:
-
search
- 검색 영역. 예:
<input type="search">
,<form role="search">
- 검색 영역. 예:
-
table
- 데이터 테이블. 예:
<table>
,<div role="table">
- 데이터 테이블. 예:
-
tabpanel
- 탭 패널. 예:
<div role="tabpanel">
- 탭 패널. 예:
-
tablist
- 탭 리스트. 예:
<div role="tablist">
- 탭 리스트. 예:
-
tab
- 개별 탭. 예:
<button role="tab">
,<li role="tab">
- 개별 탭. 예:
-
tree
- 트리 구조 목록. 예:
<ul role="tree">
- 트리 구조 목록. 예:
-
treeitem
- 트리 구조 목록 항목. 예:
<li role="treeitem">
- 트리 구조 목록 항목. 예:
- 🛠️ 테스트 코드 작성
- 워드 클라우드
- 컴포넌트 설계
- 스토리북 적용
- useAnimation Hook
- 룰렛 컴포넌트
- 토스트 컴포넌트
- useInView Hook
- 색상 영역 컴포넌트
- msw
- webpack
- 컴포넌트 동시에 띄우기