실용적인 프론트엔드 테스트 전략 (1)


바야흐로 자바스크립트의 시대이다. 최근 2~3년 동안 자바스크립트는 가장 인기있는 언어 순위 1위를 유지하고 있고, 여전히 빠른 속도로 성장하고 있다. 10여년 전, 웹 표준이라는 개념조차 없을 때부터 프론트엔드 개발을 해 오던 나 같은 개발자에게는 정말 감개무량한 시대가 아닐까 싶다. 당시에는 개발 환경이라는 단어조차 부끄러울만큼 척박한 환경이었는데, 지금 쏟아져나오는 새로운 기술과 개발 도구를 바라보고 있으면 가히 풍요의 시대라고도 할 수 있을 것 같다.

그 중에서도 개인적으로 가장 고무적인 것은 바로 "테스트 방법론과 도구의 발전"이다. 지난 몇 년간 프론트엔드의 테스트는 나에게 닿을 수 없는 먼 하늘과도 같았다. 기존의 어떤 방식을 시도해 보아도 프론트엔드 코드를 테스트하기에는 적합하지 않았고, 테스트 코드를 작성하는 노력에 비해 실제로 얻게 되는 효과는 미미하게만 느껴졌다. 하지만 최근에 등장한 테스트 도구들은 자바스크립트 개발 생태계가 몇 년간의 시행착오를 통해 쌓아온 경험의 성과를 과시하듯, 이러한 고민들에 대한 훌륭한 해결책을 제시해주고 있다.

이 글에서는 이러한 최신 테스트 도구들 중 가장 주목할만 만한 도구인 스토리북Cypress를 소개하고 실제로 사용해 보면서, 실용적인 프론트엔드 테스트를 위해 어떤 전략을 세워야 하는지를 이야기해 보도록 하겠다.

개발자와 테스트

먼저, 이 글에서 말하는 테스트의 의미에 대해 좀 더 확실히 해 둘 필요가 있을 것 같다. 소프트웨어 관점에서 테스트를 정의한다면 "애플리케이션이 요구 사항에 맞게 동작하는지를 검증하는 행위" 정도가 될 것이다. 보통은 개발의 결과물이 최종적으로 사용자에게 전달되기 직전에 QA(Quality Assuarance)라는 과정을 거치는데, 이 과정을 테스트라고 보는 것이 일반적이다. 하지만 실제로 전체 개발 과정을 살펴보면 이러한 검증이 각 단계에서 꾸준히 이루어지는 것을 볼 수 있다. 예를 들어 프로토타입 과정에서 UX를 미리 검증하고 개선하는 일, 서버의 API를 호출하고 기대값을 확인하는 일, 마크업이 끝난 이후에 디자인 시안과 비교해보는 일 등이다. 사실은 이러한 모든 일도 테스트의 범주의 속한다고 볼 수 있다.

실제 개발 과정에서 얼마나 많은 테스트가 이루어지는지를 알아보기 위해 좀 더 구체적인 예를 들어보자. 리액트로 간단한 할 일 관리 애플리케이션을 만들고 있고, 현재 "완료하기" 기능을 추가하는 중이다. 사용자의 클릭 이벤트를 받아서 해당 할 일 항목의 상태를 "완료" 상태로 변경하는 코드를 작성한다. 변경이 잘 되었는지를 확인하기 위해 해당 항목을 클릭한 다음 개발 도구를 열어서 해당 컴포넌트의 상태(state)를 확인한다. "완료" 상태가 된 할 일 항목은 UI에서 체크박스와 취소선 처리가 되어야 하기 때문에 해당 컴포넌트에 CSS를 추가하고, 다시 해당 항목을 클릭한 다음 화면에 디자인 시안과 동일하게 표현되는지를 확인한다. 추후에 "완료" 상태를 컴포넌트가 아닌 리덕스의 스토어에서 관리해야 한다는 것을 알게 되어, 코드를 리팩토링 한 다음 다시 해당 항목을 클릭하고, 화면에 이전과 동일한 결과가 표시되는지를 확인한다.

위의 예제에서 해당 항목을 클릭한 다음 결과를 확인하는 과정은 모두 테스트이다. 사실 개발자는 이미 개발 과정에서 수 많은 테스트를 진행하고 있으며, 엄밀히 말해서 개발자가 소스 코드를 입력/수정하고 저장한 다음 진행하는 일은 대부분 테스트와 관련이 있다고 볼 수 있다.

