로직
이메일로 로그인을 하는 기능을 구현하려고 한다.
회원가입과 유사한데 이메일 + 비밀번호 입력 후 로그인을 시도했을 때 로그인이 실패하면 에러 메세지가 나오게 해야 한다.
하지만 실제로 HTTP 요청을 보낸다는 점에서 회원가입과 차이가 있다.
예제에서 사용하는 LoginPage에서는 useMutation이라는 훅을 사용하고 있다.
비동기로 로그인 요청을 처리하고 요청이 실패할 경우 에러를 던지는 코드다.
const useLogin = () =>
useMutation(
["login"],
async (loginInfo: LoginProps) => postLogin(loginInfo),
{
onError() {
throw Error("login failed");
},
}
);
Login.spec.tsx에서 설정을 해준다.
const queryClient = new QueryClient({
defaultOptions: {},
});
describe("로그인 테스트", () => {
test("로그인 실패 시 에러메시지", async () => {
const routes = [
{
path: "/login",
element: <LoginPage />,
},
];
const router = createMemoryRouter(routes, {
initialEntries: ["/login"],
initialIndex: 0,
});
render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
다음 react-query 공식 문서에서 명시한대로 테스트를 진행하려고 하는데, 해당 예제는 훅 자체를 모킹하는 게 아니라 훅은 그대로 쓰되 훅의 결과를 처리하는 방식으로만 되어 있다.
아래에 에러 메세지를 검증하는 코드까지 작성해서 테스트 코드를 완성해본다.
const queryClient = new QueryClient({
defaultOptions: {},
});
describe("로그인 테스트", () => {
test("로그인 실패하면 에러메시지가 나타난다", async () => {
// given - 로그인 화면이 그려진다.
const routes = [
{
path: "/login",
element: <LoginPage />,
},
];
const router = createMemoryRouter(routes, {
initialEntries: ["/login"],
initialIndex: 0,
});
render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
// when - 사용자가 로그인에 실패한다.
const wrapper = ({ childre }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useLogin(), { wrapper });
// then - 에러 메시지가 나타난다.
await waitFor(() => result.current.isError);
const errorMessage = await screen.findByTestId("error-message");
expect(errorMessage).toBeInTheDocument();
});
이렇게 테스트를 돌리면 에러가 발생한다.
이유는 HTTP call 이후 isError를 확인해서 보여주는데, 실제 HTTP call이 일어나지 않아 isError가 false이기 때문이다.
HTTP call 발생시키기
실제 유저처럼 로그인 버튼을 활성화 하고 클릭하는 방식으로 수정해보자.
const queryClient = new QueryClient({
defaultOptions: {},
});
describe("로그인 테스트", () => {
test("로그인 실패하면 에러메시지가 나타난다", async () => {
// given - 로그인 화면이 그려진다.
const routes = [
{
path: "/login",
element: <LoginPage />,
},
];
const router = createMemoryRouter(routes, {
initialEntries: ["/login"],
initialIndex: 0,
});
render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
// when - 사용자가 로그인에 실패한다.
const wrapper = ({ childre }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const emailInput = screen.getByLabelText("이메일");
const passwordInput = screen.getByLabelText("비밀번호");
fireEvent.change(emailInput, { target: { value: "wrong@email.com"} });
fireEvent.change(passwordInput , { target: { value: "wrongPassword"} });
const logInButton = screen.getByRole("button", {name: "로그인"});
fireEvent.click(loginButton);
const { result } = renderHook(() => useLogin(), { wrapper });
// then - 에러 메시지가 나타난다.
await waitFor(() => result.current.isError);
const errorMessage = await screen.findByTestId("error-message");
expect(errorMessage).toBeInTheDocument();
});
이렇게 하자 테스트 자체는 성공했지만 axios에서 에러가 발생해 console.error가 찍히며 400이 발생한다.
테스트는 통과하지만 발생하는 에러
이때 react-query에서는 logger를 끄는 방식을 권장한다.
const queryClient = new QueryClient({
defaultOptions: {}.
logger: {
log: console.log,
warn: console.warn,
// no more errors on the console for tests
error: process.env.NODE_ENV === "test" ? () => {} : console.error,
},
});
이렇게 하면 400 에러가 발생해도 로그가 찍히지 않는다.
mocking
또 다른 방식으로는 mocking을 활용하는 것이다.
beforeEach를 사용해서 console.error가 찍히게 되면 아무 것도 하지 말라고 하는 것이다.
그리고 모든 게 끝나면 afterAll로 원상복귀를 해준다.
describe('로그인 테스트', () => {
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
jest.resetAllMocks();
});
test('로그인에 실패하면 에러 메세지가 나타난다.', async () => {
// given - 로그인 화면이 그려진다.
const routes = [
{
path: '/login',
element: <LoginPage />,
},
];
...
여기서 사용한 jest.spyOn은 특정 객체의 메서드를 감시해서 호출 여부를 확인하거나 동작을 모킹할 수 있게 한다.
여기서는 console.error 메서드를 감시해 실제로 console.error가 호출될 때 어떤 동작을 하는지 감시한다.
mockImplementation은 이 메서드의 구현을 빈 함수로 대체해 console.error가 호출될 때 아무 것도 하지 않는다.
이는 위에서 언급한대로 테스트 중 에러 메세지가 콘솔에 출력되는 것을 억제하는데 사용된다.
그리고 jest.resetAllMocks는 jest가 설정한 모든 모킹을 리셋한다.
즉 각 테스트 케이스 실행 전 콘솔 에러 출력을 억제하고, 모든 테스트가 끝난 후 모킹을 리셋해 주는 것이다.
하지만 이 테스트는 한 가지 문제가 있는데 실제로 서버에 요청이 들어간 다음에 검증한다는 것이다.
강의 장면을 캡쳐할 수 없게 되어 있는데, localhost -> user/login으로 post 요청이 계속 간다.
이 부분이 문제인 이유는 아마도 실제 서버에 http 요청을 보내 외부 의존성이 발생한다는 점이 아닐까 추측된다.
이를 방지하기 위해 실제 서버에서 bad request 응답이 온 것처럼 http call을 모킹하면 된다.
Nock을 사용한 http 모킹
Nock
HTTP server mocking and expectations library for Node.js
Nock can be used to test modules that perform HTTP requests in isolation.
For instance, if a module performs HTTP requests to a CouchDB server or makes HTTP requests to the Amazon API, you can test that module in isolation.
nock은 node.js용 http 서버 모킹 및 테스트 라이브러리다.
http 요청을 단독으로 수행하는 모듈을 테스트하는데 사용할 수 있다.
예를 들어 모듈이 CouchDB 서버에서 http 요청을 수행하거나 amazon API에 http 요청을 하는 경우 해당 모듈을 격리해서 테스트할 수 있다.
npm install --save-dev nock
설치 후 문서를 보면 json 활용법이 있는데 이를 참고해서 사용하면 된다.
JSON object: nock will exact match the request body with the provided object. In order to increase flexibility, nock also supports RegExp as an attribute value for the keys:
nock('http://www.example.com')
.post('/login', { username: 'pgte', password: /.+/i })
.reply(200, { id: '123ABC' })
nock을 사용한 최종 테스트 코드
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import '@testing-library/jest-dom';
import {
fireEvent,
render,
renderHook,
screen,
waitFor,
} from '@testing-library/react';
import LoginPage from '../pages/LoginPage';
import { RouterProvider, createMemoryRouter } from 'react-router-dom';
import useLogin from '../hooks/useLogin';
import * as nock from 'nock';
const queryClient = new QueryClient({
defaultOptions: {},
});
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
describe('로그인 테스트', () => {
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
jest.resetAllMocks();
});
test('로그인에 실패하면 에러 메세지가 나타난다.', async () => {
// given - 로그인 화면이 그려진다.
const routes = [
{
path: '/login',
element: <LoginPage />,
},
];
const router = createMemoryRouter(routes, {
initialEntries: ['/login'],
initialIndex: 0,
});
render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
// when - 사용자가 로그인에 실패한다.
nock('https://inflearn.byeongjinkang.com')
.post(`/user/login`, {
email: 'wrong@email.com',
password: 'wrongPassword',
})
.reply(400, { msg: 'SUCH_USER_DOES_NOT_EXIST' });
const emailInput = screen.getByLabelText('이메일');
const passwordInput = screen.getByLabelText('비밀번호');
fireEvent.change(emailInput, { target: { value: 'wrong@email.com' } });
fireEvent.change(passwordInput, { target: { value: 'wrongPassword' } });
const loginButton = screen.getByRole('button', { name: '로그인' });
fireEvent.click(loginButton);
const { result } = renderHook(() => useLogin(), {
wrapper,
});
// then - 에러 메세지가 나타난다.
await waitFor(() => expect(result.current.isError));
const errorMessage = await screen.findByTestId('error-message');
expect(errorMessage).toBeInTheDocument();
});
});
참고 강의
2시간으로 끝내는 프론트엔드 테스트 기본기 강의 | 강병진 - 인프런
강병진 | 테스트코드! 어디서부터 시작해야할지 막막한 분들을 위해 준비했어요. 테스트 작성부터, 자동화를 통한 배포까지 한번에!, 테스트 코드를 작성하고 싶은 프론트엔드 개발자를 위한 강
www.inflearn.com
'⚛️ React > 📜 Test Code' 카테고리의 다른 글
[Test Code] Cypress로 본격적인 테스트 코드 작성하기(fixture, recoil mocking) (0) | 2024.07.02 |
---|---|
[Test Code] Cypress로 테스트 하기 (0) | 2024.07.02 |
[Test Code] beforeEach()를 활용한 Jest 성공 케이스 작성 (0) | 2024.06.19 |
[Test Code] Jest를 활용해 테스트 코드 작성하기 (0) | 2024.06.19 |
[Test Code] About Test Code + Jest (2) | 2024.06.19 |