React

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

Asset Type
File Type
When to use
Last edited time
2022/05/05 12:55
Created by
Reference
프론트엔드 분야 테스트 방법론과 도구의 발전은 고무적이다.
테스트 코드를 작성하는 노력에 비해 실제 효과는 미미했다. 하지만 최근 도구들은 훌륭한 해결책을 제시해주고 있다.
여기서는 스토리북과 Cypress를 소개한다.

개발자와 테스트

소프트웨어 관점에서 테스트는 애플리케이션이 요구 사항에 맞게 동작하는지를 검증하는 행위 정도가 된다.
보통은 개발 결과물이 최종적으로 사용자에게 전달되기 직전에 QA(Quality Assuarance)라는 과정을 거치는데 이를 테스트라고 본다. 이러한 검증은 각 단계에서 꾸준히 이루어진다.
프로토타입 과정에서 UX를 미리 검증하고 개선, 서버의 API를 호출하고 기대값을 확인하는 일, 마크업 이후 디자인 시안과 비교하는 일 등이다.
더 구체적인 예를 들어보자. 리액트 할일 관리 앱을 만들고 현재 “완료하기” 기능을 추가하는 중이다. 사용자 클릭 이벤트를 받아서 해당 할 일 항목의 상태를 “완료" 상태로 변경하는 코드를 작성한다.
변경이 잘 되었는지를 확인하기 위해 해당 항목을 클릭한다음 개발 도구를 열어서 해당 컴포넌트의 상태(state)를 확인한다.
“완료” 상태가 된 할 일 항목은 UI에서 체크박스와 취소선 처리가 되어야 하기 때문에 해당 컴포넌트에 CSS를 추가하고, 다시 해당 항목을 클릭한다음 화면에 디자인 시안과 동일하게 표현되는지 확인한다.
추후에 “완료" 상태를 컴포넌트가 아닌 Redux의 store에서 관리해야 한다는 것을 알게 되어, 코드를 리팩터링 한 다음 다시 해당 항목을 클릭하고, 화면에 이전과 동일한 결과가 표시되는지를 확인한다.
해당 항목을 클릭 하고 결과 확인은 모두 테스트다.
우리는 “테스트"를 “개발자가 작성하는 자동화 테스트"로 한정지어 사용하겠다.

자동화 테스트의 중요성

이러한 테스트는 대부분 반복적인 작업이다.
앱이 복잡해질수록 테스트 비용이 증가하고 테스트에 소홀해지며 품질 저하로 이어진다.
코드를 수정할 때마다 매번 관련된 기능을 테스트해야 하는 부담 때문에 코드 개선을 망설이게 되어 품질 저하로 이어진다.
반복된 테스트 작업은 자동화하게 되면 테스트 비용이 줄어들어 테스트가 누락되거나 잘못 검증하는 등 실수를 방지할 수 있다.
코드 수정에 대한 두려움이 줄어들어 리팩터링을 할 수 있어 품질 향상으로 이어진다.

테스트의 기회 비용

모든 테스트에 대해서 자동화 테스트를 작성해야 하는 것은 아니다. 테스트를 유지보수하는 비용이 들기 때문이다.
투입된 비용에 비해 얻는 효과가 적다면 차라리 수동으로 테스트하는 게 낫다.
가끔 테스트 커버리지를 억지로 100%로 맞추려고 한다거나 중요한 로직이 없는 단순한 코드까지 모두 테스트하려고 하는 등 과한 목표를 설정하는 경우를 볼 수 있는데, 이는 비용 낭비다.
기존 작성된 테스트라 할지라도 불필요하면 제거한다. TDD의 창시자인 켄트 백도 아래와 같은 의견을 냈다.
...나는 테스트 코드가 아니라 제대로 작동하는 제품 코드에 대한 보수를 받는다. 그러므로 나의 원칙은 "특정 수준의 신뢰를 보장하는 최소한의 테스트 코드만 작성한다"이다...(중략)... 딱히 실수를 범할 것 같지 않은 코드는 테스트하지 않는다.
...완벽하게 모든 것을 다 테스트하려고 하면, 테스트 코드는 필연적으로 오류가 발생하기 쉬운 복잡한 코드가 된다...(중략)... 만약 코드가 너무 간단해서 오류가 날 확률이 거의 없다면, 테스트를 하지 않는 편이 낫다.

좋은 테스트의 조건

테스트 기회 비용을 가늠하기 위해 좋은 테스트가 무엇인지 알아야 한다.
테스트의 가치는 앱의 성격, 개발 도구 및 언어, 사용자 환경 등 다양한 요인에 영향을 받는다.

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

