들어가기 전에
늘어가는 기능과 화면에 비례해서 버그 또한 늘어나기 마련입니다. 그런데 그 버그를 수정하면 또 다른 곳이 터져버리는 불상사가 눈앞에 펼쳐지게 됩니다. 사람은 실수를 하기 마련이고 그것을 줄이기 위해서는 더 많은 시간을 쏟아야 합니다. 이러한 이유 때문이더라도 테스트코드는 결국 필요하게 됩니다.
따라서 이 글에서는 React로 작성된 <LoginForm />
컴포넌트에 대해 단위테스트를 작성하고, 그 과정에서 기존 코드의 문제점을 분석하고 테스트 가능한 코드로 리팩토링 하겠습니다.
선수지식
이 글에서는 사용되는 기술에 대한 상세한 사용법은 설명하지 않습니다.
- React
- React Router, TanStack Query
- Vitest, testing-library
- Vite
테스트를 위한 기본셋팅
- 테스트를 위한 패키지 다운로드
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom
- 폴더 및 파일구조 "test" 폴더 안에는 테스트를 위한 mocks, utils, setup 등을 가지고 있으며 실제 테스트를 하는 코드들은 "components" 내부에 테스트를 해야하는 곳과 가깝게 배치합니다.
src
├──── __test__
│ ├───── mocks.ts
│ ├───── setup.ts
│ └───── utils.tsx
└──── components
└───── login
├───── LoginForm.tsx
└───── LoginForm.test.tsx
- setup.ts setup파일에 import 해두면 모든 테스트 파일안에서 Dom 테스트를 위한 추가적인 jest의 matcher를 사용할 수 있습니다. (여기에서는 vitest)
import '@testing-library/jest-dom'
- vite.config.ts에 test config 추가
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
export default defineConfig({
plugins: [react()],
test: {
setupFiles: ['./src/__tests__/setup.ts'],
globals: true,
environment: 'jsdom',
},
})
테스트 케이스 구성
본격적으로 테스트를 시작하면서 바로 머리속에 생각이 하나 떠올랐을 겁니다. "무엇을 어디까지 테스트를 해야하는걸까?" 정답은 없지만, React Testing Library 공식문서의 가이드 원칙에 따르면 생각의 범위를 좁혀갈 수 있습니다.
테스트가 소프트웨어가 사용되는 방식과 닮을 수록, 테스트는 더 많은 확신을 준다.
다시 말하면, 사용자가 앱에서 보고 만지고 하는 행위들 자체를 테스트 해야한다는 것입니다.
LoginForm.tsx
파일이 있는 같은 폴더에 LoginForm.test.tsx
파일을 생성하겠습니다. 그리고 위 기본 원칙을 고려하여 LoginForm의 테스트 케이스를 구성하겠습니다.
describe('<LoginForm/>', () => {
it('아이디인풋, 비밀번호인풋, 로그인버튼이 보여야한다.', () => {
})
it('인풋에 값이 입력되면 화면에 보여야한다.', () => {
})
it('인풋 중 값이 하나라도 없으면 버튼은 클릭 할 수 없다.', () => {
})
it('버튼을 클릭하면 로그인을 시도한다.', () => {
})
it('로그인을 시도하는 중에 버튼은 프로그레스 바가 나타난다', () => {
})
})
마지막 테스트 케이스에서는 내부적인 기획에 따라 버튼에 프로그레스가 나타나야 하는 상황입니다. 일반적으로는 다르게 로딩이 처리 될 수 있습니다.
테스트 코드 작성
각 테스트는 LoginForm
을 렌더링한 후 각 요소가 있는지, 사용자 이벤트에 반응을 하는지 등을 테스트 하게 될 것입니다.
LoginForm
렌더링하고 자주 사용하는 함수와 요소를 반환하는 함수를 만듭니다.
/**
* 각 테스트케이스에서 사용할 컴포넌트 렌더 함수
*/
const componentRender = ({ isLoading }: { isLoading: boolean }) => {
// 로그인을 위한 함수를 모킹합니다.
const onSubmit = vi.fn()
const { getByPlaceholderText, getByRole } = render(
<LoginForm isLoading={isLoading} onSubmit={onSubmit}/>,
)
const accountInput = getByPlaceholderText('아이디') as HTMLInputElement
const passwordInput = getByPlaceholderText('비밀번호') as HTMLInputElement
const loginButton = getByRole('button') as HTMLButtonElement
const loginForm = getByRole('form') as HTMLFormElement
const changeAccount = (value?: string) => {
fireEvent.change(accountInput, { target: { value } })
}
const changePassword = (value?: string) => {
fireEvent.change(passwordInput, { target: { value } })
}
return {
onSubmit,
getByRole,
accountInput,
passwordInput,
loginButton,
loginForm,
changeAccount,
changePassword,
}
}
- 각 케이스별로 테스트 코드를 작성합니다.
describe('<LoginForm/>', () => {
it('아이디인풋, 비밀번호인풋, 로그인버튼이 보여야한다.', () => {
const { accountInput, passwordInput, loginButton } = componentRender()
expect(accountInput).toBeInTheDocument()
expect(passwordInput).toBeInTheDocument()
expect(loginButton).toBeInTheDocument()
})
it('인풋에 값이 입력되면 화면에 보여야한다.', () => {
const { accountInput, passwordInput, changeAccount, changePassword } = componentRender()
changeAccount('testAccount')
changePassword('1234')
expect(accountInput.value).toBe('testAccount')
expect(passwordInput.value).toBe('1234')
})
it('인풋 중 값이 하나라도 없으면 버튼은 클릭 할 수 없다.', () => {
const { loginButton, changeAccount, changePassword, onSubmit } = componentRender()
changeAccount()
changePassword('1234')
fireEvent.click(loginButton)
expect(onSubmit).toBeCalledTimes(0)
changeAccount('test')
changePassword('')
fireEvent.click(loginButton)
expect(onSubmit).toBeCalledTimes(0)
changeAccount()
changePassword()
fireEvent.click(loginButton)
expect(onSubmit).toBeCalledTimes(0)
changeAccount('test')
changePassword('1234')
fireEvent.click(loginButton)
expect(onSubmit).toBeCalledTimes(1)
})
it('버튼을 클릭하면 로그인을 시도한다.', () => {
const { loginButton, onSubmit, changeAccount, changePassword } = componentRender()
changeAccount('test')
changePassword('1234')
fireEvent.click(loginButton)
expect(onSubmit).toBeCalledTimes(1)
expect(onSubmit).toHaveBeenCalledWith({
userAccount: 'test',
userPW: '1234',
} as LoginInfo)
})
it('로그인 시도하는 중에 버튼은 프로그레스 바가 나타난다', () => {
const { getByRole } = componentRender({
isLoading: true,
})
const buttonProgress = getByRole('progressbar')
expect(buttonProgress).toBeVisible()
})
})
- 작성된 테스트를 실행합니다.
LoginForm.tsx
컴포넌트가 어떻게 구현되어 있는지도 모른채로 테스트케이스를 작성했습니다. 결과는 당연히 FAIL이 날 수밖에 없겠죠. 이제부터 테스트를 PASS 할 수 있도록 리팩토링을 진행하겠습니다.
테스트를 PASS 하도록 리팩토링 하기
위 사진을 보면 "useNavigate() may be used only..."라는 에러가 발생하며 테스트가 실패하였습니다. 해당 에러는 React Router
와 관련되어 있으며 LoginForm
렌더링 자체에서 실패했음을 알 수 있습니다. 이제 작성되어 있던 LoginForm.tsx
코드를 먼저 살펴보겠습니다.
import { Button, Input, toastError } from '내부디자인시스템'
import { FormEventHandler, useState } from 'react'
import { LoginInfo, login } from '../../api'
import { useMutation } from '@tanstack/react-query'
import { Cookies } from 'react-cookie'
import { AxiosError } from 'axios'
import { useNavigate } from 'react-router-dom'
export default function LoginForm() {
const [account, setAccount] = useState<string>()
const [password, setPassword] = useState<string>()
const navigate = useNavigate()
const { mutate, isLoading } = useMutation(
(loginInfo: LoginInfo) => login(loginInfo),
{
onSuccess(tokens) {
const cookies = new Cookies()
cookies.set('accessToken', tokens.accessToken)
cookies.set('refreshToken', tokens.refreshToken)
navigate('/')
},
onError(errors) {
if (errors instanceof AxiosError) {
toastError(errors.response?.data.message)
}
},
}
)
const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault()
mutate({
userAccount: account,
userPW: password,
})
}
return (
<form onSubmit={handleSubmit} role='form'>
<Input
defaultValue={account}
placeholder='