(앞으로는 의미를 명확하게 하기 위해 "테스트"라는 용어를 "개발자가 작성하는 자동화 테스트"라는 의미로 한정지어 사용하도록 하겠다.)

자동화 테스트의 중요성

문제는 이러한 테스트가 대부분 반복적인 작업이라는 것이다. 위의 간단한 예제에서도 "해당 항목을 클릭" 하고 결과를 "확인" 하는 과정이 계속해서 반복되는 것을 볼 수 있다. 이런 반복된 작업을 매번 수동으로 진행하면 애플리케이션이 복잡해질수록 테스트에 대한 비용이 점점 증가하게 된다. 테스트 비용이 증가하면 테스트를 소홀히 하게 되며, 이는 결국 애플리케이션 품질의 저하로 이어진다. 또한 코드를 수정할 때마다 매번 관련된 기능을 테스트해야 하는 부담 때문에 코드 개선을 망설이게 되고, 이는 코드의 품질 또한 저하시킨다.

이러한 반복된 테스트 작업을 코드로 작성해서 자동화를 하게 되면 테스트에 대한 비용이 줄어들고, 테스트가 누락되거나 잘못 검증하는 등의 실수도 방지할 수 있다. 또한 코드 수정에 대한 두려움이 없어져 적극적으로 리팩토링 등의 코드 개선을 할 수 있게 되고, 이는 곧 코드의 품질의 향상으로 이어지게 된다.

테스트의 기회비용

아마 자동화 테스트를 작성하는 것이 중요하다는 사실에는 모두가 동의할 것이다. 하지만 그렇다고 모든 테스트에 대해 자동화 테스트를 작성해야 하는 것은 아니다. 테스트 코드를 작성하고 유지 보수하는 데는 비용이 들기 때문이다. 그러므로 투입된 비용에 비해 얻는 효과가 적다면 차라리 수동으로 테스트하는 것이 더 낫다. 가끔 테스트 커버리지를 억지로 100%로 맞추려고 한다든가, 중요한 로직이 거의 없는 단순한 코드까지도 모두 테스트를 하려고 하는 등 과한 목표를 설정하는 경우를 볼 수 있는데, 이는 더 중요한 곳에 투자할 수 있는 소중한 비용을 낭비하고 있는 것이다.

심지어 기존에 작성되어 있는 테스트라고 하더라도, 불필요하다고 생각되면 제거하는 게 낫다. 테스트 코드도 애플리케이션이 변화함에 따라 계속해서 관리해 주어야 하기 때문이다. 제품 코드에서 불필요한 코드를 제거하는 것이 좋은 습관이듯이, 테스트 코드에서도 불필요한 코드는 적극적으로 제거하면서 유지 보수 비용을 줄여나가는 것이 중요하다. 심지어 TDD의 창시자인 켄트 백조차도 테스트에 대해 다음과 같은 의견을 밝힌 바 있다.

...나는 테스트 코드가 아니라 제대로 작동하는 제품 코드에 대한 보수를 받는다. 그러므로 나의 원칙은 "특정 수준의 신뢰를 보장하는 최소한의 테스트 코드만 작성한다"이다...(중략)... 딱히 실수를 범할 것 같지 않은 코드는 테스트하지 않는다.

Stack Overflow 답변 중

...완벽하게 모든 것을 다 테스트하려고 하면, 테스트 코드는 필연적으로 오류가 발생하기 쉬운 복잡한 코드가 된다...(중략)... 만약 코드가 너무 간단해서 오류가 날 확률이 거의 없다면, 테스트를 하지 않는 편이 낫다.

Extreme Programming Explained 책 내용 중

좋은 테스트의 조건

테스트의 기회 비용을 가늠해보기 위해서는 좋은 테스트가 무엇인지를 알아야 한다. 어떤 테스트 코드를 작성하느냐에 따라서 작성이나 유지보수에 드는 비용도 다르고, 얻을 수 있는 효과도 다르기 때문이다.

