Web Development/Testing || TDD

테스트코드 작성 가이드

Nomad Kim 2024. 11. 3. 10:45

목차

  1. 왜 테스트해야 하는가
  2. 무엇을 테스트해야 하는가
  3. 좋은 테스트란
  4. 규모별 테스트 전략
  5. 테스트코드 Q&A 그리고 팁
  6. 테스트코드 작성 스타일 가이드

1. 왜 테스트해야 하는가

개발자는 이미 개발 과정에서 수많은 테스트를 진행하고 있으며,

엄밀히 말해서 개발자가 소스 코드를 입력/수정하고 저장한 다음 진행하는 일은

대부분 테스트와 관련이 있다고 볼 수 있습니다.

문제는 이러한 테스트가 대부분 반복적인 작업이라는 것입니다.

따라서 반복된 테스트 작업을 코드로 작성해서 자동화를 하게 되면

테스트에 대한 비용이 줄어들고, 테스트가 누락되거나 잘못 검증하는 등의 실수도 방지할 수 있습니다.

또한 코드 수정에 대한 두려움이 없어져 적극적으로 리팩토링 등의 코드 개선을 할 수 있게 되고,

이는 곧 코드의 품질의 향상으로 이어지게 됩니다.

Ref

2. 무엇을 테스트해야 하는가

1. 크게 보면 세가지

  • 시각적 요소
  • 사용자 이벤트 처리
  • API 통신

2. React 에서 테스트코드 작성하기

코드를 작성할 때는 고려할 두 명의 사용자인 최종 사용자와 개발자 사용자 가 있습니다.

코드의 구현 자체에 대한 테스트 보다는

코드가 최종 사용자와 개발자 사용자에게 미치는 관찰 가능한 사용 사례에 대해 생각하고 테스트를 작성하세요.

세가지를 고려할 수 있습니다.

  • 수명주기 방법_Lifecycle methods
  • 요소 이벤트 핸들러_Element event handlers
  • 내부 구성요소 상태_Internal Component State

다른 측면에서, 두 명의 사용자와 관련되므로 테스트해야 할 사항은 다음과 같습니다.

이들 각각은 DOM을 변경하거나,

HTTP 요청을 하거나,

콜백 prop을 호출하거나,

테스트에 유용한 관찰 가능한 부작용을 수행할 수 있습니다.

  • 사용자 상호 작용_User interactions(@testing-library/user-event의 userEvent 사용)
  • 최종 사용자가 구성 요소가 렌더링하는 요소와 상호 작용할 수 있습니까?
  • 속성 변경_Changing props(React Testing Library에서 다시 렌더링 사용)
  • 개발자 사용자가 새 Prop으로 컴포넌트를 다시 렌더링하면 어떻게 되나요?
  • 컨텍스트 변경_Context changes(React Testing Library의 리렌더링 사용)
  • 개발자 사용자가 컨텍스트를 변경하여 구성 요소가 다시 렌더링되면 어떻게 되나요?
  • 구독 변경_Subscription changes
  • 구성 요소가 변경 사항을 구독하는 이벤트 이미터가 있으면 어떻게 되나요?
    (예: Firebase, redux 스토어, 라우터, 미디어 쿼리 또는 온라인 상태와 같은 브라우저 기반 구독)

3. 서비스의 치명적인 부분에 테스트가 있어야 합니다.

4. 주의할 것! 세번째 유저인 Codes 를 만드는 것을 피해야 합니다.

유저는 두가지 타입이어야 합니다. 즉, 최종 사용자와 개발자 사용자 입니다.

구현 자체에 대한 테스트에 집중하게 되면 세번째 유저인 코드 그 자체를 갖게 되는 것과 같습니다.

Do not test Implementation details but User use cases!

즉, 무엇보다 코드 자체가 아닌, 유저의 사용 플로우를 테스트해야 합니다.

테스트가 실패한 경우, 이 테스트 실패를 통해 유저 사용에 있어서의 에러를 잡을 수 있어야 합니다.

코드 구현 자체(Implementation details) 에 대한 테스트는 유저의 사용과는 무관하다고 할 수 있습니다.

