thumbnail

FE 우아하게 테스트코드 작성하는 방법

생성일2024. 3. 5.
태그
작성자지한솔

notion image
 
이번엔 테스트 코드에 대해 좀 더 심오한 게시글을 작성해보려고 합니다. 개인적인 견해가 가득 담겨있는 내용이니.. 참고만 부탁드립니다.

테스트 코드 왜 작성할까?

코드 변경에 대응하기 쉽다.

A와 B라는 기능을 갖고있는 화면이 있다고 가정해보자, 우리는 C라는 새로운 기능을 추가하게 될 것이다. 하지만 C라는 기능을 추가하면서 A와 B라는 기능에 영향을 주게 된다면 예상치못한 사이드 이펙트가 발생할 수도 있을 것입니다.
하지만 이미 우리가 A와 B라는 기능에 대해 테스트 코드를 작성했다면 C라는 기능을 작업하면서 사이드 이펙트를 초기에 찾아 해결할 수 있을 것입니다.

개발 로그를 남길 수 있다.

세상에 완벽한 기획서는 없다 생각합니다. 그렇기 때문에 테스트 코드는 개발자들의 개발 문서가 될 수 있습니다. Jest 기준으로 describe과 test, it 을 활용해 테스트 명을 정의할 수 있고, 테스트 코드를 통해 어떤 기능이 동작하는지 확인할 수 있습니다.
또한 코드를 리딩하는 입장에서 모든 컴포넌트의 코드와, 비즈니스 로직, 뷰 로직을 따라가는 것 이외에도 테스트 코드만을 통해 기능을 대충 유추할 수 있기때문에 리딩에도 도움이 될 수 있습니다.

견고한 웹을 만들 수 있다.

가장 중요하다 생각하는 포인트입니다. 우리는 견고한 웹을 만들기 위해 노력해야 합니다.
유저의 경험을 최대로 끌어올려 재방문을 올릴 수 있는 웹을 만드는게 우리의 역할이고, 잔버그를 줄이면서 고객의 이탈율을 막을 수 있는 아주 좋은 것이 테스트 코드입니다.
추가로, 적당한 테스트 코드는 개발자에게 심적 안정감을 줍니다.. ‘아.. 테스트 통과했으니 나는 견고한 웹을 만들었다.’ 라는 생각을 주게 되는거죠.. 이게 개발하면서 정말 큰 도움이 된다 생각합니다.

무슨 테스트를 작성할까?

테스트의 종류는 정말 다양합니다. 단위 테스트, 통합 테스트, 모의 테스트 등이 있습니다. 하지만 오늘 설명할 테스트는 비즈니스 로직 테스트, react 라우터 테스트 에 대한 내용이 중점적이니, 그 이외에 테스트는 간단하게 설명하고 넘어가도록 하겠습니다.

단위 테스트

가장 작은 코드 단위의 기능을 테스트합니다. 컴포넌트 또는 헬퍼 함수 같은 개별 부분이 예상대로 작동하는지 확인합니다.
// Button.js import React from 'react'; const Button = ({ label }) => ( <button>{label}</button> ); export default Button; // Button.test.js import React from 'react'; import { render } from '@testing-library/react'; import Button from './Button'; test('버튼에 label이 잘 나오는지 테스트', () => { const { getByText } = render(<Button label="Click me" />); expect(getByText('Click me')).toBeInTheDocument(); });

통합 테스트

여러 단위가 함께 작동하는지 테스트합니다. 예를 들어, 여러 React 컴포넌트가 상호작용하는 방식을 테스트할 수 있습니다. 예제에서는 LoginForm과 LoginButton이 등장하고, 서로 상호작용하는 구조로 되어있습니다.
// LoginForm.js import React, { useState } from 'react'; import LoginButton from './LoginButton'; const LoginForm = ({ onSubmit }) => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const handleLogin = (e) => { e.preventDefault(); onSubmit(username, password); }; return ( <form onSubmit={handleLogin}> <input type="text" placeholder="Username" value={username} onChange={(e) => setUsername(e.target.value)} /> <input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} /> <LoginButton type="submit">Login</LoginButton> </form> ); }; export default LoginForm; // LoginButton.js import React from 'react'; const LoginButton = ({ children, ...props }) => ( <button {...props}>{children}</button> ); export default LoginButton; // LoginForm.test.js import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import LoginForm from './LoginForm'; describe('LoginForm 테스트', () => { test('submit 버튼 클릭 시 username, password가 제대로 나오는 지 확인', () => { const handleSubmit = jest.fn(); const { getByPlaceholderText, getByText } = render(<LoginForm onSubmit={handleSubmit} />); fireEvent.change(getByPlaceholderText(/username/i), { target: { value: 'user1' } }); fireEvent.change(getByPlaceholderText(/password/i), { target: { value: 'password123' } }); fireEvent.click(getByText(/login/i)); expect(handleSubmit).toHaveBeenCalledWith('user1', 'password123'); }); });