그러면 어떤 테스트가 좋은 테스트일까? 사실 이 질문에 대답하는 것은 참 어렵다. 테스트의 가치는 애플리케이션의 성격, 개발 도구 및 언어, 사용자 환경 등 다양한 요인에 의해 영향을 받기 때문이다. 하지만 비록 완벽한 테스트의 기준을 잡을 수는 없어도, 좋은 테스트가 공통적으로 갖고 있는 특징들에 대해서는 아래 5가지 정도로 정리할 수 있을 것 같다.

1. 실행 속도가 빨라야 한다.

테스트의 실행 속도가 빠르다는 것은 코드를 수정할 때마다 빠른 피드백을 받을 수 있다는 의미이다. 이는 개발 속도를 빠르게 하고, 테스트를 더 자주 실행할 수 있도록 한다. 결과를 보기 위해 수십 분을 기다려야 하는 테스트는 개발 과정에서 거의 무용지물에 가까울 것이다.

2. 내부 구현 변경 시 깨지지 않아야 한다.

이 말은 "인터페이스를 기준으로 테스트를 작성하라"거나 "구현 종속적인 테스트를 작성하지 말라"는 지침과 같은 맥락이라 볼 수 있다. 좀 더 넓은 관점에서는 테스트의 단위를 너무 작게 쪼개는 경우도 해당된다. 작은 리팩토링에도 테스트가 깨진다면 코드를 개선할 때 믿고 의지할 수 없을 뿐 아니라, 오히려 테스트를 수정하는 비용을 발생시켜 코드 개선을 방해하는 결과를 낳게 된다.

3. 버그를 검출할 수 있어야 한다.

달리 표현하면 "잘못된 코드를 검증하는 테스트는 실패해야 한다"라고 할 수 있다. 테스트가 기대하는 결과를 구체적으로 명시하지 않거나 예상 가능한 시나리오를 모두 검증하지 않으면 제품 코드에 있는 버그를 발견하지 못할 수 있다. 또한 모의 객체(Mock)를 과하게 사용하면 의존성이 있는 객체의 동작이 바뀌어도 테스트 코드가 연결 과정에서의 버그를 전혀 검출하지 못하게 된다. 그러므로 테스트 명세는 구체적이어야 하며, 모의 객체의 사용은 최대한 지양하는 것이 좋다.

4. 테스트의 결과가 안정적이어야 한다.

어제 성공했던 테스트가 오늘은 실패하거나, 특정 기기에서 성공했던 테스트가 다른 기기에서는 실패한다면 해당 테스트를 신뢰할 수 없을 것이다. 즉, 테스트는 외부 환경의 영향을 최소화해서 언제 어디서 실행해도 동일한 결과를 보장해야 한다. 이러한 외부 환경은 현재 시간, 현재 기기의 OS, 네트워크 상태 등을 포함하며, 직접 조작할 수 있도록 모의 객체나 별도의 도구를 활용해야만 한다.

5. 의도가 명확히 드러나야 한다.

제품 코드의 가독성이 중요하다는 것은 이제 누구나 인정하는 사실이다. 좋은 품질의 코드는 "기계가 읽기 좋은" 코드가 아닌 "사람이 읽기 좋은" 코드이다. 테스트 코드도 품질을 높이기 위해 제품 코드와 동일한 기준을 갖고 관리해야 한다. 즉, 테스트 코드를 보고 한 눈에 어떤 내용을 테스트하는지를 파악할 수 있어야 한다. 그렇지 않으면 추후에 해당 코드를 수정하거나 제거하기가 어려워져서 관리 비용이 늘어나게 된다. 테스트 준비를 위한 장황한 코드가 반복해서 사용되거나 결과를 검증하는 코드가 불필요하게 복잡하다면 별도의 함수 또는 단언문을 만들어서 추상화시키는 것이 좋다.

테스트 전략의 중요성

위에서 설명한 좋은 테스트의 요소 중 하나를 만족하는 것은 크게 어렵지 않다. 하지만 문제는 각각의 요소들이 서로 상충되는 경우가 있다는 것이다. 예를 들어, 테스트를 아주 작은 단위로 작성하면 비교적 실행 속도가 빠르고 모든 시나리오를 검증하는 것이 쉽다. 대신 작은 단위의 변경에도 테스트가 깨지게 되어 유지 보수 비용이 증가하고, 모의 객체의 사용이 늘어나게 되어 버그를 검출하기가 어려워진다. 또한 테스트 명세를 너무 상세하게 작성하면 더 많은 상황의 버그를 검출할 수 있지만, 테스트 코드가 복잡해져서 의도가 명확히 드러나지 않을 수 있다.

