프로젝트 테스트 커버리지 끌어 올리기

Ben·2022년 12월 27일
0

Today I Learned

목록 보기
56/57

개요

완성도 높은 코드를 위하여 기존 유틸 함수들에서만 적용했던 테스트 코드들을 작은 컴포넌트 → 큰 컴포넌트 순서대로 작성하기로 했다.

먼저 테스트 코드를 ‘잘’ 작성하기 위해서, 좋은 테스트 코드는 무엇인지, 테스트 코드는 어떻게 작성해야 하는 것인지 찾아보았다.

테스트코드를 작성해야 하는 이유

기존에 인터넷에서 보았던 테스트 코드를 작성해야 하는 이유를 제외하고, 스스로 테스트 코드를 작성해야 하는 이유를 생각해 보았다.

무엇을 구현하기 전에 테스트 코드를 작성한다면(TDD) 생각할 수 있는 장점은 다음과 같다고 생각한다.

  1. 테스트코드 작성 시 구현해야 할 코드 범위가 명확해진다.

단위 테스트로 테스트코드를 우선 작성할 경우, 해당 코드가 가져야 할 역할에 대해서 명확하게 정의할 수 있다는 장점이 있다. 예를 들면, 두 수를 더하여 어떤 값을 반환하는 함수를 만들기 위하여 테스트 코드를 작성한다면, 모든 테스트 케이스에 대해 통과하는지만 확인하면, 요구사항을 만족한다고 볼 수 있다.

// add.test.ts
import add from '@utils/add';

describe('add', () => {
  it('returns 3 when a is 1 and b is 2', () => {
    const a = 1;
    const b = 2;
    expect(add(a, b)).toBe(3)
  });

  it('returns 0 when a is 0 and b is 0', () => {
    const a = 0;
    const b = 0;
    expect(add(a, b)).toBe(3)
  });

  it('returns -1 when a is 1 and b is -2', () => {
    const a = 1;
    const b = -2;
  });
});

또한, 실제 제품의 코드를 작성하기 전에, 이 코드는 이 부분을 만족해야 한다!는 것을 미리 테스트 코드로 작성한다면, 요구 사항을 보다 명확히 할 수 있을 것이라고 생각한다.

  1. 유지 보수 하기 쉬운 설계를 염두할 수 있다.

이것은 와디즈 블로그에서 글을 보고 공감하는 부분인데, 테스트를 먼저 작성하게 되면 테스트 하기 쉬운 방향으로 코드를 작성하게 되고, 테스트를 먼저 작성하는 방식은 더 나은 소프트웨어 구조를 제시해주게 된다.

  1. 나에게는 더 재미를 주는 방식일 수도 있다?

나는 평소에 알고리즘 문제를 해결하는 것을 좋아한다. 알고리즘 문제 해결 방식은 코드를 작성하고 해당 코드가 테스트 케이스를 전부 통과하는지 확인하는 방식으로 테스트를 진행한다. 어떤 부분에 대해 고려하지 못했는지 명확히 할 수 있다. 이런 개발 방식을 프로젝트에 적용한다면 더 재미를 느낄 수도 있지 않을까..? 생각한다.

무엇을 테스트해야 하나?

테스트하기 쉬운 부분을 우선 테스트 한다고 생각했을 때, 현재 진행 중인 프로젝트에서는 순수 함수로 구성되어 있는 utils(이미 적용되어 있음), 특정 작은 조각으로 되어 있는 hooks, 사이드 이펙트와 의존성이 없는 작은 컴포넌트들이 있을 수 있겠다.

아직 테스트 코드 작성에 익숙하지 않은 관계로 모든 가능성에 대해 테스트를 진행하면서 테스트 케이스 작성법에 대해 익히는 과정을 거칠 계획이다.

또한, 이미 기존에 컴포넌트를 작성하고 난 뒤 테스트 코드를 추가하는 것이기 때문에 테스트 주도 개발은 아니지만, 미래에 코드를 리팩토링할 때 도움이 될 것이라고 생각한다.

테스트가 만능은 아니다

테스트 코드가 기능의 동작을 보장하기 하지만, 컴포넌트의 경우는 시각적으로 잘 보여지는지 추가적으로 확인해야 한다. 해당 부분은 storybook을 이용하여 커버하고 있다.

테스트 커버리지?

테스트는 일어날 수 있는 모든 가능성에 대하여 테스트를 진행해야 한다. 즉, 수행한 테스트가 테스트의 대상을 최대한 커버해야 한다.

단 테스트 커버리지가 100%가 된다고 해서 모든 버그들이 제어되는 것은 아니므로, 완벽한 소프트웨어를 표현하지 않는다. 개발 역량에 따라서 어느 정도의 커버리지를 가져갈 것인지는 온전히 개발자의 몫이다.