코드를 수정할 때마다 빠른 피드백을 받을 수 있다. 이는 개발 속도를 빠르게 하고, 테스트를 더 자주할 수 있게 한다.

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

“인터페이스를 기준으로 테스트를 작성하라"거나 “구현 종속적인 테스트를 작성하지 말라"는 지침과 같은 맥락이다.
더 넓은 관점에서는 테스트의 단위를 너무 작게 쪼개는 경우도 해당된다.
작은 리팩터링에도 깨진다면 수정 비용이 발생되어 코드 개선을 방해한다.

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

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

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

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

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

좋은 품질의 코드는 사람이 읽기 좋은 코드다.
테스트 준비를 위한 장황한 코드가 반복해서 사용되거나 결과를 검증하는 코드가 불필요하게 복잡하다면 별도의 함수 또는 단언문을 만들어서 추상화시키는 것이 좋다.

테스트 전략의 중요성

각각의 요소가 상충되는 경우가 있다.
예를 들어, 테스트를 아주 작은 단위로 작성하면 비교적 실행 속도가 빠르고 모든 시나리오를 검증하는 것이 쉽다.
대신 작은 단위의 변경에도 테스트가 깨지게 되어 유지 보수 비용이 증가하고, 모의 객체 사용이 늘어나서 버그를 검출하기 힘들다.
테스트 명세를 너무 상세하게 작성하면 더 많은 상황의 버그를 검출할 수 있지만, 테스트 코드가 복잡해져서 의도가 명확히 드러나지 않을 수 있다.
프론트엔드 코드는 GUI와 밀접하게 관계되어 있고 사용자의 다양한 실행 환경을 고려해야 하기 때문에 다른 플랫폼에서 사용되는 전략을 그대로 사용할 수 없다. 시각적 요소, 서버와의 통신, UI를 통한 입력 등 각각 어떻게 테스트해야 할지 고민하여 자신만의 전략을 세워야 한다.

테스트 도구의 중요성

기존 E2E 도구를 사용한 테스트는 사용자의 관점에서 테스트할 수 있어 내부 구현에 영향을 거의 받지 않는 반면, 테스트 코드가 복잡하고 실행이 느리며 결과가 안정적이지 않다는 단점이 있었다.
최신 E2E 도구인 Cypress를 사용하면 기존 장점을 유지하면서 직관적이고 빠르고 안정적인 테스트를 작성할 수 있다.
지금부터는 간단한 할일 관리 앱을 실제로 테스트하며 실용적인 테스트 전략이 무엇인지 알아본다.

간단한 예제: 할 일 관리 앱

잘 알려진 프로젝트인 TodoMVC를 참고했고 리액트와 리덕스 조합으로 개발했다.
특정 라이브러리에 한정된 전략을 다루는 것은 아니다.
(원래 영속적 데이터 저장을 위해 localStorage를 사용하고 있지만 여기서는 서버와의 통신을 테스트하기 위해 별도의 로컬 서버를 사용하도록 변경한다.)

프론트엔드 앱의 구성 요소

서버에 저장된 데이터가 이미 존재한다고 가정하고, 앱을 처음 실행한 다음 할 일을 새로 추가하는 시나리오를 생각해보자. 내부적인 실행 단계를 고려해 순서대로 나열하면 다음과 같다.
1.
애플리케이션이 실행되면 화면에 기본 UI를 보여준다.
2.
API 서버에 “할 일 목록"을 요청한 다음 응답 데이터를 Redux store에 저장한다.
3.
저장된 스토어의 값에 따라 할 일 목록을 UI로 표시한다.
4.
사용자가 인풋 상자를 클릭한 다음 “낮잠 자기"라고 입력한 후 엔터키를 입력한다.
5.
API 서버를 “할일 추가"를 “낮잠 자기"라는 데이터와 함께 요청한다.
6.
요청이 성공하면 Redux store의 할 일 목록에 “낮잠 자기"를 추가한다.
7.
저장된 스토어의 값에 따라 UI를 갱신한다.
첫 번째는 현재 애플리케이션의 상태를 시각적으로 화면에 표시하는 일로, 1,3,7
두 번째는 외부 입력(사용자 입력, 서버 통신)을 받아 애플리케이션의 현재 상태를 변경하는 일로, 2,4,5,6
MVC 패턴에서 주로 사용하는 모델과 뷰의 구분과 비슷하다

시각적 요소의 테스트

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); });
TypeScript
복사

스냅샷 테스트 (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(); });
TypeScript
복사
테스트 코드에서 실제 결괏값을 확인할 수 없는 대신, __snapshot__ 폴더 내 파일이 생성된다.