결국 모든 요소를 100% 만족하는 테스트를 작성하는 것은 사실상 불가능하다. 그렇기에 프로젝트, 서비스 모듈 등의 특성에 따라 어떤 것을 포기하고 어떤 것을 얻을 것인가를 잘 판단해서 전략을 세워야 한다. 특히 프론트엔드 코드는 그래픽 사용자 인터페이스(GUI)와 밀접하게 관계되어 있고 사용자의 다양한 실행 환경을 고려해야 하기 때문에 다른 플랫폼에서 사용되는 전략을 그대로 사용할 수 없다. 시각적 요소, 서버와의 통신, 사용자 인터페이스(UI)를 통한 입력 등을 각각 어떻게 테스트 해야 할 지 고민하며 자신만의 전략을 세워야 한다.

테스트 도구의 중요성

프론트엔드 테스트의 전략을 세울 때는 도구의 역할 또한 아주 중요하다. 글을 시작할 때 최신 테스트 도구를 언급한 것은 이러한 도구들이 더 효율적인 전략을 세울 수 있도록 도와주기 때문이다. 예를 들어 기존 E2E(End To End) 도구를 사용한 테스트는 사용자의 관점에서 테스트할 수 있어 내부 구현에 영향을 거의 받지 않는 반면, 테스트 코드가 복잡하고 실행이 느리며 결과가 안정적이지 않다는 단점이 있었다. 하지만 최신 E2E 도구인 Cypress를 사용하면 기존 E2E 테스트의 장점은 유지하면서도 직관적이고 빠르고 안정적인 테스트를 작성할 수 있다. 즉, 테스트 도구의 발전이 더 나은 테스트 전략을 세울 수 있도록 도와주는 것이다.

휴우, 서두가 정말 길었다. 이제야 이 글의 제목이 "실용적인 프론트엔드 테스트 전략"인 이유를 모두 설명한 것 같다. 결국 효율적인 테스트를 작성하기 위해서는 프론트엔드에 맞는 실용적인 전략이 필요하며, 최신 테스트 도구들이 기존보다 더 실용적인 전략을 세울 수 있도록 도와준다고 할 수 있다.

지금부터는 간단한 할 일 관리 애플리케이션을 실제로 테스트해보며 실용적인 테스트 전략이 무엇인지를 알아보도록 하겠다.

간단한 예제: 할 일 관리 애플리케이션

테스트에 사용할 애플리케이션은 잘 알려진 프로젝트인 TodoMVC를 참고했으며, 리액트리덕스의 조합으로 개발하였다. 하지만 특정 라이브러리에 한정된 전략을 다루는 것은 아니기 때문에, 리액트로 작성하지 않은 애플리케이션에도 충분히 적용할 수 있을 것이다. 애플리케이션이 실행된 모습은 다음과 같다.

screen shot 2018-12-26 at 10 52 23 am (그림 1: TodoMVC 애플리케이션 실행 화면)

(원래 TodoMVC는 영속적 데이터 저장을 위해 localStorage를 사용하고 있지만, 이 예제에서는 실제 서버와의 통신을 테스트하기 위해 별도의 로컬 서버를 사용하도록 변경했다.)

프론트엔드 애플리케이션의 구성 요소

서버에 저장된 데이터가 이미 존재한다고 가정하고, 애플리케이션을 처음 실행한 다음 할 일을 새로 추가하는 시나리오를 생각해보자. 내부적인 실행 단계를 고려해 순서대로 나열하면 다음과 같을 것이다.

  1. 애플리케이션이 실행되면 화면에 기본 UI를 보여준다.
  2. API 서버에 "할 일 목록"을 요청한 다음 응답 데이터를 리덕스 스토어에 저장한다.
  3. 저장된 스토어의 값에 따라 할 일 목록을 UI로 표시한다.
  4. 사용자가 인풋 상자를 클릭한 다음 "낮잠 자기"라고 입력한 후 엔터키를 입력한다.
  5. API 서버에 "할 일 추가"를 "낮잠 자기"라는 데이터와 함께 요청한다.
  6. 요청이 성공하면 리덕스 스토어의 할 일 목록에 "낮잠 자기"를 추가한다.
  7. 저장된 스토어의 값에 따라 UI를 갱신한다.