Ref

3. 좋은 테스트란

  1. 실행 속도가 빨라야 합니다.
  2. 내부 구현 변경 시 깨지지 않아야 합니다.
  3. 버그를 검출할 수 있어야 합니다.
  4. 테스트의 결과가 안정적이어야 합니다.
  5. 의도가 명확히 드러나야 합니다.

1. 실행 속도가 빨라야 합니다.

테스트의 실행 속도가 빠르다는 것은 코드를 수정할 때마다 빠른 피드백을 받을 수 있다는 의미입니다.

이는 개발 속도를 빠르게 하고, 테스트를 더 자주 실행할 수 있도록 합니다.

결과를 보기 위해 수십 분을 기다려야 하는 테스트는 개발 과정에서 불필요하다고 할 수 있습니다.

2. 내부 구현 변경 시 깨지지 않아야 합니다.

이 말은 "인터페이스를 기준으로 테스트를 작성하십시오" 또는 "구현 종속적인 테스트를 작성하지 마십시오"

와 같은 지침과 같은 맥락이라 볼 수 있습니다.

좀 더 넓은 관점에서는 테스트의 단위를 너무 작게 쪼개는 경우도 해당됩니다.

작은 리팩토링에도 테스트가 깨진다면 코드를 개선할 때 믿고 의지할 수 없을 뿐 아니라,

오히려 테스트를 수정하는 비용을 발생시켜 코드 개선을 방해하는 결과를 낳습니다.

3. 버그를 검출할 수 있어야 합니다.

다르게 표현하면 "잘못된 코드를 검증하는 테스트는 실패해야 합니다"라고 할 수 있습니다.

테스트가 기대하는 결과를 구체적으로 명시하지 않거나 예상 가능한 시나리오를 모두 검증하지 않으면

제품 코드에 있는 버그를 발견하지 못할 수 있습니다.

또한 모의 객체(Mock)를 과하게 사용하면 의존성이 있는 객체의 동작이 바뀌어도

테스트 코드가 연결 과정에서의 버그를 전혀 검출하지 못하게 됩니다.

그러므로 테스트 명세는 구체적이어야 하며, 모의 객체의 사용은 최대한 지양하는 것이 좋습니다.

4. 테스트의 결과가 안정적이어야 합니다.

어제 성공했던 테스트가 오늘은 실패하거나, 특정 기기에서 성공했던 테스트가 다른 기기에서는 실패한다면

해당 테스트를 신뢰할 수 없을 것입니다.

즉, 테스트는 외부 환경의 영향을 최소화해서 언제 어디서 실행해도 동일한 결과를 보장해야 합니다.

이러한 외부 환경은 현재 시간, 현재 기기의 OS, 네트워크 상태 등을 포함하며,

직접 조작할 수 있도록 모의 객체나 별도의 도구를 활용해야만 합니다.

5. 의도가 명확히 드러나야 합니다.

제품 코드의 가독성이 중요하다는 것은 이제 누구나 인정하는 사실입니다.

좋은 품질의 코드는 "기계가 읽기 좋은" 코드가 아닌 "사람이 읽기 좋은" 코드입니다.

테스트 코드도 품질을 높이기 위해 제품 코드와 동일한 기준을 갖고 관리해야 합니다.

즉, 테스트 코드를 보고 한 눈에 어떤 내용을 테스트하는지를 파악할 수 있어야 합니다.

그렇지 않으면 추후에 해당 코드를 수정하거나 제거하기가 어려워져서 관리 비용이 늘어나게 됩니다.

테스트 준비를 위한 장황한 코드가 반복해서 사용되거나 결과를 검증하는 코드가 불필요하게 복잡하다면

별도의 함수 또는 단언문을 만들어서 추상화시키는 것이 좋습니다.

Ref

4. 규모별 테스트 전략

Test Levels

  • E2E Test
  • Integration test
  • Unit Test
  • Static Test

1. E2E(End to End) Test 란?

E2E 테스트는 End To End 테스트의 약자로 애플리케이션의 흐름을 처음부터 끝까지 테스트하는 것을 의미합니다.

