본문 바로가기
source-code/FrontEnd

지속 가능한 프론트엔드 단위 테스트 작성법

by mattew4483 2024. 10. 9.
728x90
반응형

1. 프론트엔드는 테스트가 필요 없다?!

프론트엔드 단위 테스트는 항상 논쟁의 중심에 있습니다. 프론트엔드 애플리케이션의 복잡성과 중요성이 증가하면서 단위 테스트의 필요성을 주장하는 목소리가 점점 늘어나고 있습니다. 하지만 자주 변경되는 UI와 그로 인해 쉽게 깨져버리는 테스트 코드들을 보며 프론트엔드에서의 단위 테스트가 불필요하고 비효율적이라는 비판도 여전히 거셉니다.

 

저는 여러 프론트엔드 프로젝트 경험을 바탕으로 단위 테스트는 반드시 필요하다는 결론에 도달했습니다. 단위 테스트는 코드를 안전하게 리팩토링하고, 코드 품질을 유지하며, 오류를 사전에 예방할 수 있는 중요한 수단입니다. 코드베이스가 커지고 협업 인원이 늘어날수록 단위 테스트를 통해 코드 수정 시 발생할 수 있는 문제를 사전에 방지해야만 하며, 이는 프론트엔드에서도 마찬가지였기 때문이죠.

 

히지만 열심히 작성한 테스트 코드들이 UI 변경 한 번에 와르르 실패하는 것을 보며 단위 테스트가 컴포넌트, 즉 UI에 초첨을 맞추는 순간 절대로 지속될 수 없음을 알게 되었습니다. UI는 애플리케이션 사용자와 직접적으로 맞닿아 있기 때문에, 변경사항이 잦고 예상하기 힘듭니다. 따라서 UI에 의존하는 단위 테스트는 얼마 지나지 않아 실패하는 테스트가 되고 맙니다. 기껏 작성한 테스트 코드들이 너무도 금방 실패하는 경험이 반복되면 개발자는 지칠 수밖에 없습니다. 그리고 이는 '어차피 곧 실패할 텐데, 테스트할 필요가 있을까?' 하는 생각으로 자연스레 이어지게 됩니다. (저 역시 그랬죠!)

 

그렇다면, 지속 가능한 프론트엔드 단위 테스트를 위해서는 무엇이 필요할까요?

저는 이번 글을 통해 1) GUI 테스트에 대한 기본 원칙을 살펴보고 2) 프론트엔드 개발에서 단위 테스트 작성이 힘든 이유를 알아본 후 3) 어떻게 지속 가능한 단위 테스트를 작성할 수 있는지 얘기하고자 합니다.

 

2. 로버트 C. 마틴의 원칙 : "GUI를 테스트하지 말라"

로버트 C. 마틴(Robert C. Martin)은 소프트웨어 아키텍처와 클린 코드 원칙을 제시한 선구자입니다. 그는 클린 아키텍처(Clean Architecture)와 SOLID 원칙을 통해 소프트웨어 설계에서의 응집도(cohesion)결합도(coupling)의 중요성을 강조했으며, 특히 테스트 주도 개발(TDD, Test-Driven Development)의 중요성을 설파했습니다.

TDD 하쇼!

그의 저서 '소프트웨어 장인 정신 이야기'에서, C.마틴은 GUI 테스트에 대해 아래와 같이 얘기합니다.

GUI 테스트의 규칙은 다음과 같다.
1. GUI를 테스트하지 말라.
2. 모든 것을 테스트하라. GUI만 빼고.
3. GUI는 여러분 생각보다 작다.

 

그는 UI는 가장 빈번하게 변경되는 요소이며, 이로 인해 UI에 대한 테스트는 자주 깨지고 유지보수가 어렵다고 설명합니다. 따라서 UI를 최대한 단순화하고, 중요한 비즈니스 로직에 집중하여 테스트를 작성해야 한다고 주장했습니다. 