많은 일을 하는 것 같지만, 각각의 단계를 역할에 따라 분류해 보면 크게 두 가지로 분류할 수 있다. 첫 번째는 현재 애플리케이션의 상태를 시각적으로 화면에 표시하는 일로, 1,3,7에 해당한다. 두 번째는 외부 입력(사용자 입력, 서버 통신)을 받아 애플리케이션의 현재 상태를 변경하는 일로, 2,4,5,6에 해당한다. 아마 MVC 패턴에서 주로 사용하는 모델과 뷰의 구분과 비슷하다고 보면 될 것이다.

이러한 분류가 중요한 이유는 각 부분을 테스트할 때 다른 전략을 사용해야 하기 때문이다. 특히 시각적 요소에 대한 테스트는 코드를 이용해 자동화하기가 어렵기 때문에 애플리케이션의 상태 변경을 시각적 요소와 같이 테스트하면 작성과 유지보수에 많은 비용이 든다. 그러므로 애플리케이션을 설계할 때 이 둘을 분리해서 테스트할 수 있는 구조를 만드는 것이 중요하다. 리액트Vue와 같은 최신 프레임워크는 시각적 요소와 상태 관리를 분리할 수 있는 방법을 기본적으로 제공하고 있다.

먼저, 시각적 요소를 어떻게 테스트하는 방법에 대해 살펴보자.

시각적 요소의 테스트

HTML 비교하기

흔히 프론트엔드에서 사용하는 MVC 패턴에서 뷰를 테스트한다고 하면 HTML의 구조를 테스트하는 경우가 많다. 사실상 시각적 표현을 결정하는 요소가 HTML과 CSS인데, CSS에 정의된 스타일은 보통 동적으로 제어되는 일이 드물기 때문이다. 가장 단순한 형태의 검증은 예상되는 HTML 구조를 문자열 형태로 비교하는 것이다. 만약 헤더 영역에 해당하는 컴포넌트를 이런 방식으로 테스트한다면 다음과 같을 것이다.

import React from "react";
import { render } from "react-dom";
import prettyHTML from "diffable-html";
import { Header } from "../components/header";

it("Header component - HTML", () => {
  const el = document.createElement("div");
  render(<Header />, el);

  const outputHTML = prettyHTML(`
    <header class="header">
      <h1>todos</h1>
      <input class="new-todo" placeholder="What needs to be done?" value="" />
    </header>
  `);

  expect(prettyHTML(el.innerHTML)).toBe(outputHTML);
});

위의 예제에서 diffable-html 라이브러리는 HTML 문자열을 비교할 때 다른 부분을 더 파악하기 쉽도록 기존 문자열을 특정한 포맷에 맞게 변경해준다. 이렇게 하면 테스트가 실패했을 때 예상하는 HTML의 구조와 실제 HTML의 구조가 어떻게 다른지를 더 명확하게 비교할 수 있고, innerHTML 속성이 반환하는 값이 브라우저의 내부 구현에 따라 달라지는 문제도 해결해준다. 또한 예상되는 HTML 문자열의 공백이나 개행 문자를 신경쓰지 않아도 되기 때문에 테스트 코드를 더 읽기 좋은 형태로 작성할 수 있다.

스냅샷 테스트 (HTML)

위와 같이 HTML 문자열을 비교할 때 예상되는 HTML 문자열을 개발자가 미리 작성하기는 사실 쉽지 않다. 위의 헤더 예제는 비교적 HTML의 구조가 간단한 편이지만, 보통은 더 크고 복잡한 컴포넌트를 테스트해야 하는 경우가 많기 때문이다. 그래서 이런 방식의 테스트를 작성할 때는 브라우저나 테스트 도구의 콘솔 로그를 이용해서 실제 컴포넌트가 생성한 HTML을 복사하고 테스트 코드에 붙여넣을 때가 많다.

이런 방식은 예상되는 결과를 먼저 작성하는 일반적인 테스트 주도 개발(TDD) 방식과는 다르다. 이 경우 코드를 작성할 때 빠른 피드백을 주어 개발 속도를 향상시키는 기능은 거의 없고, 사실상 회귀 테스트의 역할만을 한다고 볼 수 있다. 또한 코드를 수정할 때마다 매번 콘솔에서 결괏값을 복사해서 코드로 붙여넣는 과정도 번거롭다. 그래서 최근에는 Jest 등의 테스트 도구에서 지원하는 스냅샷 테스트를 사용해서 이런 문제를 해결하고 있다.