모의 테스트

외부 시스템과의 상호작용을 모의 객체나 함수를 사용하여 테스트합니다. API 호출이나 데이터베이스 조회 등을 모의할 수 있습니다.
// fetchData.js export const fetchData = () => { return fetch('https://api.example.com/data') .then(res => res.json()); }; // App.test.js import React from 'react'; import { render, waitFor } from '@testing-library/react'; import App from './App'; import * as API from './fetchData'; test('fetches data and displays it', async () => { const mockData = { data: 'some data' }; jest.spyOn(API, 'fetchData').mockResolvedValue(mockData); const { getByText } = render(<App />); await waitFor(() => expect(getByText('some data')).toBeInTheDocument()); });

내가 생각하는 이상적인 테스트

여기서부터는 이제.. 개인적인 생각이 가득가득 담긴 부분이니.. 참고용으로만 확인해주세요.
좀 실용적인 테스트 코드를 작성해보자.
라는 생각으로 저는 항상 머리가 아픕니다. 어떤게 실용적인 테스트 코드일까..
처음엔 1개의 파일에 1개의 테스트 코드가 국룰이다 라는 생각으로 테스트 코드를 작성했습니다. 세세한 부분까지의 테스트 코드가 들어가니 견고하긴 해도 너무 비용적으로 부담이 되었습니다.
테스트를 실패하지 않게.. 작성하지 않는 것은 어떨까..
테스트를 실패하지 않게.. 작성하지 않는 것은 어떨까..
배보다 배꼽이 더 큰 개발을 하는 것은 리스크 적으로도 좋지 않고, 회사에서 추구하는 방향과도 거리감이 있었기 때문에 과감하게 테스트 코드 방식을 변경하였습니다.

비즈니스 로직 테스트

간단하게 장바구니에 대한 로직을 만들기 전에, 통과할 수 있는 테스트코드를 작성해보았습니다.
import Cart from '../model/cart.ts'; describe('cart 비즈니스 로직 테스트', () => { it('상품 하나 추가', () => { const cart = new Cart(); cart.addItem({ id: '1', name: '상품1', price: 3000, }); expect(cart.getCartCount()).toBe(1); }); it('같은 상품 두개 추가', () => { const cart = new Cart(); cart.addItem({ id: '1', name: '상품1', price: 3000, }); cart.addItem({ id: '1', name: '상품1', price: 3000, quantity: 1, }); expect(cart.getCartCount()).toBe(1); }); it('같은 상품 두개 + 다른 상품 하나 추가', () => { const cart = new Cart(); cart.addItem({ id: '1', name: '상품1', price: 3000, }); cart.addItem({ id: '1', name: '상품1', price: 3000, quantity: 1, }); cart.addItem({ id: '2', name: '상품2', price: 1500, }); expect(cart.getCartCount()).toBe(2); }); });
위 내용을 토대로 비즈니스 로직을 설계하고 코드로 구현하였습니다.
interface Item { id: string; name: string; price: number; quantity?: number; } export default class Cart { protected items = new Map< string, { id: string; name: string; price: number; quantity: number } >(); constructor(items: Item[] = []) { items.forEach((item) => { this.items.set(item.id, { ...item, quantity: item.quantity ?? 1 }); }); } addItem(item: Item) { const getItem = this.items.get(item.id); if (getItem) { this.items.set(item.id, { ...getItem, quantity: getItem.quantity + (item.quantity ?? 1), }); } this.items.set(item.id, { ...item, quantity: item.quantity ?? 1 }); } getCartCount() { return this.items.size; } }
이렇게 테스트 코드를 간단하게 작성해두고, 그에 맞는 비즈니스 로직을 간단하게 구현해보았습니다.
이렇게 비즈니스 로직에 대한 테스트 코드를 작성하게 된다면, 그 이후엔 별 다른 테스트를 진행하지 않아도 신뢰성 있는 코드라고 생각이 됩니다.

브라우저 라우팅 테스트