자바스크립트 환경에서 주로 사용하고 있는 jest는 --coverage 옵션을 통해 어떤 파일에서 어떻게 커버가 되고 있는지 확인할 수 있다.

yarn test --coverage

내가 작성한 테스트 코드에 대한 커버리지를 나타낸다.

  • statements: 구문 커버리지를 나타낸다. 구문 하나가 실행되었다면 커버리지로서 인정된다.
function Test(n: number) {
  // statement 1
  if (n > 0) { // statement 2
    // statement 3
  }
  // statement 4
}

만약 테스트 코드를 음수에 대해서만 작성했다면 구문 3은 실행되지 않기 때문에 75%의 커버리지를 갖는다.

  • branch: 결정 커버리지라고도 하며, 시험 대상의 전체 분기 중 테스트에 의해 실행된 것을 측정한 값이이다.
  • functions: 해당 함수가 얼마나 커버됬는지 측정하는 값?(잘 모르겠다)
  • lines: 라인 커버리지를 나타낸다. statement coverage와 거의 비슷하지만, 한 줄에 두 구문을 작성한다면 line coverage는 하나로 카운트 하지만 statement는 2개로 카운트를 한다. 요즘은 eslint가 한 줄당 하나의 구문을 강제하고 있기 때문에 사실상 동일한 의미로 취급할 수 있다.

환경 구축

추가적인 환경을 구축할 필요가 없이, 단순히 watch 옵션을 주어 테스트 커버리지를 확인할 수 있도록 하였다.

yarn test --coverage --watchAll

단, package.json에서 jest 설정을 통해 coverage를 내가 원하는 파일들로 구성할 수 있다(예를 들면, 테스트에 필요하지 않은 .stories.tsx 파일들을 커버리지에서 제외시킬 수 있다)고 했는데, 해당 옵션을 사용하면 테스트파일 자체가 인식되지 않는 문제가 있었다. 해당 이슈에 대해서는 추가적으로 조금 더 알아봐야겠다.

개선 과정

우선 shared 컴포넌트에 대하여 테스트 코드를 작성하기로 한다. 우선 컴포넌트를 작성한 다음에 테스트 코드를 작성하는 것이기 때문에 TDD는 아니지만, 추후 리팩토링 시 해당 스펙을 만족하는데 있어서 자유롭게 코드를 작성할 수 있을 것이라고 생각하여 시도해 보았다.

우선 작성한 코드는 Button, LoadingButton 및 Dropdown 컴포넌트에 대한 테스트 코드이다. 이 중, 테스트를 하면서 실제로 사용성을 개선한 경험이 있는 Dropdown 컴포넌트에 대해서 조금 더 자세히 공유하고자 한다.

우선 Dropdown 컴포넌트의 테스트 코드를 작성하기 위하여 Dropdown 컴포넌트가 가져야 할 조건에 대하여 생각해 보았다.

  1. Dropdown 컴포넌트는 기본 상태에서 아이템을 렌더링하지 않아야 한다.
  2. Button 형식으로 된 trigger를 클릭할 때 아이템이 있다면 화면에 보여져야 한다. 또한 trigger를 한번 더 클릭하면 다시 아이템이 화면에 보이지 않아야 한다.
  3. Input 형식으로 된 trigger에 텍스트를 입력하고 item이 존재한다면 화면에 보여져야 한다.
  4. Input 형식으로 된 trigger에 텍스트가 없다면 item이 화면에 보이지 않아야 한다.
  5. Input 형식으로 된 trigger를 focus할 때 item이 존재한다면 화면에 보여져야 한다.
  6. Input 형식으로 된 trigger를 blur할 때 item이 화면에 보이지 않아야 한다.

여기에 맞춰서 테스트 코드를 작성해 보기로 한다.

import { render, screen, fireEvent } from '@testing-library/react';
import Dropdown from './Dropdown';
import type { Item } from './Dropdown';

