단위 테스트

원정·2025년 5월 27일
9

테스트 코드

목록 보기
1/1
post-thumbnail

항해 교육 과정에서 가장 아쉬움이 남았던 챕터가 테스트 코드였습니다.
이렇게 테스트하면 되겠다고 생각했지만 문법을 모르고 비동기로 동작하는 코드인지 몰라 다른 결과가 나오고 제출은 다가오는 어려움을 겪었습니다.
우여곡절 끝에 과제는 모두 해결했지만, PR 링크 제출을 깜빡하여 체점을 받지 못했습니다.

이후 꼭 한 번은 정리하고 자주 사용하면서 익숙해져야겠다고 생각했는데요.
계속 미루다가 입사 예정인 회사에서 Jest와 Storybook을 사용한다는 얘기를 듣고 부랴부랴 정리하고 사이드 프로젝트에 적용해보는 시간을 가졌습니다.

먼저, 테스트 코드에 대해서 알아보고 사이드 프로젝트에 적용시키며 문법에 익숙해지는 걸 목표로 진행할 예정입니다.
사이드 프로젝트가 Vite 기반의 React 프로젝트이므로 Jest가 아닌 Vitest를 사용할 예정입니다.

1. 테스트란?


테스트란 작성한 코드가 의도하는 대로 동작하는지 검증하는 과정을 의미합니다.
테스트를 통해 오류를 쉽게 찾아내고 애플리케이션의 품질을 높여 사용자에게 안정적인 서비스를 제공할 수 있습니다.

2. 좋은 테스트 코드 작성을 위한 원칙


좋은 테스트 코드를 작성하기 위해 지켜야 할 원칙이 있습니다.

2.1. 인터페이스 기준으로 작성하기

테스트 코드는 인터페이스 기준으로 작성해야 합니다.
인터페이스란 모듈의 내부 구현이 아닌, 외부에 공개된 public method입니다.

모듈의 캡슐화된 내부 구현을 테스트하게 되면 수많은 테스트를 작성해야 하고 모듈에 종속적인 성격을 띄게 되기 때문에 내부 구현이 조금만 변경돼도 깨지기 쉬운 코드가 됩니다.

테스트는 실제 사용자의 사용하는 방식과 유사할 수록 테스트의 신뢰성이 올라갑니다.
따라서 내부 구현에 종속적이지 않고 이벤트 인터페이스 기반으로 검증해야 좋습니다.

2.2. 커버리지보다는 의미있는 테스트

커버리지보다는 의미있는 테스트 코드를 작성해야 합니다.
100% 커버리지는 오히려 무의미한 테스트가 포함됐을 가능성을 의미할 수 있습니다.
커버리지 수치에 집착하기 보단, 어떤 걸 테스트해야 하고 테스트하지 말아야 할지, 의미있는 테스트를 작성할 수 있도록 고민해봐야 합니다.

2.3. 단일 책임 원칙을 지켜라

테스트 코드도 단일 책임 원칙을 지켜, 하나의 테스트는 하나의 검증만 하도록 작성합니다.
또한 description을 명확히 적음으로써 어떤 테스트를 하고 있는지 명확히 작성해야 합니다.

3. 테스트 코드로 얻게 되는 장점들


테스트 코드를 통해 얻게 되는 장점은 무엇일까요?

3.1. 좋은 설계를 위한 가이드 역할

첫 번째는 좋은 설계를 할 수 있도록 도와줍니다.
좋은 테스트 코드를 작성하기 위해서는 단일 책임 원칙을 지켜야 한다고 말씀 드렸는데요.
그러기 위해서는 모듈 간의 역할이 분리되고 의존성이 낮아야 합니다.
만약 페이지 컴포넌트에서 비지니스 로직, UI, 상태 관련한 코드들이 서로 높은 의존성을 띄고 있다면, 특정 부분을 따로 테스트하기가 어렵습니다.

테스트 코드는 독립성이 중요합니다.
독립성이 높은 테스트란, 실행 순서와 다른 테스트 결과에 영향을 받지 않는 테스트를 의미합니다.
하지만 하나의 컴포넌트에 여러 로직이 얽혀서 높은 의존성이 형성된다면 독립성은 떨어지고 특정 로직만 별도로 테스트하기 어렵습니다.

따라서 테스트 코드를 작성하기 위해서 모듈 간의 의존성을 낮추고 책임과 역할을 분리함으로써 좋은 모듈 구조를 가질 수 있도록 설계하게 됩니다.

3.2. 안전한 리팩토링 지원

두 번째로는 리팩토링을 원활하게 해줍니다.
리팩토링이란, 같은 결과를 내지만 내부 로직이나 코드를 새로 작성하는 작업을 의미합니다.
테스트 코드는 같은 결과를 내는지를 검증하는 최소한의 기준이 됩니다.
또한 리팩토링 과정에서 문제가 발생한다면, 발생 범위를 좁혀 어디에서 문제가 발생했는지 빠르게 파악할 수 있도록 도와줍니다.

3.3. 살아있는 문서 역할

