프론트엔드 테스트 도입기

Ji-Heon Park·2024년 11월 23일
7

TmaxRG

목록 보기
9/10

1. 서론: 테스트코드의 필요성

개인적으로 프론트엔드에서 테스트 코드 작성에 회의적이었다. "테스트 코드 작성할 시간에 빠르게 구현하고 직접 확인하는 것이 더 효율적이지 않나?"라고 생각했다. 하지만 올해 초, 여러 인원이 참여하는 대규모 프로젝트를 진행하면서 아래와 같은 문제가 빈번하게 발생했다

  • 수정한 버그의 재발
  • 새로운 코드로 인한 기존 코드의 고장
  • 요구사항의 누락

이러한 이슈를 처리하는 데 드는 시간이 점차 증가하면서, 테스트 코드 작성에 소요되는 시간보다 이슈 처리로 인한 비용이 더 크다는 것을 체감했다. 팀원들과 논의하는 과정에서 모두 비슷한 경험을 공유하고 있었고, 테스트 코드를 통해 이런 문제를 해결할 수 있겠다는 확신이 들었다.

테스트 코드를 작성하지 않던 팀에 테스트의 필요성과 몇가지 방법들을 주제로 세미나를 열었고, 토론을 통해서 긍정적인 반응과 프로젝트에 테스트 코드를 도입할 수 있었다. 지금까지 테스트 코드를 작성하면서 시도해본 것, 배운 것 등을 기록하고자 한다.

2. 프론트엔드 테스트 작성

2.1. 테스트 코드 종류

대표적인 테스트 종류는 아래와 같다.

1. Static Test (정적테스트)

테스트 도구: TypeScript, eslint

정적 테스트는 런타임 이전에 발생할 수 있는 구문 오류와 타입 오류를 미리 감지한다. TypeScript는 타입 안정성을 제공하며, ESLint는 코드 스타일과 잠재적 오류를 점검한다.

2. Unit Test (유닛테스트)

테스트 도구: jest, react-testing-library, mocha

유닛 테스트는 단일 함수나 모듈이 의도대로 동작하는지를 확인한다. 비즈니스 로직이 많은 함수나 데이터 처리를 담당하는 모듈을 점검할 때 유용하다.

3. Integration Test (통합테스트)

테스트 도구: react-testing-library, Enzyme

통합 테스트는 여러 모듈이나 컴포넌트가 상호작용하는 과정을 검증한다. 프론트엔드에서는 주로 화면 구성 요소들이 데이터와 올바르게 상호작용하는지를 테스트한다.

4. E2E테스트

테스트 도구: cypress, puppeteer

E2E 테스트는 사용자 관점에서 애플리케이션의 모든 플로우를 점검한다. 로그인, 결제와 같은 중요한 사용자 시나리오를 검증하기 적합하지만, 작성과 실행 비용이 높기 때문에 제한적으로 사용하는 것이 좋다.


그렇다면 어떤 테스트를 중점적으로 작성해야할까?

테스팅 트로피는 테스트 코드 작성 시 각 테스트의 적절한 비중을 시각적으로 나타낸 개념이다. 그림과 같이 피라미드 형태로 표현되며, 아래로 갈수록 작성 비중이 높아야 한다.

테스팅 트로피에 따르면 비즈니스 로직을 점검하기에 유닛 테스트는 지나치게 단순했고, E2E 테스트는 작성과 유지 비용이 높다. 따라서, 이번 도입에서는 E2E테스트는 배제하고 통합 테스트부터 정적테스트까지에 비중을 두어 여러 컴포넌트 간의 상호작용과 주요 사용자 플로우를 중점적으로 검증하였다.

2.2. 테스팅 툴 선택

테스팅 툴은 리액트 생태계에서 가장 많이 활용되는 JestReact Testing Library(이하 RTL)를 사용했다. RTL은 DOM Testing Library를 기반으로, 리액트 기반환경에서 리액트 컴포넌트를 테스팅할 수 있는 라이브러리이다.

RTL은 리액트 컴포넌트를 실제로 렌더링하지 않고도 컴포넌트가 원하는 대로 렌더링되고 있는지 학인할 수 있다. 이를 통해 사용자 중심의 테스트 작성, 낮은 구현 의존성, 직관적 문법으로 리액트 생태계에서 가장 활발하게 사용되는 테스트 라이브러리이다.

2.3. 테스트 케이스

테스트의 중요한 목적 중 하나는 내가 작성하는 코드에 대해 빠르게 피드백을 받는 것이다. 시작부터 큰 단위의 테스트를 만들게 된다면 작성한 코드에 대한 피드백을 받기까지 많은 시간이 걸린다. 그래서 문제를 작게 나누고, 그 중 핵심 기능에 가까운 부분부터 작게 테스트를 만들어 나간다.

