이 글은 웹 개발 공부의 게시글이예요

프론트엔드에서 테스트 코드를 사용해야 하는 이유와 사용 예시 - 이론편의 썸네일

복잡한 프로젝트를 시작하기 전 나에게 부족했던 점을 생각하고 채워 나가고 있다.

현재는 Zustand , React Query , Github 에 대한 강의 및 서적들을 좀 읽어나가고 있으며 뭐가 더 부족하던 찰나 든 생각이 바로 테스트 코드였다.

예전 타입스크립트와 테스트 코드 없이 순수 바닐라 자바스크립트만으로 웹 개발을 할 수 있는데 왜 배워야 하지? 라는 우매한 생각을 가지고 있었다.

하지만 타입스크립트를 배운 이후, 코드의 안정성을 높혀줘 타입 선언까지 걸리는 시간이 발생하더라도 발생 가능한 에러를 미연에 방지함으로서 더 큰 시간을 절약해주는 경험을 한 이후로 테스트 코드의 필요성도 같이 느끼게 되었다.

그런 이유로, 이번엔 테스트 코드의 필요성과 사용 예시를 한 번 경험해보고자 한다.

프론트엔드에서 TDD가 필요한 이유

TDDTest Drvien Development 의 약자로 테스트 주도 개발을 의미한다. 이는 코드를 작성 하기 전, 기대하는 행위들을 작성한 테스트 코드를 먼저 작성하고 코드를 작성해나가는 형식을 의미한다.

  • 테스트 코드를 먼저 작성한 후 개발을 하게 되면 코드를 작성 할 때 테스트 가능한 코드를 작성 하게 된다.

이렇게 테스트 가능한 코드들은 서로 독립적인 모듈 형식으로 작성되기 때문에 수정 및 확장에 열려있는 코드가 되게 된다.

  • 예기치 못한 버그 를 예방 할 수 있다. 프론트엔드의 업무가 단순한 화면 렌더링에서 다양한 경우들도 다루게 분화되었기 때문에 예기치 못한 버그를 방지하는 것이 중요해졌다. 테스트 코드는 예기치 못한 버그를 방지하는데 큰 도움이 된다.

  • 리팩토링 안정성을 보장 한다.

테스트 코드가 존재한다면 리팩토링 후에도 기능상 문제가 발생하지 않았는지를 보장 할 수 있다.

  • 코드를 설명하는 문서화 역할 을 한다.

테스트 코드는 동작 방식을 문서화 하는 역할도 한다. 협업자들이 테스트 코드를 통해 해당 컴포넌트나 코드가 어떻게 동작하는지 쉽게 이해 할 수 있다.

  • 안정적 배포가 가능 하다.

테스트 코드를 통과하지 않는다면 배포하지 않는 방식으로 지속적 통합 및 배포 환경을 생성해둔다면 예기치 못한 버그가 존재하는 프로젝트가 배포되는 행위를 막을 수 있다.

다만 해당 방식에는 많은 갑론을박이 존재한다.

반대의견도 살펴보자

TDD 에 심취하다보면 클린 아키텍쳐보다 테스트 통과에 중점하여 코드를 작성하기 때문에 아키텍쳐에 무관심해진다라는 말도 존재한다.

🪢 테스트 주도 개발(TDD)의 장단점: Bob Martin과 Jim Coplien의 토론 해당 영상에선 TDD 를 진행 할 때 주의해야 할 점들을 많이 배울 수 있다. 앞서 말한 통과에 집착하는 행위 뿐 아니라 테스트 코드가 늘어날 수록 코드가 복잡해진다는 이야기도 존재한다.

물론 이는 도구가 늘어남에 따라 복잡성이 늘어나는 것은 당연한 이치이며 , 이를 방지하기 위해 올바른 테스트 코드 를 작성하는 것이 더욱 중요함을 상기 시킨다.

🪢 포프TV - 효율적인 테스트 코드 작성법

나는 해당 영상을 매우 좋아한다. (물론 아직 테스트 코드를 작성해본적도 없는 초보자이지만)

해당 영상에선 모든 함수들에 대해 유닛 테스트를 작성하는 것을 반대 한다. 그 이유는 사람이 예상 할 수 있는 일에 대해 테스트 코드를 작성하는 것은 불필요한 시간 낭비이며

만약 함수의 무결성을 보장하는 것이 개발자의 의도가 아닌, 테스트 코드의 통과 유무라면 그는 함수를 사용 할지도 모르는 초보 개발자라고 이야기 한다.

모든 필요한 부분들에 대해서 테스트 코드를 작성 할 경우 프로젝트의 로직들이 변경 될 때마다 이전에 작성해둔 당연한 유닛 테스트 들을 많이 거둬내야 하기 때문이다.

