내겐 너무 낮선 테스트 코드, 우리 친해져보자.

RookieAND·2024년 12월 10일
1

Solve My Question

목록 보기
30/30
post-thumbnail

1. 테스트 코드, 참 낮설다

지금껏 프론트엔드를 개발하면서 부끄럽지만 테스트의 필요성을 딱히 느끼지 못한 것 같다.

물론 이는 사람마다 테스트 코드를 바라보는 관점이 다르고, 현재 자신이 어떤 상황에 처해 있냐가 더 중요하다고 생각한다. 개발 사이클이 빠르고 코드가 자주 변경되는 스타트업이라면 더욱 테스트 코드에 대해서 “언감생심”을 가지기 마련이다.

하지만 최근 Micro Frontend와 비슷하게, 어쩌면 더 이전에 대두되었던 TDD가 프론트엔드 분야를 조금씩 점령하는 느낌이다. 과거와는 달리 Storybook, Vitest 같은 테스트를 보다 유연하고 쉽게 도와주는 테스팅 라이브러리도 많이 나왔으니까 말이다.

이번 글을 통해 내가 테스트 코드에 대해서 가졌던 의문을 정리하고 이에 대한 결론을 하나씩 작성해보려고 한다.

1-1. 내가 테스트 코드에서 궁금했던 점

  1. 프론트엔드 개발에서 테스트 코드가 주는 이점은 무엇인지? 왜 중요한지?

    사실 지금까지는 부끄럽지만 유저 주도 테스트를 (…) 주로 해왔던 것 같다. 추가로 프론트엔드 개발에서 테스트 코드가 주는 이점을 아직 잘 모르겠다는 점도 크다.
    기능을 개발하는 경우에도 QA 문서를 작성하고 이를 요청하는 것이 나의 “테스트” 였기에 테스트 코드가 주는 이점을 명확하게 알고 싶었다.

  2. 테스트하기 좋은 코드는 무엇이며, 반드시 모든 경우에 대해 테스트를 해야 하는가?

    “테스트하기 좋은 코드란 과연 무엇인지?”에 대한 의문은 지금도 유효하다.
    지금까지는 “테스트를 위한 변경 없이도 쉽게 테스트 할 수 있는 코드”라고 생각했는데 추가로 고민할 만한 요소가 있을지도 궁금하다.

  3. 시간이 된다면 의존성이 강하게 결합된 코드와 이를 해결하는 과정을 서술해보자.

    구두로 전달하는 것은 뭐든지 한계가 있기 마련이다.
    직접 예시 코드를 작성해보면서 왜 의존성을 분리하는 것이 더 나은 결과를 가져오는지를 살펴보자.


2. 프론트엔드 개발에서 테스트 코드가 주는 이점은 무엇인가?

위 내용은 Toss 의 모닥불 에피소드 3회차 영상을 많이 참고했다.

2-1. 피드백 사이클이 빨라지고, 이로 인한 생산성이 향상된다.

  • 테스트 코드를 짜지 않고 개발을 진행했을 때 가장 불편한 점은, 개발자가 직접 앱을 띄우고 특정 페이지에 들어가 요소를 클릭하며 테스트를 진행해야 한다는 점이다.
  • 즉, 앞에서 이야기했던 Human Driven Development로 개발할 경우 [비즈니스 로직 수정] → [HMR 후 화면 리로딩 대기] → [테스트하고자 하는 요소가 들어간 페이지 이동] → [요소와 상호 작용하며 결과 확인]이라는 사이클이 반복된다.
  • 하지만 테스트 코드를 작성하면 개발자가 확인하고자 하는 동작을 “코드”로서 확인할 수 있고, 즉각적인 테스트 실행을 통해 원하는 결과를 확인할 수 있다는 장점이 존재한다.
  • 이러한 피드백 사이클의 감소는 결과적으로 생산성 향상이라는 결과를 이끌어낸다.