그의 철학에 따르면 UI는 데이터를 받아 화면에 그리는 역할만 하고, 실제 로직이나 기능은 UI 밖에서 처리해야 합니다. 이때 중요한 것은 비즈니스 로직을 처리하는 모듈들에 대한 테스트를 충실히 작성하는 것이며, UI 테스트는 유지보수가 어려운 비효율적인 작업이라는 것이 그의 결론입니다.

 

클린 아키텍처와 GUI

이러한 C.마틴의 주장을 이해하기 위해서는, 그가 제시한 "클린 아키텍처"에 대한 개념을 살펴볼 필요가 있습니다.

우리 모두가 겪어본 그래프

SW를 개발하다 보면, 언제부터인가 시간과 노력을 쏟아도 요구 사항을 반영하는 속도가 점점 늦어지는 순간이 찾아옵니다. 왜 이런 일이 벌어질까요? 간단합니다. 프로젝트가 지나치게 복잡해졌기 때문이죠.

 

작은 수정사항이 그와 관계없어 보이는 변경사항을 만들어내고, 이로 인해 SW 전체가 정상적으로 동작하지 않는 상황. 복잡한 프로젝트에서는 이런 상황이 끊임없이 발생합니다. 이것이 반복되면, 개발자는 기존 코드를 수정하기 꺼리며, 새로운 요구 사항의 반영 속도는 점점 느려집니다. 자연스레 생산성이 하락하고 마는 것이죠.

 

그렇다면 이 복잡함은 언제 발생하는 걸까요? C.마틴은 고수준의 비즈니스 정책과 저수준의 세부사항의 결합을 SW의 복잡성을 증대시키는 근본적인 원인으로 진단했습니다. 애플리케이션의 핵심적인 기능과 도메인 로직을 담당하는 고수준 비즈니스 정책이 외부 라이브러리, UI, 데이터베이스 등을 통해 고수준 정책을 직접 구현하는 저수준 세부사항에 의존하는 순간 SW의 복잡도가 기하급수적으로 증대된다는 것이죠. 

클린 아키텍처

따라서 그는 고수준 비즈니스 정책은 저수준 세부사항에 의존하지 않아야 한다는 원칙을 강조했습니다. 즉, 고수준 비즈니스 정책은 저수준의 세부사항과 분리되어 있어야 하며, 저수준 세부사항이 바뀌더라도 고수준 로직에 영향을 미치지 않아야 한다는 것이죠. (클린 아키텍처는 이에 대한 세부적인 원칙과 실천 방안을 의미합니다)

 

이러한 관점에서 UI Framework를 포함한 GUI는... 언제든 변경 가능한, 저수준 세부사항에 해당합니다. 즉 상태 관리를 위해 Context API를 사용하던 Redux를 사용하던, 더 나아가 React를 사용하던 Angular를 사용하던, 애플리케이션의 핵심적인 기능과 동작에는 아무런 영향이 없어야만 한다는 것이죠!

 

FE에 클린 아키텍처 적용하기 (feat. Feature-Sliced Design)

그러나, 프론트엔드에 클린 아키텍처 개념을 직접적으로 적용하긴 어렵습니다. GUI를 다루는 FE 작업에서 GUI에 의존하지 말라니! 대부분의  개발자들에게 익숙하지 않은 개념일뿐더러... 그렇게 해야 할 이유가 크게 체감되지도 않죠. (Angular에서 React로 변경해야 하는 상황이 그렇게 빈번할까요?)

 

하지만 그럼에도 불구하고, 클린 아키텍처 핵심 철학 자체는 프론트엔드 개발에서도 반드시 지켜져야 합니다. 즉, 고수준 정책과 저수준 세부사항을 분리하고, 저수준 세부사항이 고수준 정책에 의존해야 한다(그 반대가 되어서는 안 된다)는 철학이 프론트엔드 개발에서만 예외가 되어서는 안 됩니다.

중요한 것은 분리, 그리고 의존성의 뱡향

