프론트엔드 테스팅

qa

세상은 호락호락하지 않다

내가 작성한 코드가 실제로 그렇게 작동하는지 검증하는 코드를 작성하는 것은 우리가 작성한 코드에 대해 좀 더 자신감을 가지는 데 도움을 줍니다. 뿐만 아니라 협업에서도 이 코드가 어떤 의도를 가지고 만들었는지 파악하는 데 효과적입니다.

하지만 처음 엔지니어로서 업무를 시작할 때는 테스트 코드의 가치에 공감하기 어렵습니다. 분야가 프론트엔드라면 특히 더 그렇습니다. 프론트엔드의 주목적은 뭔가를 브라우저에 표현하는 것이기 때문에 브라우저라는 이름이 갖는 의미처럼 "눈에 보이는 것"이 주된 결과물입니다. 따라서 자연스럽게 브라우저 API에 의존성이 생기고 그 외에도 여러 가지 라이브러리들을 고려해야 합니다. 그러다 보면 이게 코드를 통한 테스트가 가능한가, 너무 당연한 걸 테스트하고 있는 건 아닌가, 들이는 시간에 비해 얻을 수 있는 효과가 작은 게 아닌가 하는 생각이 듭니다.

저는 여러 번의 실패를 거쳐 개발 과정에 프론트엔드 테스트를 도입했는데요, 그 과정에서의 의식의 흐름과 생각의 변화를 공유하려 합니다. 이 글을 읽는 분들의 의사결정에 도움이 되길 바랍니다.


다루고자 하는 것

  • 프론트엔드 테스트를 도입하면 서의 경험과 간단한 예시

다루지 않는 것

  • 테스팅 도구 API의 사용법
  • 테스팅 환경 세팅 과정

테스트에 대한 생각. 그 의식의 흐름

  • 1단계 - 테스트 코드가 왜 필요한지 공감하지 못함

    처음에는 "당연히 잘 되는 걸 왜 테스트하지?"라고 생각합니다. 프론트엔드 분야는 상대적으로 복잡한 로직이 적고 테스트 코드를 작성할 시간에 기능 하나 더 만드는 게 비즈니스 측면에서도 좋은 게 아닌가라는 생각을 합니다.