스냅샷 테스트 (가상 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-renderer의 toJSON() 함수를 이용하고 있다. 이 경우 브라우저의 렌더링 엔진이 필요 없기 때문에 JSDom 등의 도움 없이도 Node.js 환경에서 테스트를 실행할 수 있는 장점이 있다.

HTML 구조 비교의 문제점

리액트 엘리먼트의 트리도 결국 HTML 구조

1. 구현 종속적인 테스트

좋은 테스트의 조건 중 하나는 "내부 구현 변경 시 깨지지 않아야 한다"이다. 즉 테스트를 할 때는 결괏값을 "어떻게" 만들어내는지가 아니라 결과물이 "무엇"인지를 검증해야 한다.
최종 결과물은 HTML 구조가 아닌 화면에 표시되는 이미지
만약 header 태그 대신 div태그를 사용하거나, new-todo클래스를 add-todo로 변경하면 실제 결과 이미지에 변화가 없더라도 테스트가 깨지게 된다.
이와 같이 HTML이나 CSS를 리팩토링할 때에도 테스트 코드를 갱신시켜주어야 하며, 이로 인해 개발 속도가 오히려 느려지는 결과를 가져올 수 있다.

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

좋은 테스트의 또다른 조건은 "의도가 명확하게 드러나야 한다"이다. 하지만 HTML의 구조는 실제 화면에 그려지는 이미지를 그대로 나타내지 않는다. 비록 CSS까지 함께 테스트한다고 할 지라도, 복잡한 HTML과 CSS의 코드를 보고 실제의 이미지를 머릿속에 정확하게 그려내는 것은 사실상 불가능하다.
결국 브라우저에 표시된 결과를 실제 눈으로 확인한 다음에야, 지금 생성된 HTML이 실제 원하던 결과라는 것을 확신할 수 있는 것
이런 테스트 코드는 관리가 어렵다. 다른 개발자, 혹은 심지어 테스트 코드를 작성한 본인조차 나중에 코드를 볼 때 어떤 의도를 갖고 있는지를 파악하기가 어렵다.

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

결국 시각적 요소는 실제 화면에 표시되는 이미지를 픽셀 단위로 비교하지 않는 이상 효과적인 테스트라고 하기 어렵다. 그렇다면 남은 방법은 실제 뷰 컴포넌트를 브라우저에서 실행한 다음 화면에 표시된 결과를 스크린샷으로 저장해서 예상되는 이미지와 비교하는 방법일 것이다. 디자인 시안을 예상되는 결괏값으로 사용한다면, 매번 코드를 작성할 때마다 스크린샷을 생성해서 디자인 시안과 비교해보고 동일한지를 검증할 수 있다.
디자인 시안에는 발생 가능한 모든 시나리오가 고려되어 있지 않는 경우가 많기 때문에, 컴포넌트가 갖는 모든 상태를 검증하기 위한 기대값으로 사용하기에는 적절하지 않다. 그 외에도 화면 해상도, 브라우저의 고유한 렌더링 방식, 뷰 포트의 크기 및 여백 등의 다양한 조건들을 고려하면서 이미지를 픽셀 단위로 비교하는 것은 기술적으로 많은 어려움이 있다.
현재 가장 효율적인 시각적 테스트 도구는 여전히 "개발자의 눈"이다. 비록 시각적 테스트의 문제점을 해결하기 위해 최근에도 많은 도구들이 만들어지고 있지만, 아직 "개발자의 눈"보다 효율적인 해결책을 제시하지는 못하고 있다고 생각한다. 실제 HTML과 CSS를 개발하는 과정을 생각해보자. 개발자는 HTML 태그 하나, CSS의 스타일 하나를 추가하고 수정할 때마다 매번 눈으로 화면을 확인하고, 매 순간 다른 결괏값을 기대한다. 이러한 일련의 과정을 모두 자동화할 수 있는 도구가 나오기까지는 아직 시간이 더 필요할 것이다.
시각적 테스트는 자동화할 수 없는 것일까? 대답은 반반이다. 아직은 완벽한 자동화를 할 수 없지만, UI를 개발하는 방식을 개선할 수는 있다. 그리고 이를 위한 새로운 대안을 제시해주는 도구가 바로 스토리북이다.
(최근에는 ApplitoolsChromatic와 같이 브라우저 렌더링 방식에 의한 차이를 이해하고 이미지를 비교해 주는 시각적 테스트 도구들이 많이 발전하고 있다. 하지만 이러한 도구들은 주로 회귀 테스트의 용도로 사용되며, 앞으로 소개할 스토리북과 결합하여 사용할 경우 더 효과적이다. 2부에서 스토리북의 사용법과 함께 이러한 시각적 회귀 테스트 도구들을 소개하도록 하겠다.)
실용적인 프론트엔드 테스트 전략 (2) 스토리북 시각적 테스트 자동화