유닛 테스트나 통합 테스트는 모듈의 무결성을 증명할 수 있는 강력한 테스트이지만, 모듈의 무결성이 애플리케이션 동작의 무결성까지는 증명해 줄 수 없습니다.

그래서 E2E 테스트 과정에서는 실제 사용자의 시나리오를 테스트함으로써 애플리케이션 동작을 테스트하게 되고, 이 테스트를 통과함으로써 애플리케이션의 무결성을 증명할 수 있게 됩니다.

다만 사용자의 시나리오를 검증한다는 것은 그만큼 테스트 과정이 길고 다양하다는 것을 의미합니다.

위 그림은 단계별 테스트의 비중과 비용에 대해서 잘 나타내주는 테스트 피라미드라는 그림입니다.

테스트 비용에 따라 E2E 테스트 10%, 통합 테스트 30%, 유닛 테스트 60% 와 같이 비중을 조절해야 한다는 의미입니다.

정리하면,

  • E2E 테스트는 실제 브라우저를 실행해서 테스트하는 것을 말하며,
  • 커버리지가 높고 실제 상황에서 발생할 수 있는 에러를 검출할 수 있단 장점이 있습니다. 또한
  • 브라우저를 띄우기 때문에 Web API를 활용할 수 있고
  • 테스트 코드가 내부 구조에 영향을 받지 않기 때문에 코드의 변경에도 비교적 잘 깨지지 않습니다.

코드예제(Kent C.Dodds)

일반적으로 이는 전체 애플리케이션(프런트엔드와 백엔드 모두)을 실행하며 테스트는 일반 사용자처럼 앱과 상호 작용합니다.

이 테스트는 Cypress 로 작성되었습니다.

import { generate } from 'todo-test-utils';

describe('todo app', () => {
  it('should work for a typical user', () => {
    const user = generate.user();
    const todo = generate.todo();
    // 여기서는 등록 절차를 진행합니다.
    // 일반적으로 이 작업을 수행하는 e2e 테스트는 하나만 있습니다.
    // 나머지 테스트는 동일한 끝점(endpoint)에 도달합니다.
    // 해당 경험을 통해 탐색(navigating)을 건너뛸 수 있도록 앱이 수행하는 것입니다.
    cy.visitApp();

    cy.findByText(/register/i).click();

    cy.findByLabelText(/username/i).type(user.username);

    cy.findByLabelText(/password/i).type(user.password);

    cy.findByText(/login/i).click();

    cy.findByLabelText(/add todo/i)
      .type(todo.description)
      .type('{enter}');

    cy.findByTestId('todo-0').should('have.value', todo.description);

    cy.findByLabelText('complete').click();

    cy.findByTestId('todo-0').should('have.class', 'complete');
    // 등...
    // 제 E2E 테스트는 일반적으로 사용자와 유사하게 동작합니다.
    // 때로는 꽤 길 수도 있습니다.
  });
});

Ref

2. 통합 테스트_Integration test 란?

통합 테스트(Integration Test)는 UI와 API 간의 상호작용이 올바르게 일어나는지,

또는 state에 따른 UI의 변경이 올바르게 동작하는지를 확인하는 즉, 서로 다른 모듈들 간의 상호작용을 테스트하는 과정입니다.

예를 들어, 신규로 개발한 API 서버 내의 DB 호출 함수가 데이터베이스의 데이터를 잘 호출하고 있는지 테스트하는 과정이라고 생각하면 됩니다.

실제 API를 호출하여 broad test를 하거나 API client를 모킹(mocking) 하거나 가상 API 서버를 이용함으로써 narrow test를 수행할 수 있습니다.

따라서 통합 테스트를 수행할 때는 더 많은 리소스와 시간이 필요하며, 오류를 발견하고 수정하는데 보다 많은 노력이 필요합니다.

그러나 통합 테스트를 수행함으로써, 전체적인 소프트웨어 시스템의 신뢰성과 안정성을 높일 수 있습니다.

코드예제(Kent C.Dodds)

통합 테스트의 기본 개념은 가능한 한 mock 을 최소화하는 것입니다.