사실 완전히 틀린 생각은 아닌 것 같습니다. 관점에 따라 (현재 처한 환경에 따라) 테스트 코드를 작성하는 시간을 아껴서 기능을 만들어야 할 때도 분명 존재합니다. 또한 일회성이 강한 코드의 경우(ex. 짧은 기간 사용할 이벤트 페이지) 테스트 코드를 작성하는 것이 효율적이지 않은 경우도 있습니다. 테스트 코드는 유지 보수해야 하는 시간이 길수록 더 많은 시간을 절약해 줍니다.

  • 2단계 - 기능 검증에 귀찮음을 느낌

    개발자는 개밥 먹기를 합니다. 꼼꼼히 개발하고 QA도 거쳐서 적절한 기능을 만듭니다. 이 기능은 잘 동작합니다. 하지만 요구사항은 끊임없이 바뀝니다. 그에 따라 예전에 작성 해놨던 코드를 계속 유지/보수/변경하면서 아무리 꼼꼼한 사람이라도 예외를 고려하지 못하게 됩니다. (저는 심지어 꼼꼼한 성격도 아닙니다!) 또한 다른 사람이 만든 기능에 내가 또 다른 기능을 추가해야 하는 상황이라면 이게 이전 개발자의 의도인지 아니면 개발 과정에서 발견하지 못한 예외를 마침 내가 발견한 건지 확신할 수 없습니다. 따라서 내가 추가 기능을 끼얹은 후 기존에 잘 되던 기능이 여전히 잘 되는지 + 내가 만든 기능이 잘 되는지까지 셀프로 테스트를 해야 합니다. 이 반복되는 과정은 지루하고 귀찮습니다.

  • 3단계 - 테스트 코드를 한번 작성해 볼까

    버그를 가진 코드가 반복해서 프로덕션에 내보내지는 것을 보고 "기능이 잘 동작하는지 한 번만 더 테스트했다면 버그가 없었을 텐데"라는 회고를 합니다. 그리고 그때 마침 애자일이라는 키워드를 알게 되고 익스트림 프로그래밍, 실용주의 프로그래머 등 지속적인 통합이 왜 중요한지, 테스트 코드가 갖는 가치는 무엇인지 이야기하는 책들을 접합니다. 그리고 그것에 설득되어 처음으로 테스팅 환경을 구성하고 테스트를 작성해 봅니다. 하지만 아직 테스트 코드를 어떻게 작성해야 하는지 모르기 때문에 기능 개발보다 테스트 코드 작성에 들이는 시간이 훨씬 많습니다. 이렇게 배보다 배꼽이 더 큰 경험을 하면서 테스트 코드 작성에 대한 회의감이 들고 테스팅 환경만 구성해 놓은 채로 예전처럼 테스트 코드 없이 기능 개발에 정진합니다.

  • 4단계 - 테스트 코드가 깨져서 버그를 발견

    요구사항에 따라 기능을 개발하던 중 작성해놓은 몇 개의 테스트가 실패했다는 알림을 받습니다. 그래서 확인해 보니 내가 작성해 놓은 코드를 수정하면서 이전에 잘 동작했던 기능에 문제가 생겼다는 사실을 발견합니다. 그리고 테스트를 통과하도록 로직을 수정합니다. 이때가 테스트 코드의 가치에 공감하는 모먼트 였던 것 같습니다. 그 이후로는 나름대로 테스트 코드를 계속 작성해나갑니다. 여전히 기능 개발보다 테스트 작성이 오래 걸리는 경우가 많았지만, 테스트 코드를 통해 피드백을 받은 경험을 했기 때문에 시간을 내서 조금씩 테스트를 작성합니다. 또한 개발 기간 산정에 테스트 코드 작성을 포함시키는 연습을 하고, "어떤 테스트를 작성할지"에 대해 고민하며 기존에 작성해놨던 테스트에 케이스를 추가하는 식으로 개선합니다.

  • 5단계 - 반복되는 패턴을 발견

    "이 코드는 어떻게 테스트 코드를 작성할 수 있을까"를 고민하다 보니 테스트해야 할 프론트엔드 코드는 80% 정도 비슷한 패턴을 가진다는 것을 발견합니다. 그 이후로는 이미 작성해 본 흔한 패턴의 기능(ex. UI를 클릭하면 URL이 변경되는 패턴, 1초마다 UI가 특정 UI로 바뀌는 패턴, API 콜 후 화면에 그려주는 패턴...)은 쉽게 테스트 코드를 작성할 수 있습니다. 이제는 어떤 코드를 테스트할지 정하면 작성 자체는 예전만큼 많은 시간을 쓰지 않게 됩니다.

  • 6단계 - 좋은 테스트 코드에 대한 고민

    어떻게 양질의 테스트 코드를 작성해서 효과적인 피드백을 받을 수 있을지를 고민합니다. 그리고 아래와 같이 나름대로 효율적이라고 생각하는 개발 사이클을 만듭니다.

  1. 통과해야 할 껍데기 테스트 케이스를 먼저 정의하면서 구현해야 할 기능을 정리
  2. 기능 구현
  3. 접근성을 향상시키며 테스트 코드를 완성
  4. 기획이 바뀌면 테스트 케이스를 수정
  5. 테스트가 깨짐
  6. 테스트가 성공하도록 기능 수정

아예 테스트 코드 전체를 먼저 작성하는 fully TDD도 시도해 봤지만 프론트엔드는 구현 과정에서 그때그때 대응하며 변경되는 것들이 많아서 초반에 작성해놓은 테스트 코드 중 많은 것들을 변경해야 했습니다. 그래서 테스트 케이스만 정의해놓고 이후 내용을 작성하는 방법을 선택했습니다. (일종의 half TDD... 제가 만들어낸 용어입니다)

이 과정에서 모든 케이스에 대해 테스트를 작성하는 것은 초기 개발 속도를 늦추기 때문에 테스트하기 어려운 부분은 우선 제외합니다. 그리고 테스트를 작성했을 때 효과를 많이 볼 수 있는 복잡한 로직이 들어가는 부분과 맥락을 모르는 다른 사람이 이해하기 어려운 부분 위주로 테스트를 작성합니다.