그렇다면 프론트엔드 영역에서 고수준의 비즈니스 정책과 저수준의 세부사항을 대체 어떻게 구분해야 할까요? 기획안에 작성된 내용 = 비즈니스 정책 으로 이해하면 될까요? UI는 세부사항이고, 그 이외는 모두 비즈니스 정책으로 구분하면 되는 걸까요? 한창 프론트엔드 분야에서 논의되었던 container ↔ presenter 패턴으로 돌아가야 하는 것일까요?

 

당연히 여기에 정해진 정답은 없습니다. 각 상황마다 대답이 달라질 수밖에 없죠. 핵심은 프론트엔드에서도 애플리케이션 내 모든 코드들을 위계에 따라 구분 짓고, 이렇게 구분된 계층들의 의존 방향을 하나로 통일해야 한다는 것입니다.

Feature-Sliced Design

Feature-Sliced Design 아키텍처는 이러한 개념을 프론트엔드에 접목시킨 대표적인 폴더 구조 아키텍처입니다. FSD 패턴에서는 모든 소스 코드들을 7개의 계층(layer)으로 나누고, 하위 layer(아래쪽에 위치)는 상위 layer(위쪽에 위치)에 의존할 수 없다는 원칙을 제시합니다.

 

그렇다면 하위/상위 layer는 무엇을 기준으로 나뉘는 걸까요? 여기에서 FSD 패턴과 C.마틴의 클린 아키텍처와의 공통 철학이 드러납니다. 즉 아래쪽에 위치한 계층일수록 고수준 비즈니스 정책(애플리케이션 도메인)에 가깝고, 위쪽에 위치한 계층일수록 저수준 세부사항(애플리케이션과 무관한, 외부 세계)에 가까운 것이죠!

클린 아키텍처 철학을 FE에 구현한 모습, 느껴지시나요?

소스 코드가 도메인 영역에 위치할수록 애플리케이션이 무엇이고, 어떻게 동작해야 하는지(즉 비즈니스 정책)를 정의하는 역할을 담당합니다. '우리의 애플리케이션은 ~~ 하게 동작해야 해'가 주된 관심사이며, 이것을 GUI로도, api 요청으로도, 데이터 모델(인터페이스)이나 유틸리티 함수로도 표현할 수 있는 것이죠. 

 

한편 소스 코드가 외부 세계 영역에 위치할수록 실제 유저가 애플리케이션을 사용할 수 있도록 구현하는 역할을 담당합니다. 변경이 잦고 예측하기 어려운 외부 세계와 애플리케이션이 상호작용하는 주요 지점들을 제공하며, 따라서 도메인 영역과 철저하게 분리해 해당 영역의 변경사항이 애플리케이션 동작에 영향을 주지 않도록 제어해야 합니다.

 

그래서 단위 테스트는?

지금까지의 흐름은 아래와 같이 요약할 수 있습니다.

1) 생산성 향상을 위해, 애플리케이션의 복잡도가 지나치게 커지지 않도록 제어해야 한다.

2) 애플리케이션의 복잡도를 제어하기 위해, 고수준의 비즈니스(도메인) 정책과 저수준의 세부 구현 사항을 분리해야 한다.

3) FSD 아키텍처를 통해, 고수준 정책과 저수준 세부사항의 분리를 달성할 수 있다.

 

물론 꼭 앞서 소개한 FSD 아키텍처를 적용할 필요는 없습니다. 핵심은 비즈니스 영역과 세부 구현 사항이 분리되어있어야 한다는 것이니까요. 이 두 영역 간의 분리가 선행되었을 때, 우리는 비로소 프론트엔드에서의 단위 테스트가 어떤 모습인지를 그려볼 수 있게 됩니다.

 

3. 프론트엔드 단위 테스트 코드

우리는 단위 테스트가 싫다

프론트엔드 개발자들이 단위 테스트 작성에 (마음먹었다가도) 실패하는 이유는... 대부분 아래와 같은 장벽들을 마주하기 때문입니다.