스냅샷 테스트는 예상되는 데이터를 직접 코드로 작성하지 않고, 처음 실행된 결과물을 파일로 저장해두는 방식을 사용한다. 그 다음부터는 테스트를 실행할 때마다 기존에 저장된 파일의 내용과 현재 실행된 결과를 비교한다. 위의 방식과 마찬가지로 회귀 테스트의 역할만 하지만, 예상 결과를 직접 코드로 관리해야 하는 번거로움을 없애줄 수 있다. 다음은 기존 HTML 비교 방식을 스냅샷 방식의 테스트로 변경한 코드이다.

import React from "react";
import { render } from "react-dom";
import prettyHTML from "diffable-html";
import { Header } from "../components/header";

it("Header component - Snapshot", () => {
  const el = document.createElement("div");
  render(<Header />, el);

  expect(el.innerHTML).toMatchSnapshot();
});

기존의 테스트 코드보다 훨씬 단순해졌다. 테스트 코드에서 실제 결괏값을 확인할 수 없는 대신, __snapshot__ 폴더 내에 *.js.snap 이라는 파일이 생성되며 이 파일에 다음과 같이 결괏값이 저장되어 있는 것을 볼 수 있다.

exports[`Header component - Snapshot 1`] = `
"
<header class=\\"header\\">
  <h1>
    todos
  </h1>
  <input class=\\"new-todo\\"
         placeholder=\\"What needs to be done?\\"
         value
  >
</header>
"
`;

스냅샷 테스트 (가상 DOM)

사실 리액트의 컴포넌트가 반환하는 것은 실제 HTML이 아닌 리액트 엘리먼트라고 하는 가상의 DOM 구조이다. 실제 HTML의 생성 및 변경은 react-dom 모듈의 역할이기 때문에 엄밀하게 말하면 특정 컴포넌트에 대한 테스트의 범위에는 포함되지 않는다. 그래서 리액트의 컴포넌트를 테스트할 때는 컴포넌트가 반환하는 리액트 엘리먼트의 트리 구조를 테스트하는 경우가 많다.

리액트에서는 컴포넌트의 테스트를 돕기 위해 react-test-renderer 라이브러리를 제공하고 있으며, 이 라이브러리를 사용하면 컴포넌트를 실제로 렌더링할 필요 없이 컴포넌트의 동작을 테스트할 수 있다.

import React from "react";
import renderer from "react-test-renderer";
import { Header } from "../components/header";

it("Header component - Snapshot", () => {
  const tree = renderer.create(<Header />).toJSON();

  expect(tree).toMatchSnapshot();
});

위의 예제에서는 DOM 엘리먼트를 생성해서 직접 렌더링을 하는 코드 대신 react-test-renderertoJSON() 함수를 이용하고 있다. 이 경우 브라우저의 렌더링 엔진이 필요 없기 때문에 JSDom 등의 도움 없이도 Node.js 환경에서 테스트를 실행할 수 있는 장점이 있다.

Jest는 이 외에도 스냅샷 파일의 비교나 갱신을 위한 다양한 편의 기능을 제공한다. 스냅샷 테스트에 대한 좀 더 자세한 내용은 Jest의 문서를 참고하길 바란다.

HTML 구조 비교의 문제점

지금까지 알아본 HTML의 문자열 비교나 스냅샷 테스트 모두 시각적 요소를 테스트하기 위해 HTML의 구조를 비교하는 방식을 사용한다(리액트 엘리먼트의 트리도 결국 HTML 구조라고 볼 수 있다). 하지만 앞서 말한 "좋은 테스트의 조건"을 고려해볼 때, HTML을 테스트하는 것은 다음과 같은 문제를 갖고 있다.

1. 구현 종속족인 테스트

좋은 테스트의 조건 중 하나는 "내부 구현 변경 시 깨지지 않아야 한다"이다. 즉 테스트를 할 때는 결괏값을 "어떻게" 만들어내는지가 아니라 결과물이 "무엇"인지를 검증해야 한다. 하지만 HTML은 엄밀히 말해 시각적 요소의 결과물이 아닌 시각적 요소를 표현하기 위한 내부 구현 방식, 즉 "어떻게"에 가깝다. 시각적 요소의 최종 결과물은 HTML 구조가 아닌 화면에 표시되는 이미지이기 때문이다.