describe('Dropdown Test', () => {
  const items: Item[] = [
    { key: '1', value: 'DropdownItem1' },
    { key: '2', value: 'DropdownItem2' },
    { key: '3', value: 'DropdownItem3' },
    { key: '4', value: 'DropdownItem4' },
  ];

  // ...생
  // when click trigger(Button), render DropdownItem
  test('render items if click trigger(Button)', () => {
    const clickEvent = jest.fn();
    const trigger = <button>trigger</button>;

    render(<Dropdown items={items} trigger={trigger} />);

    const button = screen.getByRole('button');

    fireEvent.click(button, clickEvent);

    items.forEach((item) =>
      expect(screen.queryByText(item.value as string)).not.toBeNull()
    );

    fireEvent.click(button, clickEvent);

    items.forEach((item) =>
      expect(screen.queryByText(item.value as string)).toBeNull()
    );
  });

  // when type something in the input, render DropdownItem
  test('render items when something changed in the input', () => {
    const trigger = <input placeholder="write something" />;

    render(
      <Dropdown items={items} trigger={trigger} onClickItem={() => jest.fn()} />
    );

    const input = screen.getByPlaceholderText('write something');

    fireEvent.change(input, {
      target: { value: 'input' },
    });

    items.forEach((item) =>
      expect(screen.queryByText(item.value as string)).not.toBeNull()
    );

    fireEvent.change(input, {
      target: { value: ' ' },
    });

    items.forEach((item) =>
      expect(screen.queryByText(item.value as string)).toBeNull()
    );
  });

  // when item exists, show dropdown item when focus on input although there are no texts in the trigger
  test('when item exists, show dropdown item when focus on input although there are no texts in the trigger', () => {
    const trigger = <input placeholder="write something" />;

    render(
      <Dropdown items={items} trigger={trigger} onClickItem={() => jest.fn()} />
    );

    const input = screen.getByPlaceholderText('write something');
    fireEvent.focus(input);

    items.forEach((item) =>
      expect(screen.queryByText(item.value as string)).not.toBeNull()
    );

    fireEvent.blur(input);

    items.forEach((item) =>
      expect(screen.queryByText(item.value as string)).toBeNull()
    );
  });

  // when click item, dropdown expect to be closed
  test('when click DropdownItem, Dropdown expect to be closed', () => {
    const trigger = <button>trigger</button>;

    render(<Dropdown items={items} trigger={trigger} />);

    const button = screen.getByRole('button');
    fireEvent.click(button);

    const dropdownItem = screen.getByText(items[0].value as string);

    fireEvent.click(dropdownItem);

    items.forEach((item) =>
      expect(screen.queryByText(item.value as string)).toBeNull()
    );
  });

  // when click away from dropdown, expect to be closed
  test('when click away from dropdown, expect to be closed', () => {
    const trigger = <button>trigger</button>;

    render(
      <div>
        <div>click outside</div>
        <Dropdown items={items} trigger={trigger} />
      </div>
    );

    // open DropdownItems
    const button = screen.getByText('trigger');
    fireEvent.click(button);

    // close
    const clickOutSideButton = screen.getByText('click outside');
    fireEvent.mouseDown(clickOutSideButton);

    items.forEach((item) =>
      expect(screen.queryByText(item.value as string)).toBeNull()
    );
  });
});

Dropdown 컴포넌트를 사용하면서 발생할 수 있는 모든 케이스에 대하여 코드를 작성하고 테스트를 진행하였다.

테스트 코드를 작성하면서 알게된 것은, 6번의 blur 이벤트가 발생 시 테스트를 작성한 뒤 확인한 결과 해당 테스트를 통과하지 못하는 것을 확인하였다. 기존 컴포넌트에서 blur 처리를 해주지 않은 사실을 확인할 수 있었고, 이를 통해 테스트코드를 통과하도록 수정해줄 수 있었다.

개선 후 측정

Dropdown 컴포넌트의 라인 커버리지를 80%까지 끌어올릴 수 있었다. 100% 라인 커버리지를 맞출 수는 없었다. 이유는 내부에 상태를 전달하는 함수에 대하여 테스트를 진행할 수가 없었기 때문이다. 해당 부분을 제외하고 대부분의 경우에 대하여 테스트를 진행할 수 있었다.

Dropdown컴포넌트에 대하여 테스트를 진행하고 하위 컴포넌트에 대해서는 테스트코드를 작성하지 않았지만, 모든 부분에 대하여 커버가 된 것을 확인할 수 있었다.

느낀 점

컴포넌트 테스트가 필요한가?

Button, LoadingButton, Dropdown 컴포넌트에 대하여 테스트코드를 작성해보았는데, Dropdown은 여러 컴포넌트들의 조합으로 이루어져있고, 여러 조건에 따른 렌더링 차이가 많기 때문에 테스트코드를 작성하는 수고 대비 많은 이익을 볼 수 있었다. 실제로 테스트 코드를 작성하면서 요구사항을 완벽하게 맞추지 못했다는 사실을 알았고, 이에 대하여 컴포넌트 코드를 수정할 수 있었다.

그러나 Button, LoadingButton 같은 경우는 단일 컴포넌트로 구성되어 있고, 조건 변화에 따른 테스트를 구현하는 것이 쉽지 않을 뿐더러 크게 의미가 있어 보이지 않았다.

의미 없는 테스트 커버리지