그 후 기능이 배포된 후 버그가 발생했다면 "이 테스트가 있었다면 사전에 이 버그를 발견할 수 있었을 텐데" 의 관점으로 접근합니다. 또한 좀 더 고 수준의 자동화를 고려합니다.

주석을 테스트 케이스로 대체하는 게 효과적인 경우도 있습니다


저는 위와 같은 흐름으로 테스트 코드에 대한 생각이 변해왔습니다.

물론 다른 생각을 가진 분도 있을 것이라 생각합니다. 테스트가 정말 필요한지에 대해 공감하기 어려운 분, 여러 현실의 벽에 부딪혀서 기능 개발에 올인해야 하는 분, 코드를 통한 테스트보다 본인이 직접 실제 환경에서 눌러보며 테스트하는 걸 좋아하는 꼼꼼한 분 등등 저는 위와 같은 의식의 흐름을 통해 합리적인 여러 의견에 어느 정도 공감하게 되었습니다. 본인이 만들고 있는 제품은 본인이 가장 잘 알고 있고, 그 상황에서 최선의 선택일 테니까요. 저는 현재 제가 처한 환경의 프론트엔드 엔지니어링 과정에서 테스트 코드를 작성하는 게 좀 더 좋은 제품을 만드는 데 있어서 도움이 된다고 생각했기 때문에 버그를 줄이고 코드에 대한 Self confidence를 늘리는 하나의 도구로써 사용했습니다.

테스트 코드가 단순하지만 어려운 이유

describe("ProductButton", () => {
  it("이동 버튼을 클릭하면 상품 페이지로 이동한다", () => {
    const screen = render(<ButtonComponent />);
    const button = screen.getByRole("button", { name: "이동" });

    userEvent.click(button);

    expect(currentURL).toBe("/상품-페이지");
  });
});

자바스크립트에 익숙하지 않아도 이해하기 어렵지 않은 간단한 코드입니다. 테스트 코드를 작성하는 건 이렇게 단순합니다. 테스트할 화면을 만들고, 클릭 시키고, 원하는 주소로 이동했는지 assert 하는 것이 전부입니다. 만약 테스트 코드를 작성하면서 한 번에 이해하기 어려운 코드를 작성해야 한다면 그 테스트 케이스는 더 작게 쪼개는 것을 고민해 봐야 하거나 테스트하고자 하는 코드 자체가 좀 더 좋은 구조를 가질 수 있을 확률이 높습니다. (구조를 바꾸려는 이유가 테스트하기 어렵다는 것이라면 충분히 타당한 이유라고 생각합니다)

하지만 실무에서 처음 테스트 코드를 작성해 보면 생각보다 녹록지 않다는 것을 알게 됩니다. 기능 개발을 위한 코드와 테스트를 위한 코드는 그 주된 목적에 차이가 있습니다. 우리가 기능을 만들 때 작성하는 코드는 동작하기만 하면 코드의 질이 아무리 낮아도 가치를 갖습니다. 무언가 사용자에게 가치를 전달할 수 있기 때문입니다. 하지만 테스트 코드는 다릅니다. 그저 돌아가기만 하는 테스트 코드는 가치가 없습니다. 테스트를 위한 코드의 가치는 무엇을 테스트하기 위해 작성되어 있는지로부터 나옵니다. 그리고 이 사실이 테스트 코드의 작성을 어렵게 만듭니다. 기능 개발과는 다르게 테스트 코드를 작성하는 과정은 "무엇을" 테스트할지를 정하는 과정입니다. 그리고 이것은 실제로 여러 가지 기능을 개발해 본 경험과 고민을 바탕으로 합니다. 이 고민이 우리가 테스트 코드를 작성함에 따라 얻을 수 있는 주된 가치이며 그것을 결정하는 과정이 프론트엔드는 특히 더 까다롭습니다.

무엇을 테스트할지를 결정하면 선택과 집중에 따라 "어떻게"테스트할지는 자연스럽게 정해집니다. 테스트 환경은 샌드박스라서 내가 관심 있는 부분 외에는 모두 원하는 대로 동작했다고 가정하면 되기 때문입니다.

책임을 고려한 테스트 작성