세 번째로는 잘 짜여진 테스트 코드는 하나의 문서가 될 수 있습니다.
잘 짜여진 테스트 코드는 테스트 코드만 봐도 어떻게 동작해야 하는지 알 수 있습니다.
이는 온보딩 과정에서 로직 파악을 위한 시간을 단축시켜 줍니다.

4. 단위 테스트


4.1. 단위 테스트란?

먼저, 단위 테스트란 무엇일까요?
단위 테스트란 소프트웨어의 가장 작은 단위인 단일 함수나 단일 컴포넌트 등을 검증하는 테스트입니다.
단위 테스트는 상호 작용을 검증하기 보단 모듈의 행위를 독립적으로 검증합니다.
주로 공통 컴포넌트, 공통 유틸, 헬퍼 함수가 대상입니다.

4.2. AAA 패턴 (Arrange-Act-Assert)

단일 테스트를 작성할 때는 AAA(Arrange Act Assert) 패턴을 사용합니다.

  • Arrange: 테스트를 위한 환경을 설정합니다.
  • Act: 테스트를 할 동작을 발생시킵니다.
  • Assert: 검증합니다.

4.3. 단위 테스트의 한계

단위 테스트는 하나의 모듈이 의도한대로 동작하는지 검증할 수 있지만 다른 모듈과의 상호작용을 통해 애플리케이션 내에서 요구사항에 맞게 동작하는지 검증하기는 어렵습니다.

5. Testing Library


Testing Library는 UI 컴포넌트를 사용자가 사용하는 방식으로 테스트하자는 철학을 갖고 있습니다.
앞서 사용자가 사용하는 방식과 유사하게 테스트할 수록 신뢰성이 올라간다고 말씀드렸는데요.

Testing Library는 컴포넌트 내부 구현이나 상태에 직접 접근하지 않고 DOM 요소를 조회하고 이벤트를 발생시키는 방법으로 테스트합니다.
실제 사용자 시나리오와 유사하게 DOM 렌더링 -> 사용자 인터렉션(이벤트 발생) -> 검증 DOM 선택 -> 검증 순서로 검증 절차를 밟습니다.

Testing Library는 특정 프레임워크나 테스트 프레임워크에 종속되지 않고, 여러 프레임워크와 테스트 프레임워크에서 사용할 수 있는데요.
React 환경에 최적화된 버전이 React Testing Library입니다.

6. 적용해보기


이제 간단한 Button 컴포넌트와 유틸 함수를 테스트해볼텐데요.
공통 컴포넌트는 UI보다는 사용자가 발생시키는 이벤트를 기준으로 테스트할 예정입니다.

6.1. Button 컴포넌트 테스트

사용자가 버튼을 클릭하는 상황을 시뮬레이션하고 prop으로 넘겨준 onClick이 동작하는지 검증해보도록 하겠습니다.

it('버튼 클릭 시 onClick prop으로 넘긴 함수가 실행된다.', async () => {
  // Arrange - 테스트를 위한 환경을 설정한다. (테스트할 Button UI를 렌더링한다)
  const spy = vi.fn();
  const { user } = await render(<Button onClick={spy}>test</Button>);

  // Act - 테스트할 동작을 수행한다. (userEvent를 통해서 사용자가 버튼을 클릭한 상황을 시뮬레이션한다)
  const button = screen.getByRole('button');
  await user.click(button);

  // Assert - 검증한다. (spy 함수가 호출됐는지를 확인한다)
  expect(spy).toHaveBeenCalled();
});

추가로 Button 컴포넌트는 props로 넘긴 variant, color, size에 따라 스타일이 적용되지만, 스타일을 변경하고 싶을 때는 className을 통해 Tailwind CSS 클래스를 전달하기도 합니다.

import { screen } from '@testing-library/react';
import { Button } from './Button';
import render from '@/utils/test/render';

it('className prop으로 넘긴 class가 적용된다.', () => {
  // Arrange - 테스트를 위한 환경 설정(테스트할 Button UI를 렌더링한다.
  render(<Button className="my-class">test</Button>);

  // Assert - 검증한다(prop으로 넘겨준 my-class가 적용되어 있는지 확인한다).
  const button = screen.getByRole('button');
  expect(button).toHaveClass('my-class');
});

이제 작성한 테스트를 실행시켜 볼까요?

모두 의도한대로 동작하는 걸 확인할 수 있습니다.

6.2. 유틸 함수 테스트

이제 간단한 유틸 함수도 테스트해보겠습니다.

formatRelativeDate 함수는 작성일과 수정일 가운데 최신 날짜를 기준으로 오늘 날짜와 비교하여
현재 시간 기준으로 게시글이 언제 작성 또는 수정됐는지를 출력해주는 함수입니다.

