이번엔 이 공부 끝내겠다 시리즈/테스트코드

[Unit Test] 단위 테스트 개념과 작성시 주의할 점

by GicoMomg (Lux) 2024. 4. 21.

0. 들어가기 전에

현대의 개발 생태계는 빠르게 변화하는 기술 요구사항과 지속적인 서비스 업데이트를 경험하고 있다.
그래서 개발 팀은 코드의 품질을 지키면서 신속한 배포를 동시에 충족해야한다. 이러한 환경에서 프론트엔드도 테스트의 중요성이 커지고 있다.
단위 테스트는 코드의 각 부분이 예상대로 작동하는지 확인함으로써, 전체 시스템의 안정성과 신뢰성을 보장할 수 있다.
이번 포스팅에서는 단위 테스트는 무엇이며 테스트 작성시 주의할 점은 무엇인지 한 번 알아보았다.



1. 단위 테스트 개념과 작성시 주의할 점

1) 단위 테스트란? (Unit Test)

  • 단위 테스트는 개발 과정에서 개별 모듈이나 함수가 예상대로 작동하는지 검증하는 절차이다.
  • 테스트는 소스 코드의 가장 작은 실행 단위에 대해 수행되며, 각 단위가 독립적으로 작동하는지 확인한다.
  • 단위 테스트는 소프트웨어 개발의 초기 단계에서 문제를 발견하고 수정할 수 있게 해준다. 이를 통해 추후 발생할 수 있는 버그를 예방하며, 소프트웨어의 품질을 향상시킬 수 있다.
  • 또한, 단위 테스트는 코드의 모듈성을 강제하고, 설계 결함을 조기에 발견하는 데 도움을 준다.
  • 마지막으로 코드를 변경하거나 확장할 때 기존 기능이 영향을 받지 않도록 보호하는 역할도 한다.

(1) 단위테스트와 TDD

📌 TDD는 “테스트 주도 개발”(Test Driven Development)을 뜻하며, 테스트 코드를 우선 작성하여 검증된 개발을 하는 방법론이다. 이 방법론에는 3가지 법칙이 존재한다.

첫 번째 법칙: 실패하는 테스트의 먼저 작성한다.
  • 단위 테스트의 첫 번째 법칙은 '실패하는 테스트를 먼저 작성하라'이다.
  • 새로운 기능을 추가하기 전에 해당 기능이 실패하는 테스트 케이스를 먼저 작성해야 한다.
  • 이 접근 방식은 개발자가 요구 사항을 명확히 이해하고, 구현 기능에 대한 목표를 설정하도록 한다.
두 번째 법칙: 테스트를 만족하도록 개발한다.
  • 실패한 테스트 케이스를 통과할 수 있도록, 최소한의 코드로 작성한다.
  • 이 과정에서 과도한 사전 설계를 방지하고, 필요한 기능만을 구현하게 된다.
세 번째 법칙: 리팩토링을 한다.
  • 마지막으로, 작성한 코드에서 최적화할 부분을 찾고 중복 코드를 제거한다.
  • 만약 이 과정에서 테스트가 실패한다면, 두 번째 법칙을 다시 수행해야한다.

(2) 단위테스트와 F.I.R.S.T 법칙

법칙 설명
Fast (테스트는 빨라야 한다) - 프로젝트에는 수십만 개의 테스트가 존재한다.
- 만약 테스트를 실행할 때마다 많은 시간이 소요되며 테스트를 꺼리게 된다.
- 그러므로 테스트는 가능한 한 빨라야 한다.
Independent (독립적이어야 한다) - 테스트는 순서나 외부 환경에 의존하면 안된다.
- 만약 테스트 선언 순서에 따라 결과가 달라진다면, 잘못된 테스트이다.
Repeatable (반복적으로 테스트 실행이 가능해야한다.) - 테스트는 반복적으로 실행할 수 있어야 한다.
- 외부에 의존해서는 안 되며 재실행을 방해하는 문제를 발생시키면 안된다.
- 언제든지 어떤 순서로든 반복적으로 실행되어야 한다.
Self-Validating(자체 검증이 되어야 한다) - 자체적으로 테스트의 통과 또는 실패 여부를 감지할 수 있어야 한다.
- 결과를 확인하거나 검증하는 데 필요한 추가 인스턴스는 없어야 한다.
Timely(구현 전에 작성해야한다.) - 코드를 구현하기 전에 테스트를 선 작성해야한다.
- 만약 코드 작성 후에 테스트를 작성하게 되면, 성공하는 테스트만 작성하게 된다.