여기서는 고개를 절로 끄덕일 수밖에 없었던 대목이 많았다.

  • 확실히 특정 기능을 테스트하기 위해 여러 페이지를 걸쳐 이동해야 하거나, 복잡한 인터렉션을 기반으로 동작하는 기능을 테스트하는 경우 개발자의 품이 제법 많이 들었다.
  • 기능 하나 수정한 후에 Input 입력하고, 버튼 클릭하고, 스크롤 내려보고… 이 사이클이 당장은 몰라도 여러 개 쌓이면 꽤 많은 시간을 잡아먹고 있다는 생각이 든다.
  • 신뢰 가능한 테스트 코드가 있다면 이러한 과정을 생략해도 무방하기에 직접 화면을 띄우고 테스트해보는 시간이 많이 줄지 않을까 싶다.

2-2. 외부 의존성에 얽힌 기능의 경우 코드 기반 테스트가 더 수월하다.

  • A/B 테스트를 진행하는 경우에 대해서도 매번 개발자가 수동으로 체크하는 것보다는 테스트 코드를 작성함으로써 번거로움을 줄일 수 있다.
  • 예를 들어 외부로부터 특정 값을 받아와서 이를 화면에 그리는 기능인데, 이 외부 의존성이 툭하면 끊어지거나 고장이 자주 난다면? 사람이 매번 이를 테스트하기가 상당히 귀찮고 번거롭다.
  • 외부 API에 의존하거나 그 외 여러 의존성이 얽힌 기능을 개발해야 하는 경우, 이를 적절히 Mocking 함으로써 오롯이 “검증하고 싶은” 영역에만 집중할 수 있다는 장점이 있다.

외부 의존성과 얽힌 기능에 대한 테스트를 작성하는 것은 그간 막막하다고 느꼈다.

  • 복잡한 의존성을 기반으로 만들어진 페이지에 대한 테스트를 작성할 때 어떻게 Mocking을 해야 하는지, 테스트 케이스는 어떻게 작성해야 하는지 등의 고민은 모두에게 있으리라 생각한다. 너무 과한 Mocking은 오히려 테스트의 신뢰성을 떨어뜨린다는 이야기도 있으니 더욱 그렇다.
  • 하지만 Mocking이 마냥 나쁘다는 건 아니다. 내가 확인하고 싶은 영역 밖에 위치한 요소들까지 굳이 검증할 필요는 없다고 생각하고, 이를 Mocking이 잘 해결해주기 때문이다.
  • 또한 테스트 코드를 작성함으로써 내가 확인하고 싶은 영역을 격리함으로써 보다 일관된 결과를 도출할 수 있다. 이러한 사고를 지속적으로 함으로써 개발 실력도 향상할 수 있지 않을까 싶다.
  • 앞으로는 기획에서 넘겨받은 유저 플로우를 기반으로 테스트 코드를 적는 것부터 시작하려는 노력을 조금이라도 기울여야겠다.

2-3. 꼼꼼한 테스트 코드는 안정성 측면에서도 도움이 된다.

  • 라이브러리 버전을 올린다던가, 스펙을 확장한다거나, 리팩토링을 하는 경우 손으로 마이그레이션 테스트를 하는 것은 한계가 많다.
  • 테스트 코드가 잘 짜여져 있다면 기능에 대한 이상이 없음을 빠르게 확인할 수 있고, 보다 신뢰성 있는 업데이트 혹은 배포를 진행할 수 있다는 장점이 크다.
  • 개인적으로 매번 라이브러리 버전을 올릴 때마다 관련 기능들을 수동으로 테스트하지만 그럼에도 종종 에러가 터지는 경우를 몇 번 보았기에 이 부분은 참 중요하다고 느꼈다.

이 내용을 들으면서 개인적으로는 아래와 같은 생각이 들었다.

  • 라이브러리 버전이 올라감에 따라 의도치 않은 에러가 발생한 경우를 정말 많이 맞이했고, 이를 일일이 Human Test로 검증해왔던 입장에서 절절히 공감 가는 내용들뿐이었다.
  • 테스트 코드의 목적 중 하나인 “주어진 기능이 잘 동작하는가”를 잘 지켰다면, 라이브러리 버전이 올라감에 따라 발생하는 Side-Effect 또한 잘 해결할 수 있으리라 생각한다.