무엇을 테스트해야 할지 모르겠다.
페이지 별로 사용자가 할 수 있는 행위가 많고, 각 행위들이 모든 페이지 내 요소들에 연결이 되어있는데... 이들을 모두 테스트하기가 현실적으로 불가능하다.
기껏 테스트 코드를 작성해 둬도, UI 가 변경되는 순간 테스트 코드가 실패한다.
페이지 내 글자 하나를 바꿀 때도 소스코드와 테스트코드 둘 다 수정해야 해 번거롭다.
브라우저에서 직접 확인하는 것이 더 쉽고 간편하다.
테스트 코드를 작성하는 공수 대비, 직접 브라우저에서 페이지에 접속하고, 버튼을 누르고, 인풋을 입력하는 것이 훨씬 빠르고 정확하다.

 

이들은 크게 두 가지로 요약할 수 있습니다.

1. 너무 쉽게, 자주 실패하는 테스트 코드

프론트엔드 단위 테스트와 관련된 예제들을 보면, 대부분 아래와 같이 작성되어 있습니다.

// 참고) 코드와 함께 살펴보는 프론트엔드 단위 테스트 – Part 2. 실전 편
// https://techblog.woowahan.com/17721/

const defaultIdentificationProps = {
  referrer: '',
  onFinish: () => {},
};

describe('Identification 단위 테스트', () => {
  // ...

  it('인증 정보 API 호출하며 성공 시 페이지 제목이 노출된다', async () => {
    /* MSW 성공 응답 설정 */
    server.use(
      http.get('인증정보 API URL', () => {
        return HttpResponse.json({
          // 성공 응답 JSON
        });
      }),
    );
    render(<Identification {...defaultIdentificationProps} />);

    await waitFor(() => {
      expect(screen.queryByLabelText('화면을 불러오는 중')).not.toBeInTheDocument();
    });

    expect(screen.getByText('인증을 시작합니다')).toBeInTheDocument();
  });
});

 

여기서 테스트의 성공/실패는 각 expect 구문에 의해 결정됩니다. 그리고 이들은 '화면에 특정 글자가 존재하는지'를 통해 테스트 성공 여부를 결정하죠. (특정 id, 태그, label 등으로 결정하는 것 역시 맥락은 동일합니다. 요점은 '화면에 의도한 UI 요소가 존재하는지'로 테스트 결과가 달라진다는 것)

 

물론 이러한 테스트 역시 중요합니다. 어쨌든 우리는 프론트엔드 애플리케이션을 개발 중이니까요. 무언가가 '보이는지'도 반드시 테스트해야만 합니다.

 

하지만 이와 동시에, 우리는 너무나도 잘 알고 있습니다. 고객들이 얼마나 변덕스러운지. 기획과 디자인이 얼마나 자주 바뀌는지. 화면에 보이는 요소 즉 UI가 얼마나 빨리 나타났다, 수정되었다, 사라졌다, 다시 나타나는지를 말이죠. 한창 개발이 진행 중인 프로덕트나 기능일수록, UI 관련 요구 사항은 시도 때도 없이 변경됩니다. 그리고 이는 UI에 의존하는 테스트 코드는 조만간 실패할 것이 불 보듯 뻔함 을 의미합니다. 

버튼 이름이 하나 바뀌면, 단위 테스트 10개가 실패합니다

2. 무엇을 테스트할 것인가 에 대한 의문

대체 무엇을 테스트해야 하는가. 프론트 개발자들이 테스트 코드 작성을 망설이게 만드는 두 번째 고민입니다.

 

역시 프론트엔드 단위 테스트 관련 예제들을 보면, 대부분 특정 페이지 단위로 테스트 코드가 작성된 것을 볼 수 있습니다. 해당 페이지에서 요구되는 동작이나, 존재하는 기능이 몇 개 없을 때는 이것이 크게 어렵지 않죠. 하지만 우리가 실제로 마주하는 제품들은 하나의 페이지에 수많은 요소와 로직들이 얽혀있기 마련입니다. 이로 인해 소스코드 작성보다, 테스트 환경 구축 자체에 많은 시간과 노력이 소요되고 맙니다. 하나의 페이지를 제대로 테스트하려면 관련된 모든 상태 관리, API 호출 등을 모킹 하거나 스파이로 설정해야 하는데... 이 과정이 그리 달가울 리가 없죠.

 