2) 테스트 작성시 주의할 점

(1) 순수 함수여야 한다. (순수성을 지킬 것)

  • 순수 함수는 동일한 입력에 항상 동일한 출력을 반환하는 함수이다.
  • 순수함수는 테스트를 예측 가능하게 만든다. 외부 환경이나 상태 변화에 영향을 받지 않기에, 동일한 조건에서 항상 동일한 결과를 기대할 수 있다.
  • 또한 순수함수는 독립성을 보장하기에, 각 테스트 케이스가 다른 테스트 케이스의 결과에 영향을 주지 않는다.
  • 순수함수는 재사용하기 쉽고, 시스템의 다른 부분으로 쉽게 이동할 수 있다. 이는 유지보수를 용이하게 만든다.

순수 함수 예시
  • 아래는 두 개의 값을 더하는 순수 함수의 예시이다.
  • 이 함수는 주어진 매개변수 외에 외부 상태에 접근하지 않고, 함수의 호출이 전역 상태나 입력 변수에 영향을 미치지 않는다.
function add(a, b) {
  return a + b;
}

console.log(add(2, 3));  // 항상 5를 반환
비순수 함수 예시
  • 앞선 함수와 달리 다음의 함수는 순수 함수가 아니다.
  • addToBase 함수는 외부 변수 baseValue에 의존하고 이를 변경한다.
  • 따라서 동일한 입력 5에 대해 서로 다른 결과를 반환한다.
let baseValue = 10;

// 순수 함수가 아닌 예시
function addToBase(value) {
    baseValue += value;
    return baseValue;
}

console.log(addToBase(5));  // 15를 반환
console.log(addToBase(5));  // 20을 반환

(2) 인터페이스 기준으로 테스트를 작성할 것

📌 우리가 어떤 버튼을 눌렀을 때 모달이 뜨는지를 테스트 하고 싶다고 가정하자

나쁜 예시
  • 만약 테스트 작성시 인터페이스가 아닌, 내부 구현에 대한 테스트를 작성하게 된다면?
  • 구현에 종속되면 테스트가 깨지기 쉽고 복잡한 상태 변경 코드가 발생하게 된다. (캡슐화 위반)
// bad
it('isOpened의 상태를 true로 변경됐을 때 ModalComponent의 display가 none에서 block이 되면서, 모달 제목에 "경고" 텍스트가 노출된다.', () => {
  ModalComponent.setState({ isOpened: true });
});
좋은 예시
  • 테스트는 내부 구현이 아닌, 인터페이스 기준으로 작성해야 한다.
// good

it('버튼을 누르면 모달이 나타나야 한다.', () => {
  //유저의 동작과 비슷하도록 클릭 이벤트를 발생
  await user.click(screen.getByRole('button'));

  ...
});

(3) 100% 테스트 커버리지에 의존하지 말 것

  • 테스트를 작성하고 유지 보수 측면에서 적지 않은 비용이 발생한다.
  • 그럼에도 테스트 코드를 작성하는 이유는 사이드이펙트 예방, 동작 검증을 위함이다.
  • 하지만 이 본질 대신 100% 테스트 커버리지에 의존하는 건 좋지 않다.
  • 커버리지보다 어떤 범위까지 검증해야 효율적인 테스트인지, 의미있는 테스트인지 고민해야 한다.
의미없는 코드1 - 딘순 UI 렌더링
import React from 'react';

const List = ({ items = [] }) => {
  return (
    <ul>
      {items.map ((data) => {
      return ( <li key-{data}>{data}</li> );
    })}
    </ul>
  );
};
의미없는 코드2 - 단순한 함수
export const isNumber = value => typeof value === 'number';
export const isArray = value => Array.isArray(value);

(4) 테스트 코드도 유지 보수의 대상! 가독성을 높일 것

  • 테스트 하고자 하는 내용을 명확하게 적자
// 검증 기능: 리스트에서 체크된 항목들을 삭제

//bad
it('리스트에서 항목이 제대로 삭제된다.', () => {});

// good
it('항목들을 체크한 후 삭제 버튼을 누르면 리스트에서 체크된 항목들이 삭제된다.', () => {});

  • 하나의 테스트에서는 가급적 하나의 동작만 검증해야 한다.(단일 책임 원칙)
  • 만약 하나의 테스트에 여러 동작을 체크하는 경우, 한 테스트의 책임 범위가 커지게 된다.