저는 단지 아래 리스트만 mock 합니다:

  1. Network requests (using MSW)
  2. Components responsible for animation (누가 테스트에서 애니메이션을 기다리고 싶을까요?)
import * as React from 'react';
import { render, screen, waitForElementToBeRemoved } from 'test/app-test-utils';
import userEvent from '@testing-library/user-event';
import { build, fake } from '@jackfranklin/test-data-bot';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { handlers } from 'test/server-handlers';
import App from '../app';

const buildLoginForm = build({
  fields: {
    username: fake((f) => f.internet.userName()),
    password: fake((f) => f.internet.password()),
  },
});

// 통합 테스트는 일반적으로 MSW를 통한 HTTP 요청만 mock 합니다.
const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterAll(() => server.close());
afterEach(() => server.resetHandlers());

test(`logging in displays the user's username`, async () => {
  // 사용자 정의 렌더링은 앱이 다음을 수행할 때 resolve 되는 promise 를 반환합니다.
  // 로드가 완료되었습니다(서버 렌더링을 수행하는 경우에는 이것이 필요하지 않을 수도 있습니다).
  // 사용자 정의 렌더를 사용하면 초기 경로를 지정할 수도 있습니다.
  await render(<App />, { route: '/login' });
  const { username, password } = buildLoginForm();

  userEvent.type(screen.getByLabelText(/username/i), username);
  userEvent.type(screen.getByLabelText(/password/i), password);
  userEvent.click(screen.getByRole('button', { name: /submit/i }));

  await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i));

  // assert whatever you need to verify the user is logged in
  expect(screen.getByText(username)).toBeInTheDocument();
});

Ref

3. 단위 테스트_Unit Test 란?

단위 테스트(Unit Test)는 소프트웨어 개발에서 일반적으로 사용되는 테스트 중 하나로, 개별적인 코드 단위(보통 함수, 메서드)가 의도한 대로 작동하는지 확인하는 과정입니다.

소프트웨어의 개별 코드 단위를 테스트하여 오류를 발견하고, 이를 수정하여 전체적인 소프트웨어의 품질을 향상시키는 과정입니다.

이를 위해서는 테스트 케이스를 작성하여, 각각의 코드 단위가 정확한 입력값과 출력값을 반환하는지 확인합니다.

코드예제(Kent C.Dodds)

import '@testing-library/jest-dom/extend-expect';
import * as React from 'react';
// 위의 통합 테스트 예시와 같은 테스트 utils 모듈이 있는 경우
// 그런 다음 @testing-library/react 대신 이를 사용합니다.
import { render, screen } from '@testing-library/react';
import ItemList from '../item-list';

// 어떤 사람들은 React를 사용하여 DOM에 렌더링하기 때문에 이를 단위 테스트라고 부르지 않습니다.
// 대신 얕은 렌더링을 사용하라고 지시합니다.
// 이 이야기를 하면 https://kcd.im/shallow 로 보내주세요.
test('renders "no items" when the item list is empty', () => {
  render(<ItemList items={[]} />);
  expect(screen.getByText(/no items/i)).toBeInTheDocument();
});

test('renders the items in a list', () => {
  render(<ItemList items={['apple', 'orange', 'pear']} />);
  // 참고: 너무 간단한 경우 대신 스냅샷 사용을 고려할 수도 있지만 다음과 같은 경우에만 가능합니다.
  // 1. 스냅샷이 작습니다.
  // 2. toMatchInlineSnapshot()을 사용합니다.
  // 자세히 보기: https://kcd.im/snapshots
  expect(screen.getByText(/apple/i)).toBeInTheDocument();
  expect(screen.getByText(/orange/i)).toBeInTheDocument();
  expect(screen.getByText(/pear/i)).toBeInTheDocument();
  expect(screen.queryByText(/no items/i)).not.toBeInTheDocument();
});

단위테스트에서 AAA Pattern 은 테스트 코드의 가독성을 높이고 코드를 이해하기 쉽게 해줍니다.

  • Arrange : 테스팅 환경과 값을 정의함
  • Act : 테스트 되어야할 코드를 실행함
  • Assert : 실행 결과값을 평가함 / 예상되어야 하는 결과 혹은 값에 부합하는지 비교