2-4. 테스트 코드를 하나의 스펙으로 체크하여 빠르게 파악할 수 있다.

  • 잘 작성된 테스트 코드는 해당 Component 혹은 기능이 사용자와 어떻게 상호작용하는지에 대한 명세를 자연스럽게 취득하게 된다.
  • 특히 업데이트나 변화하는 내용이 많은 스타트업의 경우, 눈물 나지만 문서를 잘 관리하기란 거의 불가능에 가깝다고 생각한다.
  • 하지만 테스트 코드를 작성할 경우 기능이 추가되거나 변경되었을 때 기존의 테스트 코드가 필연적으로 깨지기 때문에 이를 반드시 유지보수해야 한다. 그런 차원에서 테스트 코드는 신뢰할 수 있는 스펙이라고 본다.

새삼 느끼지만 테스트 코드를 하나의 스펙으로 여기려면 정말 코드를 잘 짜야겠다는 생각이 든다.

  • 그만큼 테스트 코드는 팀 차원에서, 더 나아가 전사 차원에서 유지보수해야 하는 요소이기에 개인의 노력이 아닌 단체의 노력이 필수로 들어가야 한다. 이를 어떻게 설득하면 좋을까?
  • 추가로 특정 페이지에서 제공하는 기능이 어떤 플로우로 동작하고, 어떤 목적을 가지고 개발된 페이지인지를 별도의 문서나 명세 없이 테스트 코드로 알 수 있다면 개발자로 하여금 정말 많은 시간이 단축될 것이라 기대한다.

2-5. 단기적인 관점에서는 느릴 수 있어 보여도 장기적으로는 생산성이 더 늘어난다.

  • 초단기적인 생산성으로 보았을 때는 물론 사람이 직접 테스트하는 게 더 편하지만, 실제 배포 시 에러가 발생한다거나 버그가 쌓이는 경우가 점점 많아진다.
  • 하지만 테스트 코드를 초반에 잘 다져둔다면 이러한 고민을 최대한 덜 수 있고 기능 구현에만 오롯이 집중할 수 있다는 장점을 크게 언급한다.
  • 테스트 코드는 초단기적인 생산성은 당장의 개발 사이클을 빠르게 이끌어줄지 몰라도 장기적인 관점에서는 매번 오류를 QA 혹은 실제 서비스를 운영하는 사용자에게 노출해야 하고, 이를 해결하는 데 더 많은 심력을 소모해야 하는 문제가 존재한다.

이 내용을 읽으면서 느낀 점은 테스트 코드의 가장 큰 허들이 “당장의 귀찮음”이라는 것이다.

  • 일단 개인적으로 팩트 폭격을 많이 맞았다. 사실 지금 재직 중인 회사에서는 프론트엔드 개발에 테스트 프로세스가 전혀 없이 QA 과정에서 발견된 버그를 수정하는 과정만이 존재하기 때문이다.
  • 나는 개발을 완벽하게 했다고 생각했지만 실제로는 그렇지 못한 경우가 많았고, 이를 고치고 수정 사항을 반영했는지 두 눈으로 확인하는 시간이 정말 오래 걸렸기 때문이다.

2-6. 그래서 왜 테스트 코드가 필요한데? 에 대한 나의 결론.

위의 내용을 총합해서 요약하자면 테스트 코드를 작성함으로써 아래와 같은 이점을 누리게 된다.

  • 매번 사람이 요구 사항 충족 여부를 위해 화면 내 요소와 직접 상호작용할 필요가 없다.
  • 외부 의존성과 관계없이 내가 검증하고자 하는 기능을 격리하여 테스트가 가능해진다.
  • 테스트 코드를 꼼꼼하게 작성했다면 그 자체로도 하나의 “테크스펙” 문서가 될 수 있다.
  • 라이브러리 업데이트, 기능 수정 등 변경 사항에 대한 체크를 빠르게 파악할 수 있다.

개인적으로 가장 와닿았던 내용은 테스트 코드를 하나의 테크스펙으로 바라보는 관점이었다.

  • 화면 개발을 시작하기 전에 시안을 캡처하고 컴포넌트는 어떻게 쪼갤지, Context가 필요하다면 이는 어떻게 가져갈지를 매번 설계하지만 시간이 지나면서 문서와 실제 구현 내용이 달라지는 경우가 생기고, 이를 잘 챙기기가 쉽지 않았다.
  • 이를 테스트 코드로 풀어내어 내가 구현하고자 하는 기능은 무엇인지, 해당 페이지에서 제공하고자 하는 요소는 무엇인지를 별도의 구두 논의 없이 제공할 수 있는 환경이 주어진다면 얼마나 행복할까? 정말 많은 노력을 기울여야겠다는 생각이 든다.