// bad
it('장바구니에 담긴 상품들이 정상적으로 노출되고, 수량을 변경하면 가격이 재계산된다. 그리고 삭제 버튼을 누르면 상품이 삭제된다.', () => {});
// good

it('장바구니에 담긴 상품들을 정상적으로 렌더링 한다.', () => {});
it('장바구니에 담긴 상품의 수량을 수정하면 가격이 재계산된다. ', () => {});
it('장바구니에 담긴 항목의 삭제 버튼을 누르면 리스트에서 삭제된다. ', () => {});

(5) 모든 테스트는 독립적으로 실행되어야 한다

  • 테스트 순서가 바뀔 때마다 오류가 난다면, 좋은 테스트 코드가 아니다.
  • 순서에 따라 테스트 결과가 달라진다는 건, 테스트가 상호 의존적이라는 뜻이 된다.
// 상황1
it('A test', () => {});
it('B test', () => {}); // error
// 상황2
it('B test', () => {}); // okay
it('A test', () => {});



3) 단위 테스트 작성 방법

📌 단위 테스트를 효과적으로 작성하고 구조화하는 방법 중 하나는 Arrange-Act-Assert (AAA) 패턴을 사용하는 것이다.
이 패턴은 테스트 코드를 명확하고 읽기 쉽게 만드는 데 도움을 주며, 각 테스트의 목적과 행동을 명확하게 구분해준다.

(1) Arrange-Act-Assert (AAA) 패턴이란?

  • AAA 패턴은 테스트를 세 부분으로 나뉜다.
단계 설명
A (Arrange) - 테스트를 실행하기 전에 필요한 모든 입력, 객체, 대상, 변수 등을 설정한다.
- 이 단계에서는 테스트에 필요한 모든 조건을 준비한다.
A (Act) - 테스트 코드에서 실제 행동을 수행한다. (ex. 함수 호출, 메서드 실행 등)
- 이 단계에서는 준비된 객체에 대한 조작이 이루어지고, 결과를 생성한다.
A (Assert) - 기대한 결과가 실제로 도출되었는지 확인한다.
- 주로 검증 함수나 조건을 사용해 테스트의 성공 여부를 판단한다.

(2) AAA 패턴과 단위테스트 예시

📌 글을 작성할 수 있는 TextField 컴포넌트를 예시로 AAA 패턴을 살펴보았다.
예시 코드는 React, vitest, @testing-library/react를 사용했다.

import { screen, render } from '@testing-library/react';
import React from 'react';
import TextField from '@/components/TextField';

it('className props으로 설정한 css class가 적용된다.', async () => {
  /** A(Arrange) */
  await render(<TextField className="test-class" />);

  /** A(Assert) */
  expect(screen.getByPlaceholderText('글자를 입력하세요.')).toHaveClass(
    'test-class',
  );
});
단계 코드 설명
A (Arrange) - 테스트를 위한 환경을 준비한다. (ex. 렌더링, 모킹)
- render()를 호출하여, 테스트 환경의 jsDom에 컴포넌트를 렌더링한다.
A (Act) - 테스트할 액션이 없으므로 패스한다.
A (Assert) - 기대한 결과가 실제로 도출되었는지 확인한다.
- 플레이스홀더가 "글자를 입력하세요."인 요소를 찾아, "test-class" 클래스가 있는지 확인한다.

import { screen, render } from '@testing-library/react';
import React from 'react';
import TextField from '@/components/TextField';

it('enter키를 입력했을 때, onEnter props으로 등록한 함수가 호출된다.', async () => {
  /** A(Arrange) */
  const spy = vi.fn();
  const { user } = await render(<TextField onEnter={spy} />);
  const textInput = screen.getByPlaceholderText('텍스트를 입력해 주세요.');

  /** A(Act) */
  await user.type(textInput, 'hello{Enter}');

  /** A(Assert) */
  expect(spy).toBeCalledWith('hello');
});
단계 코드 설명
A (Arrange) - 테스트를 위한 환경을 준비한다. (ex. 렌더링, 모킹)
A (Act) - 테스트할 액션 수행한다.
- type() 는 글자를 입력하는 액션을 수행한다.
- ‘hello{Enter}’는 hello라는 글자를 입력하고, Enter키를 누르는 액션을 수행한다는 의미이다.
A (Assert) - 기대한 결과가 실제로 도출되었는지 확인한다.
- spy 함수에서 hello라는 문자가 호출되었는지 확인한다.




참고자료
소프트웨어 테스트와 TDD
Unit Testing in a Nutshell
Unit Test

반응형

댓글