반대로 작은 컴포넌트 단위로 테스트를 작성하면, 테스트 자체가 너무 당연한 것들(예: "화면에 이 컴포넌트가 나타난다")을 검증하는 것이 아닌가 하는 의문이 들 수 있습니다. 이는 실제 비즈니스 로직을 검증하기보다는 단순히 UI 요소가 표시되는지 여부만 확인하게 되기 때문입니다.

 

또한, 특정 상태를 변경하는 로직이나 복잡한 Hook을 테스트하려고 해도 다양한 상태 관리 로직을 모킹해야 하며, 이 과정 역시 상당히 까다롭고 번거롭습니다.

 

즉 막상 테스트 코드를 작성하려고 해도, 무엇을 어디에서부터 어떻게 테스트할 것인지에 대한 의문이 발생해, 몇 번 테스트 코드를 작성하다 금방 포기해버리고 마는 것이죠.

 

지속 가능한 단위 테스트 작성법

첫째, 테스트 코드가 외부 요인으로 인해 너무 쉽게 깨진다.

둘째, 무엇을 테스트해야 하는지 모호하다.

 

이 두 요인은 프론트 개발자들이 단위 테스트와 멀어지게 만듭니다. 그리고... 우리는 이를 받아들여야만 합니다. 아무리 테스트 코드의 중요성을 설파해도, 간단한 테스트 툴들이 나타나도, 이 두 가지 원인이 존재하는 이상 프론트엔드 개발자는 절대로 테스트 코드와 가까워질 수가 없습니다. 

 

그렇다면, 어떻게 해야 할까요? 어떻게 해야 프론트엔드 영역에서도 단위 테스트를 지속 가능하게 작성할 수 있을까요? 저는 그 대답을 앞서 설명한 클린 아키텍처 철학에서 찾을 수 있었습니다.

도메인 영역에 대한 단위 테스트를 작성하라.

 

네. 이것이 전부입니다. 이것이 프론트엔드 영역에서 단위 테스트를 지속 가능하게 만드는 핵심적인 원칙입니다. 

소스 코드가 도메인 영역에 가까울 수록, 단위 테스트를 작성해야만 합니다.

도메인 영역에 대한 단위 테스트를 작성하라.

이 원칙을 앞서 얘기한 FSD 패턴에 적용한다면, 하위 계층, 즉 entity나 feature에 속하는 segement(FSD 패턴 계층 구조 중 하나. ui, api, model, lib 등 목적에 의해 그룹화된 코드를 의미합니다)에 대한 단위 테스트를 작성하라는 의미로 이해할 수 있겠죠.

 

그렇다면, 저는 왜 이런 원칙을 제시한 걸까요? 이 원칙을 통해 1) 테스트 코드가 외부 요인으로 인해 너무 쉽게 깨진다 2) 무엇을 테스트해야 하는지 모호하다 라는 두 걸림돌이 제거되기 때문입니다.

 

단위 테스트는, 도메인이 변경되었을 때만 실패한다.

도메인 영역의 코드는 애플리케이션의 핵심 비즈니스 로직과 규칙을 정의하는 코드로, 외부 UI 요소나 프레임워크와의 연계보다는 애플리케이션 자체의 동작을 결정하는 부분입니다. 이러한 영역의 코드에 대한 테스트는 UI 변화와 무관하게 비즈니스 로직을 검증할 수 있으므로, UI 요구사항이 바뀌더라도 테스트 코드가 쉽게 깨지지 않습니다.

 