테스트를 작성하는 과정은 결정의 연속입니다. 테스트를 어떻게 작성하는 게 내가 만든 기능을 잘 테스트해 줄 수 있을지를 생각해야 합니다. 아래는 간단한 React 컴포넌트의 예시입니다.

const CouponContainer: React.FC<{ user: User }> = ({ user }) => {
  const { name } = (() => {
    if (user.isNotLoggedIn) {
      return { name: null };
    }
    let name: string | null = "기본 쿠폰";

    if (user.age > 19) {
      name = "20대 쿠폰";
    }

    if (user.height > 190) {
      name = "키 큰 사람 쿠폰";
    }

    return { name };
  })();

  return <Coupon name={name} />;
};

const Coupon: React.FC<{ name: string | null }> = ({ name }) => {
  if (!name) return <div>로그인하면 쿠폰을 받을 수 있어요</div>;

  return (
    <div>
      <div>{name}</div>
    </div>
  );
};

테스트 코드를 작성할 때 가장 먼저 생각해 봐야 할 것은 "컴포넌트의 책임"입니다. CouponContainer컴포넌트의 책임은 쿠폰의 name을 구하는 것, 그리고 그것을 Coupon컴포넌트에 전달하는 것입니다. 그리고 Coupon컴포넌트의 책임은 전달받은 name을 표시하는 것과 name이 없는 경우 "로그인하면 쿠폰을 받을 수 있어요"텍스트를 표시하는 것입니다.

그렇다면 위 컴포넌트에 대한 테스트를 작성할 때 우선 아래와 같은 고민을 해볼 수 있습니다

  • 컴포넌트의 책임별로 테스트를 나누기 위해 테스트도 CouponContainerCoupon을 분리할 것인지
  • CouponContainer에서 Coupon의 책임까지 테스트할 것인지 물론 전자가 더 촘촘한 테스트 방법이긴 하지만 후자는 파일을 하나만 만들어도 되기 때문에 효율적입니다. 저라면 처음 테스트 코드를 작성할 때는 후자를 선택하고 (위 코드가 관련 로직의 전부라는 가정하에) 이후 Coupon컴포넌트를 다른 곳에서 재사용할 일이 생기거나 Coupon컴포넌트의 책임이 더 많아지면 테스트 코드를 분리할 것 같습니다.
describe("CouponContainer", () => {
  it('로그인하지 않은 유저라면 "로그인하면 쿠폰을 받을 수 있어요"를 표시한다', () => {
    const sutUser = { isNotLoggedIn: false, age: null, height: null };
    const screen = render(<CouponContainer user={sutUser} />);

    screen.getByText("로그인하면 쿠폰을 받을 수 있어요");
  });

  it('유저의 age가 19 이상이라면 "20대 쿠폰"을 표시한다', () => {
    const sutUser = { isNotLoggedIn: true, age: 20, height: 180 };
    const screen = render(<CouponContainer user={sutUser} />);

    screen.getByText("20대 쿠폰");
  });

  it('유저의 height가 190 이상이라면 "키 큰 사람 쿠폰"를 표시한다', () => {
    const sutUser = { isNotLoggedIn: true, age: 15, height: 190 };
    const screen = render(<CouponContainer user={sutUser} />);

    screen.getByText("키 큰 사람 쿠폰");
  });
});

React-Testing-Library는 실제 유저에 가까운 테스트를 작성하는 동시에 접근성도 챙길 수 있게 도와주는 강력한 API를 제공합니다

테스트 케이스를 정의하기 위한 팁은 로직에서 경곗값에 대한 테스트 케이스를 고려하는 것입니다. input에 대한 output이 변하는 변곡점이기 때문입니다. 위의 예시에서는 age가 19일 때, height가 190일 때에 대한 케이스입니다.


하지만 테스트 코드를 작성할 때 이 방법만이 정답인 것은 아닙니다. 리팩토링을 거쳐 로직을 분리해서 테스트를 작성하는 게 좋겠다는 생각을 할 수도 있습니다. 그것 또한 좋은 생각입니다.

function getCouponName(user: User) {
  if (user.isNotLoggedIn) {
    return { name: null };
  }
  let name: string | null = "기본 쿠폰";

  if (user.age > 19) {
    name = "20대 쿠폰";
  }

  if (user.height > 190) {
    name = "키 큰 사람 쿠폰";
  }

  return { name };
}