이러한 구현 종속적인 테스트는 작은 변경에도 깨지기 쉬워 관리 비용을 증가시킨다. Header 컴포넌트에 대한 테스트를 예로 들어 보자. 만약 header 태그 대신 div 태그를 사용하거나, new-todo 클래스를 add-todo로 변경하면 실제 결과 이미지에 변화가 없더라도 테스트가 깨지게 된다. 이와 같이 HTML이나 CSS를 리팩토링할 때에도 테스트 코드를 갱신시켜주어야 하며, 이로 인해 개발 속도가 오히려 느려지는 결과를 가져올 수 있다.

2. 의도가 드러나지 않는 테스트

좋은 테스트의 또다른 조건은 "의도가 명확하게 드러나야 한다"이다. 하지만 HTML의 구조는 실제 화면에 그려지는 이미지를 그대로 나타내지 않는다. 비록 CSS까지 함께 테스트한다고 할 지라도, 복잡한 HTML과 CSS의 코드를 보고 실제의 이미지를 머릿속에 정확하게 그려내는 것은 사실상 불가능하다. 테스트를 작성할 때 예상되는 HTML 결괏값을 미리 코드로 작성할 수 없는 이유도 동일하다. 결국 브라우저에 표시된 결과를 실제 눈으로 확인한 다음에야, 지금 생성된 HTML이 실제 원하던 결과라는 것을 확신할 수 있는 것이다.

이런 테스트 코드는 관리가 어렵다. 다른 개발자, 혹은 심지어 테스트 코드를 작성한 본인조차 나중에 코드를 볼 때 어떤 의도를 갖고 있는지를 파악하기가 어렵다. 결국, 테스트가 깨질 때마다 별다른 생각없이 실행 결과를 복사해서 붙여넣거나 스냅샷을 갱신하게 되고, 이는 테스트의 신뢰도와 효과를 떨어뜨리게 된다.

시각적 테스트 자동화의 어려움

결국 시각적 요소는 실제 화면에 표시되는 이미지를 픽셀 단위로 비교하지 않는 이상 효과적인 테스트라고 하기 어렵다. 그렇다면 남은 방법은 실제 뷰 컴포넌트를 브라우저에서 실행한 다음 화면에 표시된 결과를 스크린샷으로 저장해서 예상되는 이미지와 비교하는 방법일 것이다. 디자인 시안을 예상되는 결괏값으로 사용한다면, 매번 코드를 작성할 때마다 스크린샷을 생성해서 디자인 시안과 비교해보고 동일한지를 검증할 수 있다.

하지만 이 방법 역시 간단하지 않다. 디자인 시안은 보통 개발에 필요한 컴포넌트 단위로 정확히 분리되어 있지 않기 때문이다. 또한 디자인 시안에는 발생 가능한 모든 시나리오가 고려되어 있지 않는 경우가 많기 때문에, 컴포넌트가 갖는 모든 상태를 검증하기 위한 기대값으로 사용하기에는 적절하지 않다. 그 외에도 화면 해상도, 브라우저의 고유한 렌더링 방식, 뷰 포트의 크기 및 여백 등의 다양한 조건들을 고려하면서 이미지를 픽셀 단위로 비교하는 것은 기술적으로 많은 어려움이 있다.

허무하게 들릴 수도 있지만, 내 생각에 현재 가장 효율적인 시각적 테스트 도구는 여전히 "개발자의 눈"이다. 비록 시각적 테스트의 문제점을 해결하기 위해 최근에도 많은 도구들이 만들어지고 있지만, 아직 "개발자의 눈"보다 효율적인 해결책을 제시하지는 못하고 있다고 생각한다. 실제 HTML과 CSS를 개발하는 과정을 생각해보자. 개발자는 HTML 태그 하나, CSS의 스타일 하나를 추가하고 수정할 때마다 매번 눈으로 화면을 확인하고, 매 순간 다른 결괏값을 기대한다. 이러한 일련의 과정을 모두 자동화할 수 있는 도구가 나오기까지는 아직 시간이 더 필요할 것이다.