하지만 그렇다고 해서 테스트 코드가 불필요하단 것이 아닌, 적절한 상황에 사용해야 한다는 이야기를 하는데 영상에서는 예기치 못한 버그가 발생한 시점에, 버그가 발생한 순간을 가정하여 테스트 코드를 작성한다고 이야기 한다.

나도 사실 어느정도 이 부분은 공감한다. 다만 그 부분을 사용해보기 전 TDD 를 먼저 경험해봐야 그 다음의 단계를 알 수 있을 거기 때문에 우선적으론 TDD 를 경험해본 후 다음 단계를 생각해보겠다.

어떤 라이브러리를 사용할까 ? : Jest

나는 테스트 라이브러리로 Jest 를 이용하기로 하였다.

🪢 Jest 공식 페이지

Jest 는 설정이 간편하고 사용하기 쉬운 테스트 프레임코드일 뿐더러, 리액트에서 CRA 환경에서 리액트를 생성하면 자동으로 같이 install 되는 만큼 권장되는 프레임 워크이다.

그럼 Jest 문서를 한 번 훌텅보고 실제 TDD 를 한 번 해보자

Jest의 기본 사용법

Jest파일명.test.js|ts|jsx|tsx 와 같이 생성된 파일로 작성하며 해당 파일들을 어디에 위치시킬지는 개발자의 자유이다.

나는 우선적으론 test 라는 폴더를 따로 만들고 사용하려 한다.

테스트 파일 구조
src/
├── components/
│   ├── Button.js
│   ├── Header.js
├── utils/
│   ├── helpers.js
tests/
├── components/
│   ├── Button.test.js
│   ├── Header.test.js
├── utils/
│   ├── helpers.test.js

이후엔 기본적인 Jest 문법들을 소개하는데 자세히 설명하지 않고 코드의 생김새만 보아도 어떤 기능을 하는지 파악하는데 어려움이 없다.

기본 테스트 구조

기본 테스트 구조
test('설명 문구', () => {
  // 테스트 코드
});
 
// 또는
it('설명 문구', () => {
  // 테스트 코드
});

기본 테스트 구조는 위와 같이 test , it 메소드 이후 해당 테스트를 설명하는 문자열과 테스트 하고자 하는 상황을 표시한 콜백 함수의 형태로 나타낸다.

test,it 모두 동일한 기능을 제공하며 개인 취향을 따른다고 한다. (it 은 주로 BDD (Behavior Driven Development) 에서 사용한다 한다.)

개별적인 테스트 코드들을 하나의 블록으로 묶는 것도 가능하다.

describe를 이용해 테스트 케이스들을 블록으로 정의하기
describe('Math operations', () => {
  it('adds 1 + 2 to equal 3', () => {
    expect(1 + 2).toBe(3);
  });
 
  it('subtracts 2 - 1 to equal 1', () => {
    expect(2 - 1).toBe(1);
  });
 
  it('multiplies 2 * 3 to equal 6', () => {
    expect(2 * 3).toBe(6);
  });
});

Matcher

Matcher 메소드는 다음과 같이 사용된다.

Matcher 사용 예시 toBe , toEqual
test('1 + 1은 2입니다.', () => {
  expect(1 + 1).toBe(2);
});
 
test('객체 값 비교', () => {
  const data = { one: 1 };
  data['two'] = 2;
  expect(data).toEqual({ one: 1, two: 2 });
});

toBe 메소드는 값을 비교하고 toEqaul 은 엄격하게 동일한 객체인지를 비교한다. (참조하는 메모리 주소가 같은가)

이것 말고도 toBeTruthy , toBeFalsy ... toThrow 등 다양한 매쳐 메소드가 존재한다.

비동기 테스트

비동기 메소드들로 테스트 코드를 작성하는 방법
test('비동기 async/await 테스트', async () => {
  function fetchData() {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve('peanut butter');
      }, 1000);
    });
  }
 
  const data = await fetchData();
  expect(data).toBe('peanut butter');
});

물론 테스트 코드 내에서 비동기 처리를 이용해줄 수 있다.

async/await 문법을 이용해도 되고 .resolve/rejects 매처를 사용하는 방법도 존재한다. 나는 async/await 를 이용하여 코드를 작성하기 때문에 해당 방법을 사용하고자 한다.

Mock Functions

Mock Functions 는 특정 함수의 동작을 시뮬레이션 할 때 사용하며 jest.fn() 을 통해 목업화 해줄 수 있다.

목업 함수가 필요한 이유는 외부 의존성을 제거하고 독립적으로 테스트 할 수 있게 하기 위함이다.

목업 함수의 사용 예시 1
const forEach = require('./forEach');
 