테스트 커버리지를 올려보자!라는 마음에 prop 변경에 따른 버튼 사이즈 변화 역시 테스트코드로 작성하였다. 그런데 굳이 잘 동작하는지 확인하는 과정을 테스트 코드로 작성해야 했을까라는 생각이 든다.

또한, 이런 컴포넌트들은 중요한 것이 다른 컴포넌트와 함께 있을 때 레이아웃이 깨지냐, 깨지지 않느냐가 굉장히 중요하다고 생각한다. 그러나 이러한 부분은 작성했던 테스트 코드 내에서는 테스트할 수 있는 방법이 없었다(있을 수도 있으나 확인하지는 못했다).

이런 컴포넌트들은 storybook을 통해 실시간으로 확인할 수 있으며, 심지어는 이벤트도 부여하여 확인이 가능하다. 또한 작성자가 컴포넌트에 필요한 prop을 화면 수준에서 토글 버튼, 또는 라디오 버튼등을 통해 제어하여 실시간으로 변화를 확인하는 것이 가능하다.

또한 UI 변경에 의해 이러한 컴포넌트들은 너무나도 쉽게 스펙 변경이 발생한다. 이러한 스펙 변경에 대비하여 그에 맞는 테스트 코드를 작성하고 레이아웃까지 확인을 하는 것보다는, storybook을 통해 한 눈에 컴포넌트를 관리하고 동작 여부를 확인하는 것이 시간 절약을 할 수 있는 방법이지 않을까?

복잡한 form 또는 컴포넌트 등에서는 유용할 것 같다

작은 단위의 컴포넌트에서는 별로 유용하다고 생각하지 않았지만, 복잡한 form 또는 컴포넌트에 대하여 테스트하는 것은 굉장히 유용할 듯 했다.

위에서 작성했던 Dropdown 테스트 코드를 통해서 storybook을 통해 완벽하게 잡아낼 수 없었던 요구사항을 테스트 코드 작성을 통해 조금 더 다듬을 수 있었다.

특히 form을 테스트 하는데 있어서 굉장히 유용할 것 같다. 값을 입력하고, 해당 값에 따라 어떤 함수를 호출해야 하는지까지 테스트가 가능한 것을 확인할 수 있었고, 해당 함수가 호출될 때 상태의 조건까지도 함께 테스트를 진행할 수가 있다.

숫자에 집착하지 말자

단순히 보여주기용 테스트 커버리지 올리기는 현재로서는 큰 의미가 없는 듯하다. 테스트 코드를 작성하는 수고에 비해서 실제로 얻은 것은 크지 않았다.

특히, 테스트 커버리지를 올리기 위해서 모든 prop에 대한 테스트 코드를 작성하는 방식(forEach 구문으로 한번에 작성하긴 했으나, 스타일을 테스트하는 행위 자체가 큰 의미가 없다고 생각한다)은 굉장히 비효율적임을 느꼈다. 수치적으로 보여지는 성과가 존재하긴 하지만, 실제 내용을 봤을 때 오히려 리팩토링을 어렵게 만드는 행위가 될 수 있다고 생각한다.

미흡한 점

우선 테스트 코드를 작성하는 방식에 대하여 아직 미흡한 부분이 있기 때문에 중복되는 코드가 많다. 온라인에서 자료들을 찾아 보니, 이렇게 선언적으로 작성할 수 있는 방법도 많이 이용되는 듯 하다. 확실히 기존에 내가 작성했던 코드에 비해서 보다 깔끔하고 한 눈에 어떤 것을 테스트하는 코드인지 한 눈에 보기 편하다. 테스트 코드를 작성하는데 있어서도 보다 선언적으로 작성할 수 있는 연습을 해야겠다.

앞으로 방향

유틸 함수들에 대하여 테스트 코드를 작성하는 것을 제외하고, 컴포넌트단에서 테스트를 도입한 것은 처음이다. 아직은 사용 방법이 미숙하여 많은 부분에 테스트를 도입하지 못했지만, 공부를 통해 보다 큰 컴포넌트 또는 페이지를 테스트를 진행해볼 예정이다.

출처

When you run jest --coverage, what does the Branches column do/mean?

1. 테스트하기 좋은 코드 - 테스트하기 어려운 코드

테스트 코드 한 줄을 작성하기까지의 고난

프론트엔드 개발자의 TDD 적응하기 - 와디즈 테크 블로그

프론트엔드에서 테스트코드 짜기 | Kooku's log

구조적 커버리지(Coverage)의 정의와 종류

React Testing Library를 이용한 선언적이고 확장 가능한 테스트

[Testing] 1. 프론트엔드, 무엇을 테스트 할 것인가

profile
New Blog -> https://portfolio-mrbartrns.vercel.app

0개의 댓글