3. 테스트하기 좋은 코드는 무엇인가? 언제 작성하는 게 좋은가?

3-1. 테스트 코드를 잘 작성하고자 노력하는 것이 곧 개발 실력의 향상이다.

  • 유닛 테스트의 경우 특정 메서드 혹은 컴포넌트에 너무 많은 테스트가 작성되었다면 과도한 책임을 지고 있다 판단하고 이를 적절히 쪼개는 과정을 고민할 수 있다.
  • 통합 테스트 혹은 E2E 테스트를 작성한다면 사용자 시나리오를 조금 더 작은 단위로 쪼개고 유저 측면에서 어떠한 기능을 점검하는 것이 보다 나은지를 사고하게 되는 힘을 가지게 된다.
  • 스펙을 있는 그대로 받아들이지 않고 내가 유저가 되어 페르소나를 더 깊게 상상해보고, 요구사항을 보다 비판적으로 바라봄으로써 추가적인 개선점을 더 잘 찾도록 돕는다.

사내에서 디자인 리뷰를 하거나 기획 리뷰를 하는데, 이를 잘 활용하면 좋을 것 같았다.

  • 리뷰 이후 최종적으로 확정된 기획 문서를 기반으로 내가 직접 유저가 되어 기능을 사용해보는 상상을 많이 해야겠다는 생각이 든다.
  • 당장 개발부터 착수하는 습관은 줄었지만 가끔 기능을 구현할 때 깊은 고민을 하지 않았던 경우가 종종 있었는데, 테스트 코드를 먼저 작성함으로써 이러한 사고력을 늘릴 수 있지 않을까 싶다.

3-2. 언제 주로 테스트 코드를 많이 작성하는가?

  • Funnel Test 혹은 여러 의존성이 겹친 기능의 경우 테스트 코드를 많이 작성한다.
    그 이유는 테스트 케이스가 상당히 복잡하거나 환경이 복잡하여 피드백 사이클이 긴 경우 이를 TDD 기반의 코드로 방어한다고 한다.
  • 유닛 테스트, 컴포넌트 테스트, 페이지 단위의 통합 테스트 등이 있는데 일반적으로 유닛 테스트가 거의 대부분인데 Toss에서는 오히려 페이지 단위의 통합 테스트를 주로 작성한다고 한다.
  • 오히려 테스트하기가 너무 손쉽거나, 당장 하루 이틀 뒤에 사라지는 컴포넌트의 경우, 즉 테스트를 하기가 “쉬운” 경우에는 오히려 테스트 코드를 잘 작성하지 않는다고 한다.
  • 사용자 레벨에서 어떤 의미가 있는지를 생각한다면, 결국 사용자의 동작을 테스트하는 코드가 많고 의존성이 많은 케이스를 더 많이 테스트한다고 한다. “같은 코드여도 생산성을 더 향상시키는 분야에 주로 도입하는구나”라는 걸 느꼈다.

결국은 프론트엔드 개발에서 가장 중요한 것은 사용자라는 것을 다시금 느낀다.

  • 사실 프론트엔드 개발에서 Unit Test를 작성해야 하는 경우는 여러 프로덕트에 공통으로 쓰이는 디자인 시스템이나 하나의 앱에서 고루 쓰이는 공통 컴포넌트, 혹은 유틸 Hook 정도라고 생각한다.
  • 정말 작은 단위 기능까지 꼼꼼하게 테스트하는 것도 좋지만 테스트 코드를 작성하는 시간과 노력 또한 비용임을 잘 생각해야겠다. 그런 관점에서 사용자 관점에서 어떤 테스트가 의미가 있는가?라는 의문은 정말 좋은 포인트라고 생각한다.