// vitest 예제
import { it, expect } from 'vitest';

import { add } from './math.js';

it('배열 안의 숫자를 모두 더함', () => {
  // Arrange - 가독성을 높이고, 바꾸기 쉽도록
  const numbers = [1, 2, 3, 4, 5];

  // Act
  const result = add(numbers);

  // Assert
  const expectedAssert = numbers.reduce(
    (prevValue, curValue) => prevValue + curValue,
    0
  ); // 항상 결과값을 알 수 없기때문에 변수로 선언
  expect(result).toBe(expectedAssert);
});

4. 정적 테스트_Static Test 란?

: 정적 테스트는 코드를 실행시키지 않고 테스트를 하는 것을 말합니다.

  • 프론트엔드의 경우 ESLint 를 활용하여 사용하지 않는 변수를 찾거나
  • Typescript 로 함수의 인자로 받는 파라미터의 타입 검사를 하는 것

이 정적 테스트에 포함됩니다.

코드예제(Kent C.Dodds)

// 버그를 발견할 수 있나요?
// ESLint의 for-direction 규칙이 코드 검토에서
// 당신보다 더 빨리 이를 포착할 수 있을 것이라고 확신합니다 😉

for (var i = 0; i < 10; i--) {
  console.log(i);
}

const two = '2';
// ok, this one's contrived a bit,
// but TypeScript will tell you this is bad:
const result = add(1, two);

Ref

5. 테스트코드 Q&A 그리고 팁

  • Q. screen.getByText(username) 로 이미 찾았음을 증명할 수 있는데 expect 를 사용해야 할까?A. Yes!상세 내용은 아래와 같습니다.
    • 명확성: expect를 사용하면 테스트 의도를 명확하게 전달합니다. 이는 애플리케이션에서 관찰할 것으로 예상되는 동작을 명시적으로 나타냅니다. 이를 통해 귀하와 코드베이스를 검토하거나 유지 관리하는 다른 개발자가 테스트 사례를 더 쉽게 읽고 이해할 수 있습니다.
    • 실패 보고: assertion 이 실패하면 테스트 라이브러리(예: React 테스트 라이브러리)는 문제를 신속하게 진단하고 디버그하는 데 도움이 되는 정보 오류 메시지를 제공합니다. 'expect' 문이 없으면 테스트가 실패한 이유와 예상 동작 중 어느 부분이 충족되지 않았는지 명확하지 않습니다.
    • 일관성: 테스트 스위트 전체에서 expect 문을 일관되게 사용하면 테스트 작성을 위한 표준 형식과 접근 방식을 유지하는 데 도움이 됩니다. 모든 테스트 사례가 동일한 구조와 규칙을 따르도록 보장하여 테스트 스위트를 더욱 체계화하고 유지 관리하기 쉽게 만듭니다.
      요약하자면, 요소가 문서에 있는지 확인하기 위해 'expect'를 사용하는 것이 중복되는 것처럼 보일 수 있지만, 'expect' 문을 사용하여 주장을 명확하게 정의하는 것이 테스트에 가장 좋은 방법입니다. 테스트의 가독성, 명확성 및 유지 관리 용이성을 향상시켜 테스트 케이스를 더 쉽게 작성하고 이해할 수 있습니다.
  • expect를 사용하는 것이 중복된 것처럼 보일 수 있지만, 테스트의 명확성과 구조를 위해서 사용하는 것이 좋습니다.
  • expect(screen.getByText(username)).toBeInTheDocument();
  • Q.엘리먼트가 그려지지 않았음(null) 을 테스트하는 방법은?
  • A: 엘리먼트를 발견하지 못하는 경우 에러가 아닌 null 을 리턴하는 queryBy- 메소드를 사용합니다.

6. 테스트코드 작성 스타일 가이드

  • 한글로 작성하여 테스트코드 자체를 문서화 합니다.
  • 각 메소드 기능의 명시성을 활용하여 문서화 합니다.