describe('formatRelativeDate', () => {
  beforeAll(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2025-05-27T09:00:00'));
  });

  afterAll(() => {
    vi.useRealTimers();
  });

  it('3초 전 날짜를 인자로 넘기면 "3s ago"를 출력한다', () => {
    const result = formatRelativeDate('2025-05-27T08:59:57.000000', '2025-05-27T08:59:57.000000');
    expect(result).toBe('3s ago');
  });

  it('30분 전 날짜를 인자로 넘기면 "30m ago"를 출력한다', () => {
    const result = formatRelativeDate('2025-05-27T08:30:00.000000', '2025-05-27T08:30:00.000000');
    expect(result).toBe('30m ago');
  });

  it('3시간 전 날짜를 인자로 넘기면 "3h ago"를 출력한다', () => {
    const result = formatRelativeDate('2025-05-27T06:00:00.000000', '2025-05-27T06:00:00.000000');
    expect(result).toBe('3h ago');
  });

  it('3일 전 날짜를 인자로 넘기면 "3d ago"를 출력한다', () => {
    const result = formatRelativeDate('2025-05-24T09:00:00.000000', '2025-05-24T09:00:00.000000');
    expect(result).toBe('3d ago');
  });

  it('생성일과 수정일 중 더 최신 날짜를 기준으로 결과가 출력된다.', () => {
    const createdAt = '2025-05-24T09:00:00.000000';
    const modifiedAt = '2025-05-27T08:59:57.000000';
    const result = formatRelativeDate(createdAt, modifiedAt);
    expect(result).toBe('3s ago');
  });
});

이 과정에서 FakeTimer를 사용했는데요.
날짜를 비교해야 할 경우 실제 시간을 사용하면, 테스트 결과가 실행 시점에 영향을 받기 때문에 FakeTimer로 시간을 지정하여 사용합니다.

Setup과 TearDown을 사용해서 시스템 시간을 맞추고 테스트가 완료되면 다시 원래 시간으로 돌려놓도록 했습니다.
만약 테스트 별로 현재 시간을 다르게 설정한다면 beforeEach와 afterEach를 사용하여 각 테스트 별로 setSytemTime을 따로 설정해주면 됩니다.

추가로 기대값을 검증하는 과정에서 toBe과 toEqual 두 가지가 있는데요.
보통 원시 자료형을 비교할 때는 toBe를 사용합니다.
둘의 차이는 참조형 자료를 비교할 때 있는데요.
toBe는 참조값을 비교하여 같은 메모리를 가르키고 있는지를, toEqual은 참조 자료의 내부 값을 비교합니다.

it('toBe와 toEqual을 비교해보자', () => {
  const a = { v: 1 };
  const b = { v: 1 };

  expect(a).toBe(b); // 테스트 실패
  expect(a).toEqual(b); // 테스트 성공
});

따라서 참조형 자료를 비교할 때, 같은 메모리를 바라보고 있는지 검사하려면 toBe, 같은 값을 갖고 있는지 비교하고 싶다면 toEqual을 필요에 따라 적절하게 선택해서 사용하면 됩니다.

이제 테스트 코드를 실행하면!

정상적으로 통과하는 걸 확인할 수 있습니다.

이렇게 공통 컴포넌트와 유틸 함수를 검증해봤습니다.
다음 글에서는 Storybook을 활용한 UI 테스트와 모듈 간의 상호작용을 테스트하는 통합 테스트에 대해 다뤄보겠습니다.
아무쪼록 긴 글 읽어주셔서 감사합니다.

profile
https://wonjung-jang.github.io/ 로 이동했습니다!

12개의 댓글

comment-user-thumbnail
2025년 5월 27일

초록불의 촤라락 하고 뜨는거 보면 기분이 좋아지죠!
글 잘 읽고 갑니다.

1개의 답글
comment-user-thumbnail
2025년 5월 28일

저는 사실 아직도 테스트 코드 작성 시간 vs 얻는 이익의 트레이드오프에 대해서 명확한 답을 내리지 못했어요..
테스트 코드도 결국 도구일 뿐이니까, 상황에 맞게 선택적으로 적용하는 게 현실적인 접근인 것 같습니다! 좋은 글 잘 읽었어요 👍

1개의 답글
comment-user-thumbnail
2025년 5월 29일

'입사 예정인 회사' 라니,, 역시 불황속에도 원정님은 빛나시네요!

1개의 답글
comment-user-thumbnail
2025년 5월 31일

테스트 코드에 대해 이론적인 내용을 한번 더 진행할 수 있어서 좋았습니다 bbb

1개의 답글
comment-user-thumbnail
2025년 5월 31일

테스트는 있으면 참 좋지만 쓰기는 참 귀찮은 친구죠... 최소한의 좋은 테스트만 작성할 수 있도록 공부를 해야겠습니다. 좋은 글 잘 읽었습니다.

1개의 답글
comment-user-thumbnail
2025년 6월 1일

단위 테스트는 간편하면서도 좋은 듯합니다.
개인적으로는 액션 함수 테스트가 유틸보다 더 중요하다고 생각했는데, 글을 읽다 생각하니,
액션 함수가 왜 잘못되었는가를 찾는 원인이 유틸이 될 수도 있다는 생각을 해보게 되네요.
좋은 글 공유 감사합니다!

1개의 답글