테스트 케이스는 주로 명세(Spec)를 기반으로 작성했다. 특히, 요구사항의 경계값(엣지 케이스)을 중심으로 테스트를 설계했다. 값의 최소치나 최대치와 같은 경계값을 테스트하면, 그 사이의 값들도 정상적으로 동작할 가능성이 높아 효율적으로 테스트 범위를 커버할 수 있다.

// 실제 요구사항
// 1. '+', '-'버튼 클릭 시, 한번마다 10% 씩 증감
// 2. 80%일 경우 축소 버튼 클릭 시 액션없음
// 3. 200%일 경우 확대 버튼 클릭 시, 액션 없음

 test('줌 레벨은 최소 80%, 최대 200% 까지 변경 가능하다.', async () => {
    const minusButton = screen.getByTestId<HTMLButtonElement>(`zoomout-button`);
    const plusButton = screen.getByTestId<HTMLButtonElement>(`zoomin-button`);

    expect(screen.getByTestId('zoom-level')).toHaveTextContent('100');

    clickIteration(minusButton, 4);

    await waitFor(() => {
      expect(minusButton).toBeDisabled();
      expect(screen.getByTestId('zoom-level')).toHaveTextContent('80');
    });

    clickIteration(plusButton, 12);

    await waitFor(() => {
      expect(plusButton).toBeDisabled();
      expect(screen.getByTestId('zoom-level')).toHaveTextContent('200');
    });
  });

프론트엔드는 사용자에게 완전히 노출된 영역이고, 사용자는 개발자의 의도대로만 사용하지않는다. 그래서 어떻게 작동할 지 최대한 예측해서 작성해야한다.

2.4. 테스트 커버리지

파레토 법칙: 소프트웨어 테스트에서 오류의 80%는 전체 모듈의 20% 내에서 발견된다.

테스트 커버리지에 관해서는, 완벽한 커버리지는 사실상 불가능하다고 생각한다. 초기부터 모든 코드를 테스트하려 하기보다는, 다음과 같은 우선순위를 설정했다:

  • 결함이 자주 발생하는 영역: 버그가 많았던 부분에 우선 적용
  • 핵심 기능: 비즈니스 로직이나 사용자에게 직접적인 영향을 미치는 중요한 파트를 중심으로 작성

이후에는 새로운 이슈가 발생하거나 기능이 추가될 때마다 점진적으로 테스트를 보완해 갔다. 이렇게 하면 초기 작성 부담을 줄이고, 동시에 테스트의 실질적인 효과를 높이는 데 도움이 되었다.

2.5. 구현 세부에 의존하지 않는 테스트

최근에 진행한 과제에서 "구현 세부에 의존하지 않는 테스트를 작성"하라는 요구사항이 있었다.

처음 테스트 코드를 작성할 때는 data-testidclassname을 기준으로 요소에 접근했다. 하지만 이는 테스트가 구현 세부(구체적인 코드 구조나 스타일링)에 의존하게 만들었다. 특히, 프론트엔드 환경에서는 코드 변경이 잦기 때문에, 이런 접근 방식은 테스트 코드의 유지보수를 어렵게 만든다는 문제를 경험했었다.

구현 세부에 의존하지 않는 테스트 작성법

과제의 요구사항을 준수하기 위해 사용자 관점에서 요구사항을 기반으로 테스트를 작성하였다.

예를 들어:

  • 명세서: '이메일 주소' 입력 필드에 값을 입력하고, '로그인' 버튼을 클릭하면 이벤트가 작동해야 한다.
  • 테스트 코드: 실제 DOM에서 요소를 찾는 대신 getByLabelTextgetByRole 같은 접근 방식을 사용해 사용자 경험에 맞는 시나리오를 그대로 재현한다.

이러한 접근은 구현 변경(예: 클래스 이름 수정, 스타일 변경)이 발생하더라도 테스트가 깨지지 않도록 하며, 사용자 중심의 테스트를 작성할 수 있었다.

// ❌ As-Is: 구현 세부에 의존
const 이메일_입력_폼 = screen.getByTestId('email-input'); // data-testid에 의존
const 중복_확인_버튼 = screen.getByTestId('check-duplicate-button'); // data-testid에 의존

fireEvent.change(이메일_입력_폼, { target: { value: 'invalid-email' } });
fireEvent.click(중복_확인_버튼);

await waitFor(() => {
  expect(screen.getByTestId('error-message')).toHaveTextContent(
    '유효한 이메일 주소를 입력해 주세요.',
  );
});

// ✅ To-Be: 사용자 관점(UI 명세)에 의존
const 이메일_입력_폼 = screen.getByLabelText('이메일 계정');
const 중복_확인_버튼 = screen.getByText('중복 확인');

fireEvent.change(이메일_입력_폼, { target: { value: 'invalid-email' } });
fireEvent.click(중복_확인_버튼);