3-3. 테스트하기 좋은 코드는 의존성을 직접 호출하지 않는 것이다.

  • 테스트에 가장 큰 걸림돌이 되는 경우는 외부 의존성을 일일이 Mocking 하는 과정에서의 신뢰도 하락과 Mocking 과정에서 불필요하게 증가하는 코드의 양과 복잡도가 있다.
  • 이를 해결하기 위해서는 비즈니스 로직과 이를 구현하기 위해 의존하는 외부 의존성과의 분리를 잘해야 하며, 의존성이 적절하게 분리된 코드는 테스트 코드를 작성하기가 매우 쉽다.

강결합된 두 로직을 분리하는 것이 클린 코드의 시작이라고 말할 만큼 중요하다.

  • 프론트엔드를 개발할 때는 두 의존성 모듈 간의 결합도가 높음에도 이를 간과하는 경우가 많은데, 이렇게 개발하다가 큰 코 다친 경우가 최근 들어 몇 번 있었기에 많은 반성을 하고 있다.

4. 직접 테스트하기 좋은 코드를 작성해보자.

4-1. 의존성이 강하게 결합된 코드의 문제점은?

아래 코드는 브라우저의 sessionStorage를 사용하여 유저 설정을 저장하고 불러오는 로직이다.

// 유저 설정 가져오기
export function getUserSettings(): Record<string, any> {
  const rawSettings = sessionStorage.getItem("userSettings") ?? "{}";
  return JSON.parse(rawSettings);
}

// 유저 설정 저장하기
export function saveUserSettings(settings: Record<string, any>) {
  const settingsString = JSON.stringify(settings);
  sessionStorage.setItem("userSettings", settingsString);
}

이 코드는 간단하지만 다음과 같은 문제가 있다.

  1. sessionStorage에 강하게 결합되어 있어 저장소를 바꾸려면 관련 코드를 전부 수정해야 한다.
  2. 테스트 시 sessionStorage를 반드시 Mocking 해야 하므로 테스트 코드 작성이 번거롭다.
  3. 유저 설정의 조작 로직과 저장소의 의존성이 분리되지 않아 유지보수 비용이 증가한다.

4-2. 이를 기반으로 테스트 코드 작성 시 겪는 고통은 무엇일까?

이를 기반으로 테스트 코드를 작성한 경우는 아래와 같다.

describe("User Settings - Original Implementation", () => {
  let mockSessionStorage: Record<string, string>;

  beforeAll(() => {
    // sessionStorage 모킹
    mockSessionStorage = {};
    Object.defineProperty(window, "sessionStorage", {
      value: {
        getItem: jest.fn((key: string) => mockSessionStorage[key] || null),
        setItem: jest.fn((key: string, value: string) => {
          mockSessionStorage[key] = value;
        }),
        clear: jest.fn(() => {
          mockSessionStorage = {};
        }),
      },
      writable: true,
    });
  });

  beforeEach(() => {
    // 초기화
    mockSessionStorage = {};
    jest.clearAllMocks();
  });

  it("should get user settings", () => {
    const settings = { theme: "dark" };
    sessionStorage.setItem("userSettings", JSON.stringify(settings));

    const result = getUserSettings();
    expect(result).toEqual(settings);
    expect(sessionStorage.getItem).toHaveBeenCalledWith("userSettings");
  });

  it("should return an empty object if no settings are stored", () => {
    const result = getUserSettings();
    expect(result).toEqual({});
    expect(sessionStorage.getItem).toHaveBeenCalledWith("userSettings");
  });

  it("should save user settings", () => {
    const settings = { language: "en" };

    saveUserSettings(settings);

    expect(sessionStorage.setItem).toHaveBeenCalledWith(
      "userSettings",
      JSON.stringify(settings)
    );
    expect(mockSessionStorage["userSettings"]).toBe(JSON.stringify(settings));
  });
});
  • sessionStorage를 직접 모킹하여 테스트하므로, 테스트가 특정 브라우저 API에 강하게 의존하는 구조를 가진다.
  • 만약 저장 방식이 sessionStorage에서 다른 저장소로 변경되면, 모든 테스트 코드도 일괄 수정해야 한다.
  • 각 테스트 전에 mockSessionStorage를 초기화하는 추가 코드가 필요하기에 구조를 복잡하게 만들고 테스트에 대한 신뢰도를 하락시키는 요인이 된다.
  • getUserSettingssaveUserSettings의 핵심 로직과 저장소(sessionStorage)의 동작이 혼합되어 테스트되기에, 검증하고자 하는 로직에 집중하기 어려운 상태가 된다.