const mockCallback = jest.fn((x) => 42 + x);
 
test('forEach mock function', () => {
  forEach([0, 1], mockCallback);
 
  // The mock function was called twice
  expect(mockCallback.mock.calls).toHaveLength(2);
 
  // The first argument of the first call to the function was 0
  expect(mockCallback.mock.calls[0][0]).toBe(0);
 
  // The first argument of the second call to the function was 1
  expect(mockCallback.mock.calls[1][0]).toBe(1);
 
  // The return value of the first call to the function was 42
  expect(mockCallback.mock.results[0].value).toBe(42);
});

위 예시는 mockCallback 이란 이름으로 목업 함수를 생성해준 후, 해당 목업 함수의 실행 결과를 테스트하는 예시이다.

목업 함수는 .mock 이란 특별한 프로퍼티로 다양한 정보에 접근 할 수 있다.

.mock 을 이용해 다양한 정보에 접근하는 예시
const forEach = require('./forEach');
 
const mockCallback = jest.fn((x) => 42 + x);
 
test('forEach mock function', () => {
  forEach([0, 1], mockCallback);
 
  // The mock function was called twice
  expect(mockCallback.mock.calls).toHaveLength(2);
 
  // The first argument of the first call to the function was 0
  expect(mockCallback.mock.calls[0][0]).toBe(0);
 
  // The first argument of the second call to the function was 1
  expect(mockCallback.mock.calls[1][0]).toBe(1);
 
  // The return value of the first call to the function was 42
  expect(mockCallback.mock.results[0].value).toBe(42);
});

사실 목업에 대한 내용은 아직 충분히 이해하지 못해서 전부 이야기를 하고 넘어가진 않겠다.

Jest의 Snapshot 방법

Snapshot Testing - 🪢 Snapshot Testing Snapshot tests are a very useful tool whenever you want to make sure your UI does not change unexpectedly.

A typical snapshot test case renders a UI component, takes a snapshot, then compares it to a reference snapshot file stored alongside the test. The test will fail if the two snapshots do not match: either the change is unexpected, or the reference snapshot needs to be updated to the new version of the UI component.

JestSnapshot Testing 기능은 프론트 영역의 테스트에서 중요한 역할을 한다.

테스트를 시행하기 전 특정 컴포넌트의 렌더링 결과를 사전에 기록하고, 테스트 하고자 하는 부분과 일치하는지를 확인하는 걸 가능하게 한다.

Jest의 Snapshot 사용 예시
import React from 'react';
import { render } from '@testing-library/react';
import MyComponent from './MyComponent';
 
test('MyComponent Snapshot 테스트', () => {
  const { asFragment } = render(<MyComponent />);
  expect(asFragment()).toMatchSnapshot();
});

다음과 같이 사용 할 때 Jest<MyComponent/> 의 렌더링 결과를 __snapshots__ 디렉토리에 저장한다. (파일 이름은 테스트 파일과 동일한 이름을 갖는다.)

이후 테스트 실행 시, 현재 렌더링 결과와 기존 스냅샷을 비교한다.

위에 테스트 코드가 처음 실행 될 때 render(<MyComponent/>) 를 통해 스냅샷이 파일 디렉토리에 저장되고 호출 결과로 반환되는 asFragment 컴포넌트가 스냅샷 파일에 존재하는 컴포넌트와 동일한지를 판단한다.

이후 테스트 코드 실행 시엔 초기에 렌더링 된 <MyComponent/> 의 결과와 실행 시점에 렌더링 된 asFramgent 가 동일한지를 파악한다.

이는 예기치 못한 컴포넌트의 수정 사항을 파악하거나 예기치 못한 변경이 존재하는지를 확인하는데 도움이 된다.

만약 MyComponent 를 의도적으로 업데이트 했다면 터미널에서 jest -u 를 통해 스냅샷을 업데이트 하는 것이 가능하다.

render 와 screen

이 부분이 프론트엔드 테스트 영역에서 가장 중요한 메소드들이라 볼 수 있을 것이다.

render

render 메소드의 사용 예시
import { render } from '@testing-library/react';
import MyComponent from './MyComponent';
 
test('MyComponent 테스트', () => {
  const { getByText } = render(<MyComponent />);
  expect(getByText('Hello, World!')).toBeInTheDocument();
});

render 는 인수로 들어온 컴포넌트를 Virtual DOM 에 렌더링 하고 해당 컴포넌트와 상호작용 할 수 있는 다양한 유틸리티들을 반환한다.

이 때 반환되는 유틸리티들 중 렌더링 된 Virtual DOM 에 접근 가능한 쿼리 함수들은 다음과 같다.

  • getByText
  • getByRole
  • getByLabelText
  • queryByText
  • findByText