react-router-dom 에서 제공하는 router를 기반으로 테스트를 진행하는 것입니다.
view로직이 될 수 있고, 브라우저와 브라우저 간의 데이터 전달, 흐름등을 파악하기 좋습니다.
무엇보다 가장 편하다고 생각하는건, 한 페이지에서 수행하는 명확한 기능들에 대해서 완벽한 동작 테스트를 할 수 있다는 것입니다.
import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.tsx'; import './index.css'; import { BrowserRouter } from 'react-router-dom'; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> <BrowserRouter> <App /> </BrowserRouter> </React.StrictMode>, );
최상위에서 browserRouter로 감싸주고, 저희는 저 App을 테스팅 할 것입니다.
import { Route, Routes } from 'react-router-dom'; import StartPage from './StartPage.tsx'; export const ProjectRoute = () => { return ( <Routes> <Route path={'/'} element={<StartPage />} /> </Routes> ); }; import Text from '../components/commons/Text.tsx'; import theme from '../utils/theme.ts'; import Button from '../components/commons/Button.tsx'; const StartPage = (): React.ReactNode => { return ( <> <Button position={'bottom'}>시작하기</Button> </> ); }; export default StartPage;
route의 설정을 이렇게 추가해둔 상태로, 루트 페이지에 대해서 테스트 코드를 작성해 보겠습니다.
import { createMemoryHistory } from 'history'; import { Router } from 'react-router-dom'; import App from '../App.tsx'; import { render, screen } from '@testing-library/react'; describe('1. 시작 페이지 확인', () => { it('1. 스타트 페이지 확인', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }); render( <Router location={history.location} navigator={history}> <App /> </Router>, ); expect(await screen.findByText(/시작하기/)).toBeInTheDocument(); }); });
시작을 ‘/’로 주고, 해당하는 페이지인 StartPage의 ‘시작하기’ 가 제대로 나왔는지에 대해 테스트를 진행하였습니다.
이런방식으로 StartPage에 대한 로직들이 추가되면, ‘/’를 시작으로 데이터의 흐름도 파악이 가능하고, 추가로 ‘/’에서의 기능동작을 명확하게, 정확하게 할 수 있다는 장점이 있습니다.
저는 위와같은 장점들을 토대로 비즈니스 로직과 브라우저 라우팅 테스트를 자주하는 편입니다. 하지만, 꼭 이것이 정답은 아닙니다.

더 명확하고 정확한 테스트를 위해

const Test = () => { const [장바구니데이터] = useGetCartHook(); const [장바구니, 장바구니담기] = useState([]) useEffect(() => { 장바구니담기(장바구니데이터) }, []); return ( <div> {장바구니.map(상품 => <span>{상품}</span>)} </div> ) } const useGetCartHook = () => { const [장바구니API, 장바구니API담기] = useState([]) useEffect(() => { (()=> { fetch('유알엘').then((레스) => { 장바구니API담기(레스.데이터) }) })() }, []); }
위와같은 코드가 있다고 가정합시다. (예제 코드가 참..)
useGetCartHook은 fetch가 된 시점에 데이터를 받아 return 해줄 것입니다. 그런데, 초기값이 빈 배열의 형태를 가지고 있죠.
Test컴포넌트도 정상적으로 동작하진 않을것입니다. 이러한 코드가 프로덕트 환경에 존재한다고 가정할때, 여러 렌더링에 영향을 주는 state, hook 등에 의해 리 랜더링이 일어나서 개발자가 직접 눈으로 버그를 확인하지 못했다면, 이는 더 큰 문제로 돌아올 수 있습니다.
‘브라우저 라우팅 테스트’ 에서도 개발자의 눈과 동일하게 하나의 페이지 단위로 테스트를 하기때문에 놓칠 수 있습니다. (경험담입니다.)
이러한 사이드 이펙트를 위해 작은 단위의 테스트도 필요합니다. 하지만, 언제나 그렇듯.. 시간은 많지 않기 때문에 간단하면서, 많은 득을 볼 수 있는 2가지 테스트를 우선적으로 하는 편입니다.
 
기회와 시간이 주어진다면 정말 프로덕트 코드의 여러 테스트를 진행해보고 싶습니다. 아직은 병아리 단계지만,, 언젠간 꼭 TDD도 해보고 많은 테스트 코드를 토대로 테스트 코드 강연에도 참석해보고 싶습니다..
 
언제나 많은 피드백 부탁드립니다! 감사합니다.