하지만, 이것이 모든 UI에 대한 단위 테스트 코드를 작성하지 않는다는 의미는 아닙니다. 만약 어떠한 UI 요소가 도메인 영역에 포함된다면, 해당 UI 컴포넌트에 대한 테스트 코드는 작성해야겠죠. 즉 중요한 것은 "해당 소스코드가 도메인 영역에 가까운지"를 판단하는 것이며, 이 판단 과정에서 발생한 계층의 분리를 통해 우리의 애플리케이션(과 테스트 코드를)을 외부의 변경사항에 무관하게 유지할 수 있게 됩니다.

도메인 영역에 의존하는 단위 테스트

 

무엇을 테스트할 것인지 명확해진다.

무엇을 테스트할 것인가. 이제 답변은 간단해집니다. 도메인 영역에 소스 코드를 추가할 때, 단위 테스트를 작성하면 됩니다. 그 대상이 React 컴포넌트인지, 유틸 함수인지, 특정 라이브러리인지는 중요하지 않습니다. 테스트 환경 구축이나 테스트 케이스 작성이 복잡하더라도, 이것이 도메인 영역에 속한다고 판단했다면, 단위 테스트가 필요한 것입니다.

 

4. 결국 중요한 건? (+ 우리는 무엇에 집중해야 하는가)

그렇습니다. 프론트엔드에서 지속 가능한 단위 테스트를 작성하기 위해서는, 반드시 애플리케이션 내 모든 소스코드들이 계층에 따라 구분되어 있어야 합니다. 각 구분된 계층들이 의도(도메인 영역에 가까운가, 비즈니스 정책을 얼마만큼 구현하는가)에 따라 나뉘어있고, 이들의 의존 방향이 하나로 정해져 있어야만, 지속 가능한 단위 테스트를 작성할 수 있습니다.

도구 이전에 우리가 해야할 고민은?

최근 프론트엔드 테스트 코드의 주된 논의는 '얼마나 사용자와 동일한 환경에서 테스트할 수 있는가'에 집중되어 있다고 느낍니다. 테스트 환경에서 무언가를 입력하고, 클릭하고, 화면에 의도한 요소가 뜨는지를 확인한다. 이를 돕는 많은 라이브러리들이 나왔고, 덕분에 테스트 코드를 작성하는 것도 굉장히 쉬워졌습니다. 이는 분명한 사실이죠.

 

하지만 여러 프론트엔드 프로젝트들을 담당하면서, 도메인 영역이 분리되지 않은 상태의 단위 테스트 코드는 작성하는데 들이는 노력 대비 이점이 적고 그 수명도 지나치게 짧다는 것을 느꼈습니다. FIRST 원칙을 지키지 못하는, 이름만 단위 테스트인 코드들이 탄생할 수 밖에 없었습니다. 그리고 이는 팀 내부적으로 '단위 테스트는 아무런 도움도 주지 못한다'라는 분위기로까지 이어지고 말았습니다.

 

FSD 패턴을 통해 각 모듈들을 계층에 따라 분리하고, 도메인 영역에 대한 단위 테스트를 작성하자 여러 문제점들이 해결되었습니다. 단위 테스트가 정상 동작하는 애플리케이션을 만드는 데 실질적인 도움을 주기 시작했습니다. 코드를 수정하는 속도는 빨라졌고, 수정사항을 반영할 때 망설임이 크게 줄었습니다. 

 

우리가 속한 도메인은 무엇인가. 각 코드들의 계층은 어떻게 나눌 것인가. 해당 모듈의 책임과 관심사는 무엇인가. 

 

이제 프론트엔드 개발자들은 이러한 질문을 던져야만 합니다. 이러한 고민이 선행되어야만, 우리는 지속 가능한 단위 테스트를 작성할 수 있습니다. 그리고 지속 가능한 단위 테스트가 점차 쌓였을 때, 우리는 요구 사항을 올바르게 구현하고 수정 사항을 빠르게 반영하는, 좋은 소프트웨어를 만들어낼 힘을 비로소 갖게 됩니다.

728x90
반응형