await waitFor(() => {
  expect(
    screen.getByText('유효한 이메일 주소를 입력해 주세요.'),
  ).toBeInTheDocument();
});

사용 시나리오에 의존한 테스트 작성을 통해 과제 피드백에서도 과제의 출제 의도에 부합했다는 긍적적인 피드백을 얻을 수 있었다. 중요한 답은 명세서 기반 테스트였다.

2.6. 비동기 이벤트

프론트엔드의 코드는 백엔드 API 응답에 의존하는 경우가 많다. 처음에는 간단히 jest.spyOn을 활용해 서버 응답을 모킹(mocking)했다. 그러나 서버 응답 값이 변경되거나 오류 상황을 테스트할 때, 모든 응답 값을 다시 모킹해야 한다. 이는 테스트를 수행할 때마다 반복 작업을 필요로 하며, 테스트 코드가 길고 복잡해지는 문제를 초래했다.

초기 해결: 팩토리 패턴

과제에서 팩토리 패턴을 도입해 Test Fixture를 간소화하는 것을 시도해보았다. 팩토리 패턴을 사용하면 테스트에 필요한 상태나 환경 설정을 공장 클래스(factory class)로 캡슐화하여 중복 작업을 줄이고 재사용성을 높일 수 있다.

하지만 이 방법은 여전히 네트워크 요청의 복잡성을 완전히 해소하지 못하고, 특정한 API 요청/응답을 관리하기 어렵다는 한계가 있었다.

MSW로 개선

추후에는 Mock Service Worker(MSW)를 활용하는 방식을 고려해보려고 한다. MSW는 네트워크 요청을 가로채고, 미리 준비된 모킹 데이터를 제공하는 도구이다.

MSW를 통해 다양한 응답 상태(성공, 오류, 지연 등)를 쉽게 설정해 유연한 오류 상황을 테스트 할 수 있다.

3. 테스트 코드 작성 이점

- 회귀 테스트 자동화와 이슈 처리 비용 감소

가장 큰 이점은 테스트 코드가 서론에서 언급한 문제점들을 해결해줄 수 있다는 점이다. 처음에는 테스트 코드 작성 비용이 크다고 느껴졌지만, 프로젝트 초기 수동 검사를 반복하는 시간을 줄이고, 시간이 지나면서 코드 변경 및 버그 수정에서 더 큰 효율성을 제공했다.

- 코드 품질

테스트 코드를 작성하면서 강한 결합으로 인해 테스트가 어려운 코드를 자주 발견했다. 강한 결합은 유지보수성과 확장성을 저해하는 주요 원인이다. 이를 해결하기 위해 의존성 주입, 함수 분리 등 리팩토링을 시도하면서 코드 구조가 더 단순하고 읽기 쉬워졌다. 결과적으로 테스트 작성이 코드 품질을 자연스럽게 개선하는 데 도움을 줬다.

- 명세서

잘 작성된 테스트 케이스는 명세서처럼 작동했다. 이를 통해 요구사항을 명확히 이해하고, 이를 기준으로 엣지 케이스와 예외 처리를 빠르게 파악할 수 있다.

describe('콘텐츠도구 테스트', () => {
  test('페이지네이션은 최소 1, 최대 페이지 갯수까지 가능하다.',async () => {});
  test('줌 인/아웃 버튼을 통해 줌 레벨이 변경되어야 한다. (단위: 10%)', async () => {});
  test('줌 레벨은 최소 80%, 최대 200% 까지 변경 가능하다.', async () => {});
});

4. 결론

테스트 코드 도입을 설득할 때 가장 많이 마주한 반대 의견은 "공수가 크다""익숙하지 않다"는 점이었다. 나 역시 테스트 코드를 작성하는 것이 항상 최우선 순위라고 생각하지는 않는다.

하지만, 테스트 코드 작성에 드는 공수가 큰 이유는 결국 테스트 작성에 익숙하지 않기 때문이라고 느꼈다. 테스트를 반복적으로 작성하고 경험을 쌓으면 숙련도가 올라가며, 작성 시간도 점차 줄어들 것이다.

또한, 테스트 코드 작성의 명확한 이점을 경험하면서, 익숙하지 않다는 이유로 시도조차 하지 않는다면 그 이점을 영원히 놓치게 된다는 것을 깨달았다. 테스트 코드는 단순히 버그를 줄이는 것 이상으로 코드 품질, 유지보수성, 협업 효율성을 높이는 데 큰 역할을 한다.

앞으로도 테스트 코드를 지속적으로 반복하고 학습해 숙련도를 높이며, 테스트가 주는 이점을 최대한 활용하려고 한다.

References

profile
Frontend Developer | 기록되지 않은 것은 기억되지 않는다

0개의 댓글

관련 채용 정보