4-3. 이제 강결합된 의존성을 분리하여 로직을 작성해보자.

위 문제를 해결하기 위해서는 유저 설정 로직저장소 접근 로직을 분리해야 할 필요가 있다.

  • 이를 위해 먼저 저장소의 인터페이스인 Storage를 별도로 정의한다.
  • Storage 인터페이스를 기반으로 세션 스토리지를 사용하는 SESSION_STORAGE 구현체를 생성하여 로직을 캡슐화한다.
export interface Storage {
  get: (key: string) => string | null;
  set: (key: string, value: string) => void;
}

// Storage Interface를 구현하는 SESSION_STORAGE 제작.
export const SESSION_STORAGE: Storage = {
  get: (key) => sessionStorage.getItem(key),
  set: (key, value) => sessionStorage.setItem(key, value),
};

이제 저장소를 외부에서 주입받도록 기존 로직을 수정해보자.

  • UserSettingManager Class를 생성하고, Storage Interface를 구현하는 구현체를 받도록 한다.
  • 이후 Class 내부에서는 생성자로부터 받은 Storage 구현체를 사용하도록 로직을 분리했다.
export class UserSettingsManager {
  private storage: Storage;

  constructor(storage: Storage) {
    this.storage = storage;
  }

  getUserSettings(): Record<string, any> {
    const rawSettings = this.storage.get("userSettings") ?? "{}";
    return JSON.parse(rawSettings);
  }

  saveUserSettings(settings: Record<string, any>) {
    const settingsString = JSON.stringify(settings);
    this.storage.set("userSettings", settingsString);
  }
}

4-4. 개선된 코드를 기반으로 테스트 코드를 작성해보자.

위 코드를 기반으로 작성된 테스트 코드는 아래와 같다.

import { UserSettingsManager } from "./UserSettingsManager";
import { type Storage } from "./Storage";

class MockStorage implements Storage {
  private store: Record<string, string> = {};

  get(key: string): string | null {
    return this.store[key] || null;
  }

  set(key: string, value: string): void {
    this.store[key] = value;
  }
}

describe("UserSettingsManager", () => {
  let mockStorage: MockStorage;
  let settingsManager: UserSettingsManager;

  beforeEach(() => {
    mockStorage = new MockStorage();
    settingsManager = new UserSettingsManager(mockStorage);
  });

  it("should get user settings", () => {
    mockStorage.set("userSettings", JSON.stringify({ theme: "dark" }));
    const settings = settingsManager.getUserSettings();
    expect(settings).toEqual({ theme: "dark" });
  });

  it("should save user settings", () => {
    const newSettings = { language: "en" };
    settingsManager.saveUserSettings(newSettings);

    expect(mockStorage.get("userSettings")).toBe(JSON.stringify(newSettings));
  });

  it("should update a specific user setting", () => {
    mockStorage.set("userSettings", JSON.stringify({ theme: "light" }));
    settingsManager.updateUserSetting("theme", "dark");

    const settings = settingsManager.getUserSettings();
    expect(settings).toEqual({ theme: "dark" });
  });
});
  • sessionStorage와의 의존성이 분리되었기에 저장소가 변경되어도 핵심 로직을 수정할 필요가 없어졌다.
  • MockStorage를 구현하여 외부 의존성 없이 핵심 로직만 테스트하도록 구조를 수정했기에 테스트 코드의 구조 자체가 간소화되었다.
  • 만약 sessionStorage를 더 이상 사용하지 않더라도 별도의 변경 없이 쉽게 의존성을 교체할 수 있다.

5. 마치며

이번 시간에는 테스트 코드가 과연 중요한지, 어떤 코드가 테스트 코드를 작성하기 쉬운지에 대한 개인적인 고찰을 진행했다. 다음에는 타 오픈소스 라이브러리들의 테스트 코드를 분석하며 각자의 TC 작성 방식에 대한 탐구를 진행하고자 한다.


Ref

profile
항상 왜 이걸 써야하는지가 궁금한 사람

0개의 댓글