쿼리 메소드에 대한 자세한 설명은 🪢 코딩앙마 - React Testing Library #3 요소를 찾는 쿼리 getBy~ Queries 에 자세히 설명되어 있으니 참고하면 좋을 것 같다.

쿼리 메소드란 ?

쿼리 메소드란 DOM에서 요소를 찾기 위해 사용된다.

이 때의 DOM 은 실제 브라우저 환경의 DOM 이 아닌 Virtual DOM 이기 때문에 네트워크 상황에 상관 없이 사용 가능하다.

getBy 계열 쿼리 메소드 : 요소가 존재하지 않으면 에러를 발생한다. queryBy 계열 쿼리 메소드 : 요소가 존재하지 않으면 null 을 반환한다. findBy 계열 쿼리 메소드 : 요소를 비동기적으로 찾는다.

위 테스트 코드를 살펴보면 <MyComponent/>Virtual DOM 에 렌더링 이후 해당 Virtaul DOMHello, World 라는 텍스트를 가진 노드가 존재하는지를 확인하는 것이라 볼 수 있다.

screen

screen 객체는 render 메소드가 반환하는 다양한 쿼리 함수들을 담고 있는 객체이다.

screen 을 이용하면 render 의 반환값을 받지 않아도 된다.
import { render, screen } from '@testing-library/react';
import MyComponent from './MyComponent';
 
test('MyComponent 테스트', () => {
  render(<MyComponent />);
  expect(screen.getByText('Hello, World!')).toBeInTheDocument();
});

이는 render 에서 반환하는 메소드를 이용하지 않고 screen 이란 객체를 통해 모든 쿼리 함수를 사용 가능하게 하는 편의성을 제공한다.

백문이 불여일견이라고 테스트 코드의 예시를 알아보자

하나 하나 깊게 들어가는 것보다 예시들을 살펴보고 직접 경험해보는 것이 더 도움이 될 것 같다.

예시를 먼저 살펴보자

기본적인 렌더링 테스트

Greeting.js
import React from 'react';
 
const Greeting = ({ name }) => <h1>Hello, {name}!</h1>;
 
export default Greeting;
Greeting.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';
 
test('renders greeting with name', () => {
  render(<Greeting name='John' />);
  expect(screen.getByText('Hello, John!')).toBeInTheDocument();
});

이벤트 핸들링 테스트

Button.js
import React from 'react';
 
const Button = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
);
 
export default Button;
Button.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
 
test('calls onClick when button is clicked', () => {
  const handleClick = jest.fn();
  render(<Button label='Click me' onClick={handleClick} />);
  fireEvent.click(screen.getByText('Click me'));
  expect(handleClick).toHaveBeenCalledTimes(1);
});

비동기 데이터 로딩 ㅌ에스트

UserPRofile.js
import React, { useEffect, useState } from 'react';
 
const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    const fetchUser = async () => {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      setUser(data);
    };
    fetchUser();
  }, [userId]);
 
  if (!user) return <div>Loading...</div>;
 
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
};
 
export default UserProfile;
UserProfile.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
 
global.fetch = jest.fn(() =>
  Promise.resolve({
    json: () =>
      Promise.resolve({ name: 'John Doe', email: 'john@example.com' }),
  }),
);
 
test('loads and displays user data', async () => {
  render(<UserProfile userId='1' />);
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  await waitFor(() => expect(screen.getByText('John Doe')).toBeInTheDocument());
  expect(screen.getByText('john@example.com')).toBeInTheDocument();
});

폼 입력 및 제출 테스트

LoadingForm.js
import React, { useState } from 'react';
 
const LoginForm = ({ onSubmit }) => {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
 
  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit({ username, password });
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <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)}
      />
      <button type='submit'>Login</button>
    </form>
  );
};
 
export default LoginForm;
LoadingForm.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import LoginForm from './LoginForm';
 
test('submits username and password', () => {
  const handleSubmit = jest.fn();
  render(<LoginForm onSubmit={handleSubmit} />);
 
  fireEvent.change(screen.getByPlaceholderText('Username'), {
    target: { value: 'testuser' },
  });
  fireEvent.change(screen.getByPlaceholderText('Password'), {
    target: { value: 'password' },
  });
  fireEvent.click(screen.getByText('Login'));
 
  expect(handleSubmit).toHaveBeenCalledWith({
    username: 'testuser',
    password: 'password',
  });
});

fireEvent 메소드는 DOM 이벤트를 시뮬레이션 할 수 있는 다양한 메소드를 제공한다.

위의 테스트 코드에선 쿼리 메소드로 반환된 DOM shemdml change,clock 이벤트 등을 실행 시키는 모습을 볼 수 있다.