-
프론트엔드 테스트 코드 작성하기 - 기초편React.js & Next.js 2024. 3. 28. 23:19
목차
- prerequisites
- Project
- Basic Code
- Test Code
- Explain each test codes
- Results
- 마치며
1) Prerequisites
- 이번 장에서는 vs code의 extension인 Jest Runner를 사용합니다.
- 이전 장에서 다룬 레포지토리를 확장하여 사용합니다
2) Project
코드는 해당 레포지토리에서 다운로드하실 수 있습니다.
https://github.com/junh0328/react-test
기본적인 UI를 위해, styled-in-JS 중 하나인 @stitches/react 를 사용하였습니다.
$ yarn add @stitches/react
2-1) Basic Code
Next v14 의 기본적인 문법에 대해서 설명하진 않도록 하겠습니다.
'use client'; import { styled } from '@stitches/react'; import { useCallback, useMemo, useState } from 'react'; import { defaultAxios } from '../../../config/axiosConfig'; import { isAxiosError } from 'axios'; const signIn = async (body: { email: string; password: string }): Promise<void> => { await defaultAxios.post('/v1/users/sign-in', body); return; }; const SignIn = () => { const [id, setId] = useState(''); const [passWord, setPassWord] = useState(''); const [errorMsg, setErrorMsg] = useState(''); const [isSuccess, setIsSuccess] = useState(false); const onSubmit = useCallback( async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); if (errorMsg) { setErrorMsg(''); } if (isSuccess) { setIsSuccess(false); } try { await signIn({ email: id, password: passWord }); setIsSuccess(true); } catch (e: unknown) { if (isAxiosError(e)) { setErrorMsg(e?.response?.data?.message || 'something went wrong.'); return; } setErrorMsg('알 수 없는 에러가 발생했습니다.'); } }, [errorMsg, id, isSuccess, passWord] ); const clearData = useCallback(() => { setId(''); setPassWord(''); setErrorMsg(''); setIsSuccess(false); }, []); const isDisabled = useMemo(() => { return !id || id.trim() === '' || !passWord || passWord.trim() === ''; }, [id, passWord]); return ( <form onSubmit={onSubmit}> <Flex> <CustomLabel htmlFor='id'>Id</CustomLabel> <input type='text' id='id' name='id' value={id} onChange={(e) => setId(e.target.value)} /> </Flex> <Flex> <CustomLabel htmlFor='passWord'>passWord</CustomLabel> <input type='password' id='passWord' name='passWord' value={passWord} onChange={(e) => setPassWord(e.target.value)} /> </Flex> <CustomSpan id='error-msg'>{errorMsg}</CustomSpan> {isSuccess && <CustomSpan>로그인이 완료되었습니다.</CustomSpan>} <Flex> <button type='submit' disabled={isDisabled}> login </button> <button type='button' onClick={clearData}> reset </button> </Flex> </form> ); }; export default SignIn; const CustomLabel = styled('label', { display: 'block', width: '150px', }); const CustomSpan = styled('p', { color: 'red', marginBottom: 10, }); const Flex = styled('div', { display: 'flex', gap: 10, marginBottom: 10, });
input 값의 state를 관리하기 위해 useState를 사용하였습니다
const [id, setId] = useState(''); const [passWord, setPassWord] = useState(''); const [errorMsg, setErrorMsg] = useState(''); const [isSuccess, setIsSuccess] = useState(false);
input 값들의 기본적인 유효성 검사를 가져가기 위해 조건을 useMemo로 묶어 사용하였습니다
const isDisabled = useMemo(() => { return !id || id.trim() === '' || !passWord || passWord.trim() === ''; }, [id, passWord]);
form 이벤트와 인풋 값을 초기화할 기본적인 비즈니스 로직입니다
// 외부 axios 요청을 위한 함수 (컴포넌트와 동일한 스코프에 선언) const signIn = async (body: { email: string; password: string }): Promise<void> => { await defaultAxios.post('/v1/users/sign-in', body); return; }; ... // 내부 로직을 돌리기 위한 함수 (컴포넌트 내부 스코프에 선언) const onSubmit = useCallback( async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); if (errorMsg) { setErrorMsg(''); } if (isSuccess) { setIsSuccess(false); } try { await signIn({ email: id, password: passWord }); setIsSuccess(true); } catch (e: unknown) { if (isAxiosError(e)) { setErrorMsg(e?.response?.data?.message || 'something went wrong.'); return; } setErrorMsg('알 수 없는 에러가 발생했습니다.'); } }, [errorMsg, id, isSuccess, passWord] ); const clearData = useCallback(() => { setId(''); setPassWord(''); setErrorMsg(''); setIsSuccess(false); }, []);
- onSubmit 요청 시에 truthy 한 errorMsg 값을 가지고 있다면, 문자열을 빈 문자열을 재 할당합니다
- onSubmit 요청 시에 truthy한 이전 요청의 성공 값 (isSuccess)를 가지고 있다면, false 값을 재 할당합니다
- 모든 조건을 통과할 경우 signIn 함수를 호출합니다
- signIn 은 void를 반환하기 때문에 axios 요청이 문제가 없을 경우 setIsSuccess를 통해 isSuccess 상태에 true를 할당합니다
- isAxiosError 정적 메서드를 통해 에러가 axiosError인지 확인합니다
- axios 에러일 경우, 에러 객체 내부의 메시지를 에러 메시지로 할당합니다
- axios 에러가 아닐 경우, ‘알 수 없는 에러가 발생했습니다.’를 에러 메시지로 할당합니다
2-2) Test Code
프론트 단에서 해당 코드에 대한 테스트 코드를 짠다고 했을 때, 생각해야 할 것들을 제 관점에서 적어보겠습니다.
- 뷰에 인풋 엘리먼트와 버튼 엘리먼트가 원활히 보여야 한다
- 파라미터가 유효한 상태에서 login 버튼이 활성화돼야 한다
- 원활한 값이 입력 됐을 경우, 서버에서 온 올바른 응답을 표시할 수 있어야 한다
- ID, PW가 틀렸을 경우, 서버에서 온 에러 메시지를 표시할 수 있어야 한다
- 올바르지 않은 요청을 보냈을 경우, 해당 메시지를 표시할 수 있어야 한다
- 알 수 없는 에러가 발생했을 경우, 해당 메시지를 표시할 수 있어야 한다
관점에 따라, 전략에 따라 더 많은 경우의 수가 있겠지만, 처음 테스트 코드를 도입하는 관점에서 다음 6가지의 경우를 테스팅하기로 결정했습니다.
우선 전체 코드를 공유하고 해당 코드에 대한 전략을 공유하도록 하겠습니다.
🗂️ __test__/sign-in.test.tsx import SignIn from '@/app/sign-in/page'; import { render, fireEvent, waitFor, screen } from '@testing-library/react'; import { defaultAxios } from '../config/axiosConfig'; import { AxiosError } from 'axios'; jest.mock('../config/axiosConfig.ts'); describe('SignIn', () => { beforeEach(() => { jest.clearAllMocks(); }); const testData = { validEmail: 'bowow@eazel.net', validPassword: 'eazel1!', invalidEmail: 'invalidEmail@test.com', invalidPassword: 'invalidPassword', }; const renderSignInComponent = () => { // given render(<SignIn />); // when const idInputNode = screen.getByLabelText('Id'); const passwordInputNode = screen.getByLabelText('passWord'); const loginButtonNode = screen.getByText('login'); const resetButtonNode = screen.getByText('reset'); return { idInputNode, passwordInputNode, loginButtonNode, resetButtonNode }; }; it('renders without crashing', () => { // given, when const { idInputNode, loginButtonNode, passwordInputNode, resetButtonNode } = renderSignInComponent(); // then expect(idInputNode).toBeTruthy(); expect(passwordInputNode).toBeTruthy(); expect(loginButtonNode).toBeTruthy(); expect(resetButtonNode).toBeTruthy(); }); describe('Ensure signIn params correct', () => { it('should call sign in with axios and return success', async () => { // given, when const { idInputNode, loginButtonNode, passwordInputNode } = renderSignInComponent(); // when (more condition) fireEvent.change(idInputNode, { target: { value: testData.validEmail } }); fireEvent.change(passwordInputNode, { target: { value: testData.validPassword } }); fireEvent.click(loginButtonNode); // then await waitFor(() => expect(defaultAxios.post).toHaveBeenCalledWith('/v1/users/sign-in', { email: testData.validEmail, password: testData.validPassword, }) ); expect(defaultAxios.post).toHaveBeenCalledTimes(1); }); }); describe('Check response "로그인 왼료되었습니다."', () => { beforeAll(() => { defaultAxios.post = jest.fn().mockResolvedValue({}); }); it('should show success 로그인이 완료되었습니다', async () => { const { idInputNode, passwordInputNode, loginButtonNode } = renderSignInComponent(); fireEvent.change(idInputNode, { target: { value: testData.validEmail } }); fireEvent.change(passwordInputNode, { target: { value: testData.validPassword } }); fireEvent.click(loginButtonNode); await waitFor(() => expect(defaultAxios.post).toHaveBeenCalledWith('/v1/users/sign-in', { email: testData.validEmail, password: testData.validPassword, }) ); const succesMsg = await screen.findByText('로그인이 완료되었습니다.'); expect(succesMsg).toBeTruthy(); }); }); describe('Check response "로그인이 실패되었습니다"', () => { const errorMessage = '로그인이 실패되었습니다'; beforeAll(() => { const axiosError: AxiosError = new AxiosError(errorMessage, '401', undefined, undefined, { data: { message: errorMessage }, status: 401, statusText: '', headers: {}, config: {} as any, request: undefined, }); defaultAxios.post = jest.fn().mockRejectedValue(axiosError); }); it(`should show success ${errorMessage}`, async () => { // given const { idInputNode, passwordInputNode, loginButtonNode } = renderSignInComponent(); // when fireEvent.change(idInputNode, { target: { value: testData.invalidEmail } }); fireEvent.change(passwordInputNode, { target: { value: testData.invalidPassword } }); fireEvent.click(loginButtonNode); // then await waitFor(() => expect(defaultAxios.post).toHaveBeenCalledWith('/v1/users/sign-in', { email: testData.invalidEmail, password: testData.invalidPassword, }) ); const failMessage = await screen.findByText(errorMessage); expect(failMessage).toBeTruthy(); }); }); describe('Check response "something went wrong."', () => { const errorMessage = 'something went wrong.'; it(`should display ${errorMessage}`, async () => { const error: AxiosError = { isAxiosError: true, config: undefined, name: 'Error', message: 'Unknown Error', toJSON: () => ({}), }; (defaultAxios.post as jest.Mock).mockRejectedValue(error); // given const { idInputNode, passwordInputNode, loginButtonNode } = renderSignInComponent(); // when fireEvent.change(idInputNode, { target: { value: testData.validEmail } }); fireEvent.change(passwordInputNode, { target: { value: testData.invalidPassword } }); fireEvent.click(loginButtonNode); // then const data = expect(await screen.findByText(errorMessage)); expect(data).toBeTruthy(); }); }); describe('Check response "알 수 없는 에러가 발생했습니다."', () => { const errorMessage = '알 수 없는 에러가 발생했습니다.'; it(`should display ${errorMessage}`, async () => { (defaultAxios.post as jest.Mock).mockImplementation(() => { throw new Error('Unknown Error'); }); const { idInputNode, passwordInputNode, loginButtonNode } = renderSignInComponent(); fireEvent.change(idInputNode, { target: { value: testData.invalidEmail } }); fireEvent.change(passwordInputNode, { target: { value: testData.invalidPassword } }); fireEvent.click(loginButtonNode); const data = expect(await screen.findByText(errorMessage)); expect(data).toBeTruthy(); }); }); });
전체 코드를 스코프에 맞춰 접는다면, 다음과 같은 구조가 나옵니다.
- SignIn 을 테스트합니다
- 모든 describe 실행 전, 목 데이터를 비웁니다
beforeEach(() => { jest.clearAllMocks(); });
- 다음과 같은 테스트용 ID, PW를 사용합니다
const testData = { validEmail: 'bowow@eazel.net', validPassword: 'eazel1!', invalidEmail: 'invalidEmail@test.com', invalidPassword: 'invalidPassword', };
- given - when - then 조건에 맞춰 중복으로 사용하게 되는 조건을 따로 분리하였습니다
const renderSignInComponent = () => { // given render(<SignIn />); // when const idInputNode = screen.getByLabelText('Id'); const passwordInputNode = screen.getByLabelText('passWord'); const loginButtonNode = screen.getByText('login'); const resetButtonNode = screen.getByText('reset'); return { idInputNode, passwordInputNode, loginButtonNode, resetButtonNode }; };
2-3) Explain each test codes
- renders without crashing
it('renders without crashing', () => { // given, when const { idInputNode, loginButtonNode, passwordInputNode, resetButtonNode } = renderSignInComponent(); // then expect(idInputNode).toBeTruthy(); expect(passwordInputNode).toBeTruthy(); expect(loginButtonNode).toBeTruthy(); expect(resetButtonNode).toBeTruthy(); });
- 사전에 정의한 renderSignInComponent 에서 테스트에 필요한 노드들을 가져옵니다
- expect() 를 통해 해당 노드들의 존재 여부를 파악합니다
- 모든 describe를 시작하기 전, 테스트에 필요한 노드들을 파악하기 위한 테스트 코드입니다
- Ensure signIn params correct
describe('Ensure signIn params correct', () => { it('should call sign in with axios and return success', async () => { // given, when const { idInputNode, loginButtonNode, passwordInputNode } = renderSignInComponent(); // when (more condition) fireEvent.change(idInputNode, { target: { value: testData.validEmail } }); fireEvent.change(passwordInputNode, { target: { value: testData.validPassword } }); fireEvent.click(loginButtonNode); // then await waitFor(() => expect(defaultAxios.post).toHaveBeenCalledWith('/v1/users/sign-in', { email: testData.validEmail, password: testData.validPassword, }) ); expect(defaultAxios.post).toHaveBeenCalledTimes(1); }); });
- 사전에 정의한 renderSignInComponent 에서 테스트에 필요한 노드들을 가져옵니다
- fireEvent()를 통해 유저의 인터렉션을 가정합니다
- 뷰에서 걸었던 isDisabled 을 정상적으로 통과해야 하고, 해당 post 요청이 한 번 실행돼야 합니다
- 로그인 버튼 click 이후 response를 기다립니다
- 해당 로직에서는 서버에 응답에 대한 내부 데이터의 값을 보는 것이 아닌, 정상적으로 호출되는지 여부를 파악합니다
- Check response "로그인 왼료되었습니다.”
describe('Check response "로그인 왼료되었습니다."', () => { beforeAll(() => { defaultAxios.post = jest.fn().mockResolvedValue({}); }); it('should show success 로그인이 완료되었습니다', async () => { const { idInputNode, passwordInputNode, loginButtonNode } = renderSignInComponent(); fireEvent.change(idInputNode, { target: { value: testData.validEmail } }); fireEvent.change(passwordInputNode, { target: { value: testData.validPassword } }); fireEvent.click(loginButtonNode); await waitFor(() => expect(defaultAxios.post).toHaveBeenCalledWith('/v1/users/sign-in', { email: testData.validEmail, password: testData.validPassword, }) ); const succesMsg = await screen.findByText('로그인이 완료되었습니다.'); expect(succesMsg).toBeTruthy(); }); });
- 제스트에서 제공하는 mockResolvedValue를 처음으로 사용합니다
- 백엔드 단에서 테스트 코드를 사용하고 있는 동료 분의 조언으로는, 실제 api를 호출하는 것보다 조건에 따라 정상/ 비정상적인 응답이 온다는 것을 가정하고 코드를 짜는 것이 중요하다고 하셨습니다
- 코드를 고립(isolate)시켜서 정말 해당되는 부분에 대한 테스트를 해야 한다는 의견이었습니다
- 따라서 실제 api 요청을 보내는 것이 아닌, jest 제공 메서드(mockResolvedValue)를 통해 응답이 유효하다는 것을 가정하고 진행합니다
- fireEvent()를 통해 유저의 인터렉션을 가정합니다
- fireEvent.click 이벤트를 통해 로그인 버튼을 누르고, 실제 api 경로와 해당 api가 원하는 파라미터를 동봉하여 호출합니다 (실제로 호출하지 않고, 호출하는 척합니다)
- jest를 통해 바인딩하였기 때문에 서버에 직접적인 요청 없이 성공을 반환합니다
- 요청이 성공했을 때 표시하기로 했던 문구 “로그인이 완료되었습니다.” 이 정상적으로 호출되는지 체크합니다
- Check response "로그인이 실패되었습니다”
describe('Check response "로그인이 실패되었습니다"', () => { const errorMessage = '로그인이 실패되었습니다'; beforeAll(() => { const axiosError: AxiosError = new AxiosError(errorMessage, '401', undefined, undefined, { data: { message: errorMessage }, status: 401, statusText: '', headers: {}, config: {} as any, request: undefined, }); defaultAxios.post = jest.fn().mockRejectedValue(axiosError); }); it(`should show success ${errorMessage}`, async () => { // given const { idInputNode, passwordInputNode, loginButtonNode } = renderSignInComponent(); // when fireEvent.change(idInputNode, { target: { value: testData.invalidEmail } }); fireEvent.change(passwordInputNode, { target: { value: testData.invalidPassword } }); fireEvent.click(loginButtonNode); // then await waitFor(() => expect(defaultAxios.post).toHaveBeenCalledWith('/v1/users/sign-in', { email: testData.invalidEmail, password: testData.invalidPassword, }) ); const failMessage = await screen.findByText(errorMessage); expect(failMessage).toBeTruthy(); }); });
- 현재 서버에서는 유저의 로그인 요청에서 값이 올바르지 않을 경우, 401을 반환합니다
- axios를 통해 401을 반환한다는 가정하에 AxiosError 클래스에서 인스턴스를 생성하여 Reject 시킵니다
- new AxiosError에 첫 번째 인자 값인 errorMessage 가 정상적으로 반환되는지 확인합니다
- Check response "something went wrong.”
describe('Check response "something went wrong."', () => { const errorMessage = 'something went wrong.'; it(`should display ${errorMessage}`, async () => { const error: AxiosError = { isAxiosError: true, config: undefined, name: 'Error', message: 'Unknown Error', toJSON: () => ({}), }; (defaultAxios.post as jest.Mock).mockRejectedValue(error); // given const { idInputNode, passwordInputNode, loginButtonNode } = renderSignInComponent(); // when fireEvent.change(idInputNode, { target: { value: testData.validEmail } }); fireEvent.change(passwordInputNode, { target: { value: testData.invalidPassword } }); fireEvent.click(loginButtonNode); // then const data = expect(await screen.findByText(errorMessage)); expect(data).toBeTruthy(); }); });
- axios 에러 중 알 수 없는 응답이 발생했다는 가정하에 테스트 코드를 작성합니다
- 비즈니스 로직의 방어 코드 중 해당 코드가 정상적으로 에러 테스트 메시지를 렌더링 할 수 있는지 테스트하기 위함입니다
- Unkown Error를 바인딩하여 Reject 시킵니다
- 해당 에러 메시지 (something went wrong.) 가 정상적으로 화면에 노출되는지 확인합니다
- Check response "알 수 없는 에러가 발생했습니다.”
describe('Check response "알 수 없는 에러가 발생했습니다."', () => { const errorMessage = '알 수 없는 에러가 발생했습니다.'; it(`should display ${errorMessage}`, async () => { (defaultAxios.post as jest.Mock).mockImplementation(() => { throw new Error('Unknown Error'); }); const { idInputNode, passwordInputNode, loginButtonNode } = renderSignInComponent(); fireEvent.change(idInputNode, { target: { value: testData.invalidEmail } }); fireEvent.change(passwordInputNode, { target: { value: testData.invalidPassword } }); fireEvent.click(loginButtonNode); const data = expect(await screen.findByText(errorMessage)); expect(data).toBeTruthy(); }); });
- axiosError가 아닌 상황이 발생했다는 가정하에 테스트 코드를 작성합니다
- axiosError 클래스를 통해 생성한 인스턴스가 아닌, Error 객체를 바로 던집니다
- isAxiosError 가 false 일 때, 적용하기로 했던 메시지가 정상적으로 나오는지 확인합니다
2-4) Results
3) 마치며
토스페이먼츠의 프론트엔드 챕터 리드 개발자이신 한재엽 님의 테스트 관련 컨퍼런스를 듣고 글을 작성하였습니다. 이제 3년 차 개발자로서 스탭업을 생각하고 있는 요즘, ‘코드의 품질을 높이기 위해서는 어떤 것들을 할 수 있을까?’를 생각했을 때, 한 가지의 방향이 테스트 코드였습니다. 단순히 ‘프론트 엔드는 외부 의존성이 너무 높고, 유저 인터렉션 기반이니깐 테스트가 까다로워’ 라는 생각이 있었고, 그러한 이유로 테스트 코드 작성을 기피했던 것 같습니다.
실제로 테스트 코드를 작성해 보니, 여간 까다로운 게 아니었습니다. 실무 코드에 도입한 것이 아닌 정말 베이직한 뷰와 비즈니스 코드임에도 전략을 어떻게 짜느냐에 따라서 테스트 코드를 정말 깊이 다룰 수 있을 것 같았습니다. 아직 실무에 적용한 부분이 아닌, 테스트 코드를 다루기 위해 기본적인 메서드와 전략, 방법 등을 체득하고 있기 때문에 단언할 수는 없지만, 정말 필요한 부분에 대한 테스트 코드의 도입은 코드의 안정성과 다양한 측면을 고려하는 점에서 필요하다고 느꼈습니다.
재엽 님이 컨퍼런스에서 말씀하신 것처럼, QA레벨 보다 더 낮은 레벨 - 개발 보다 더 낮은 레벨 - 설계 레벨에서부터 테스트 코드를 고려한다면, 더 좋은 품질의 코드를 유지할 수 있을 것 같습니다. 아직 까마득하지만, 제가 실무 코드에 적용한다면,, 같은 주제로 다시 돌아오도록 하겠습니다.
'React.js & Next.js' 카테고리의 다른 글
리액트 디자인 패턴 - Controlled Pattern과 UnControlled Pattern (0) 2024.06.30 프론트엔드 테스트 코드 시작하기 - 개념편 (0) 2024.03.26 prettier로 import module 순서 통일하기 (0) 2023.10.24 한번에 적용하는 Sentry with Next.js (1) 2023.10.23 구글 애널리틱스 (GA4) for developers (3) 2023.07.16