const CouponContainer: React.FC<{ user: User }> = ({ user }) => {
  const { name } = getCouponName(user);

  return <Coupon name={name} />;
};

const Coupon: React.FC<{ name: string | null }> = ({ name }) => {
  if (!name) return <div>로그인하면 쿠폰을 받을 수 있어요</div>;

  return (
    <div>
      <div>{name}</div>
    </div>
  );
};
describe("getCouponName", () => {
  it("로그인하지 않은 유저라면 name: null을 반환한다", () => {
    const sutUser = { isNotLoggedIn: false, age: null, height: null };

    expect(getCouponName(user)).toEqual({ name: null });
  });

  it('유저의 age가 19 이상이라면 name: "20대 쿠폰"을 반환한다', () => {
    const sutUser = { isNotLoggedIn: true, age: 20, height: 180 };

    expect(getCouponName(user)).toEqual({ name: "20대 쿠폰" });
  });

  it('유저의 height가 190 이상이라면 name: "키 큰 사람 쿠폰"을 반환한다', () => {
    const sutUser = { isNotLoggedIn: true, age: 15, height: 190 };

    expect(getCouponName(user)).toEqual({ name: "키 큰 사람 쿠폰" });
  });
});

테스트 코드를 좀 더 컴팩트하게 작성하기 위해 로직을 분리했습니다. CouponContainer의 책임이었던 쿠폰의 name을 구하는 것을 getCouponName로 분리하면서 이에 대한 테스트도 분리했습니다. 우리는 이것을 "비즈니스 로직을 분리"한다고 이야기하기도 합니다. 물론 아직 CouponContainer에는 "Coupon 컴포넌트에 props를 전달하는 것"이라는 책임이 남아있지만 굳이 이것만을 위한 테스트 파일 하나를 생성하는 것은 좀 귀찮게 느껴집니다. 만약 props를 전달하면서 뭔가 복잡한 로직을 포함한다든지 해서 그것을 잘 전달했는지에 대한 확신이 필요해지면 그때 작성해도 늦지 않을 것 같습니다. 이렇게 getCouponName로 분리해서 테스트를 작성하면 이후 getCouponName가 재사용 되었을 때 다시 한번 테스트를 작성하지 않아도 되고, 테스트 코드 또한 가독성과 응집도가 높아진다는 장점이 있지만 CouponContainer를 테스트하는 것이 좀 더 실제 유저가 사용하는 것과 가까운 테스트라고 할 수 있습니다. 유저가 사용하는 것과 가까운 테스트일수록 Self confidence가 높습니다. (물론 getCouponName를 분리하되 테스트만 CouponContainer에 대해 작성하는 방법도 존재합니다. 본인이 적당하다고 생각하는 방법을 선택하세요!)

이 외에 컴포넌트를 어떻게 렌더링 하는지, 상위 컴포넌트에서 전달한 prop가 하위 컴포넌트로 잘 전달되는지 등은 React의 책임입니다. 우리가 테스트하고 싶은 건 CouponContainerCoupon이지 React가 아니기 때문에 이것에 대한 테스트를 작성할 필요는 없어 보입니다.

마치며

테스트 코드를 작성하는 건 결정의 과정의 연속입니다. 그리고 그 결정은 글을 통해서는 익히기 어려운 경험과 고민을 필요로 합니다. 또한 모든 케이스를 고려하지 못한다는 사실과 타협해야 합니다. 이것이 제가 경험한 테스트를 작성하기 어려운 이유였습니다.

테스트를 작성하기 전에 우리가 만든 제품에 가장 도움이 되는 테스트 케이스가 무엇일지 생각해 보는 것이 우선되어야 실제 도움이 되는 테스트를 작성할 수 있습니다. TDD를 할지 말지 와 같은 것들은 이후의 문제입니다. 본인에게 효율적인 프로세스를 만들어나가면 됩니다. 프론트엔드는 "검증해야 하는 부분"에 대한 결정이 더 쉽지 않을 수 있지만, 자신감을 가져도 됩니다. 본인이 본인의 코드의 오너이기 때문입니다.


[참고 자료]

https://kentcdodds.com/blog?q=testing