그렇다면 시각적 테스트는 자동화할 수 없는 것일까? 대답은 반반이다. 아직은 완벽한 자동화를 할 수 없지만, UI를 개발하는 방식을 개선할 수는 있다. 그리고 이를 위한 새로운 대안을 제시해주는 도구가 바로 스토리북이다.

(최근에는 Applitools, Chromatic와 같이 브라우저 렌더링 방식에 의한 차이를 이해하고 이미지를 비교해 주는 시각적 테스트 도구들이 많이 발전하고 있다. 하지만 이러한 도구들은 주로 회귀 테스트의 용도로 사용되며, 앞으로 소개할 스토리북과 결합하여 사용할 경우 더 효과적이다. 2부에서 스토리북의 사용법과 함께 이러한 시각적 회귀 테스트 도구들을 소개하도록 하겠다.)

스토리북: UI 개발 환경

공식 홈페이지에서는 스토리북을 "UI 개발 환경"이라고 소개하고 있다. 사실상 테스트 도구라기 보다는 UI개발을 위한 더 나은 환경을 제공해주는 도구에 가깝다. 일종의 컴포넌트 갤러리라고 할 수 있는데, 아래의 그림에서 볼 수 있듯이 애플리케이션에서 사용되는 모든 컴포넌트의 조합을 페이지별로 등록해 놓고 편리하게 눈으로 확인할 수 있도록 네비게이션을 제공한다.

screen shot 2018-12-26 at 10 57 55 am

(그림 2: 스토리북 예제 - 토스트 파일에서 사용되는 컴포넌트의 목록)

그러면 이 도구가 어떻게 시각적 테스트를 도와줄 수 있을까? 글의 앞부분에서 코드를 저장한 후에 결과를 확인하는 모든 과정이 사실상 모두 테스트라고 했던 것을 기억해보자. 컴포넌트의 모든 가능한 조합과 입력값이 미리 저장된 상태된 상태로 등록되어 있으면 이러한 과정에서의 반복된 작업을 상당 부분 자동화할 수 있다.

좀 더 자세한 설명을 위해 파일 업로드 완료 팝업 내의 아이콘 크기를 변경하는 경우를 예로 들어보자. 코드를 변경한 후 결과를 확인하기 위해서는 새로운 파일을 업로드하고 업로드가 완료될 때까지 기다려야 한다. 팝업이 표시되어 결과를 확인했는데, 아이콘이 너무 커서 글자를 덮어버린 것을 확인했다. 코드를 다시 수정한 후에 확인하기 위해 또 다시 파일을 업로드하고 완료할 때까지 기다린다. 다시 확인했더니 이번엔 아이콘이 너무 작은 것 같다. 코드를 수정하고 또 다시 앞의 작업을 반복한다.

이러한 일련의 반복 작업은 시각적 요소에 대한 테스트가 전체 애플리케이션의 상태와 결합되어 있기 때문이다. 파일을 업로드하고 업로드 되기까지 기다리는 과정은 애플리케이션의 상태를 원하는 상태로 만들기 위한 작업이다. 만약 스토리북에 "업로드 완료 팝업" 컴포넌트가 따로 등록되어 있거나, "업로드 완료 팝업이 보여진 상태"가 미리 등록되어 있었다면, 반복적인 상태 조작 과정이 필요 없이 비주얼 요소의 변경만을 확인하면서 코드를 수정할 수 있었을 것이다.

1부를 마치며

지금까지 테스트 자동화의 중요성과 좋은 테스트 코드의 조건, 테스트 전략이 왜 중요한지에 대해서 알아보았다. 그리고 시각적 테스트를 자동화하는 것이 왜 어려운지, 스토리북이 어떤 대안을 제시하고 있는지에 대해서도 설명했다. 물론 프론트엔드 코드를 테스트하는 전략은 다양하며, 이 글에서 제시하는 방향이 꼭 맞지 않을 수도 있다. 이 글은 최대한 "실용적인" 전략이 무엇인가를 탐구해 가는 과정임을 알아주길 바란다.

2부에서는 실제로 스토리북을 사용해보면서 시각적 테스트를 위한 좀 더 상세한 전략을 알아보도록 하겠다.

(아직 다루지 못한 "어플리케이션의 상태 관리"에 대한 테스트는 3부에서 Cypress와 함께 설명할 예정이다.)

김동우2018.12.26
Back to list