Engineering
실용적인 프론트엔드 테스트 전략 (3)
2019년 4월 19일
원문에서 보기 ↗2부에서는 스토리북을 사용해서 시각적 요소에 대한 테스트를 자동화하는 방법에 대해 알아보았다. 기억을 되살리기 위해 우리가 작성하고 있는 할 일 관리 애플리케이션의 실행 단계를 다시 정리해보자.
- ~~애플리케이션이 실행되면 화면에 기본 UI를 보여준다.~~
- API 서버에서 "할 일 목록" 데이터를 받아와서 리덕스 스토어에 저장한다.
- ~~저장된 스토어의 값에 따라 할 일 목록을 UI로 표시한다.~~
- 사용자가 인풋 상자를 클릭한 다음 "낮잠 자기"라고 입력한 후 엔터키를 입력한다.
- 리덕스 스토어의 할 일 목록에 "낮잠 자기"를 추가한다.
- ~~변경된 스토어의 값에 따라 UI를 갱신한다.~~
- 변경된 스토어의 상태를 서버에 전송하여 동기화한다.
이 실행 단계는 "애플리케이션의 상태"를 기준으로 둘로 나눌 수 있다. 먼저 취소선 처리가 된 1,3,6은 현재 애플리케이션의 상태를 화면(UI)에 보여주는 단계이다. 2부에서는 이 단계에 대한 테스트를 자동화하기가 왜 어려운지, 스토리북을 사용하면 이러한 문제를 어떻게 해결할 수 있는지를 자세하게 알아보았다.
이제 남은 2,4,5,7은 모두 애플리케이션의 상태를 관리(조작)하는 단계이다. 이 단계는 크게 사용자의 입력을 받아서 상태를 변경하는 부분(4,5), 그리고 클라이언트의 상태와 서버의 상태를 동기화하는 부분(2,7)으로 나눌 수 있다. 3부에서는 이 단계를 테스트하는 전통적인 방법들을 검토해보고, Cypress를 사용한 테스트가 전통적인 방법들과 비교해 어떤 장점을 갖는지를 살펴보겠다.
(이 글에서 작성한 모든 소스코드는 깃헙 리포지토리에 공개되어 있다. 글에서 대부분의 중요 소스 코드를 보여주고 있지만, 전체 소스 코드가 궁금하다면 리포지토리에서 직접 소스 코드를 확인하길 바란다.)
모듈별 단위 테스트 작성하기
리덕스를 사용한 리액트 애플리케이션은 보통 액션 생성자(action creator), 리듀서(reducer), 컨테이너(container) 컴포넌트, 시각적(presentational) 컴포넌트로 이루어진다. 또한 네트워크 IO, 로컬 스토리지 등의 부수 효과를 다루어야 하는 경우 이를 위한 미들웨어 코드도 추가된다. 이 글의 예제에서는 서버와의 비동기 통신을 처리하기 위해 Redux-Thunk를 사용하기 때문에 부수 효과를 다루는 코드는 액션 생성자 내에서 처리된다.
각각의 모듈은 자신만의 고유한 역할을 갖고 있으며, 함수나 클래스 단위로 잘 분리되어 있기 때문에 각 모듈에 대한 단위 테스트를 작성하기가 매우 쉽다. 리덕스의 공식 튜토리얼에서도 역할에 따라 모듈별 단위 테스트를 작성하는 방법을 잘 설명하고 있으며, 가장 널리 쓰이는 리액트 테스트 라이브러리인 Enzyme 등에서도 이러한 방식을 권장하고 있다.
하지만 1, 2부에서도 설명했듯이 테스트틀 너무 작은 단위로 나누면 모의 객체의 사용이 많아지고 모듈 간의 연결 상태를 검증하기가 어려워진다. 또한 상대적으로 세부 구현에 (2) 대한 의존성이 커지기 때문에 리팩토링 시에 테스트가 깨지기 쉽다. 예를 들어 "할 일 추가" 기능을 테스트하기 위해 각 모듈에 대한 단위 테스트를 작성한다면 다음과 같은 4개의 개별 테스트를 작성하게 될 것이다.
(이 예제에서는 jest와 Enzyme을 사용하지만, 각 도구의 사용법에 대해서는 설명하지 않는다. 자바스크립트는 대부분의 테스팅 프레임워크가 유사한 API를 갖고 있기 때문에 jest를 사용해보지 않은 사람도 코드를 읽는 데는 크게 무리가 없을 것이다. Enzyme에 대한 자세한 사용법은 공식 API 문서를 참고하길 바란다.)
1. 컨테이너 컴포넌트 (Header)
Header 컴포넌트와 스토어를 연결하는 컨테이너 컴포넌트가 액션 생성자인 addTodo() 함수를 스토어와 연결해서 자식 컴포넌트의 Props에 addTodo라는 이름으로 넘겨주는지 검증한다.
import React from 'react';
import Header from '../components/Header';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {shallow} from 'enzyme';
import {configure} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import {addTodo} from '../actions';
configure({adapter: new Adapter()});
jest.mock('../actions', () => ({
addTodo: jest.fn().mockReturnValue({type: 'ADD_TODO'})
}));
const mockStore = configureStore([thunk]);
it('should pass addTodo action to child component', () => {
const store = mockStore({});
const component = shallow(<Header store={store} />).first();
const todoText = 'Hava Lunch';
component.prop('addTodo')(todoText);
expect(addTodo).toBeCalledWith(todoText);
});
2. 시각적 컴포넌트 (Header)
실제 Header 컴포넌트의 input 요소의 값을 변경한 후 엔터키를 입력했을 때 Prop으로 주어진 addTodo() 함수를 실행하는지 검증한다.
import React from 'react';
import {configure} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import {shallow} from 'enzyme';
import {Header} from '../components/Header';
configure({adapter: new Adapter()});
it('should dispatch addTodo when input text', () => {
const addTodo = jest.fn();
const wrapper = shallow(<Header addTodo={addTodo} />);
const todoText = 'Have Lunch';
const input = wrapper.find('input');
input.simulate('change', {target: {value: todoText}});
input.simulate('keydown', {keyCode: 13});
expect(addTodo).toBeCalledWith(todoText);
});
3. (비동기) 액션 생성자
Redux-Thunk를 사용한 비동기 액션 생성자인 addTodo() 함수 실행 시 ADD_TODO 액션을 발생시키고 서버에 동기화 요청을 보내는지 검증한다.
import axios from 'axios';
import {addTodo, ADD_TODO} from '../../src/actions';
it('should dispatch ADD_TODO action and update Server data', () => {
jest.spyOn(axios, 'put');
const todos = [{id: 1}];
const getState = () => ({todos});
const dispatch = jest.fn();
const thunkAction = addTodo('Have Lunch');
thunkAction(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
type: ADD_TODO,
text: 'Have Lunch'
});
expect(axios.put).toHaveBeenCalledWith('/todos', todos);
});
4. 리듀서
todos 리듀서가 ADD_TODO 액션을 받아서 기존 배열에 새로운 할 일 항목을 추가해서 새로운 배열을 반환하는지 검증한다.
import {todos} from '../../src/reducers';
import {ADD_TODO} from '../actions';
it('should handle ADD_TODO', () => {
const prevState = [
{
id: 1,
text: 'Have Breakfast',
completed: true
}
];
const action = {
type: ADD_TODO,
text: 'Have Lunch'
};
expect(todos(prevState, action)).toEqual([
...prevState,
{
id: 2,
text: 'Have Lunch',
completed: false
}
]);
});
이런 방식으로 작성된 테스트 코드는 각 모듈을 분리해서 테스트하기 위해 모의 객체를 많이 사용한다. 이로 인해 불필요한 코드가 많이 늘어나고 실제 모듈간 연결에 대한 검증을 할 수 없게 된다. 예를 들어 Header의 컨테이너 컴포넌트가 자식 컴포넌트에게 전달하는 함수명을 addTodo에서 appendTodo라고 변경하더라도, 실제 Header (시각적) 컴포넌트의 테스트는 실패하지 않는다. 또한 todos 리듀서만 분리해서 테스트했기 때문에 combineReducers를 사용해서 루트 리듀서를 만들 때 todos 리듀서가 누락되더라도 이를 검증하지 못한다.
이런 이유로 이 글에서는 개별 모듈 단위의 작은 단위 테스트 대신, 여러 모듈이 조합된 형태를 하나의 단위로 보고 좀 더 큰 규모의 단위 테스트를 작성하기를 권장한다. 용어를 통일하기 위해 지금부터는 상대적으로 큰 규모의 단위 테스트를 통합 테스트라고 부르도록 하겠다.
(테스트를 구분하는 용어는 사실 명확하게 정의된 것이 아니라서 사람들마다 조금씩 다른 의미로 사용한다. 각 용어에 대한 좀 더 자세한 설명은 마틴 파울러가 설명한 단위 테스트, 통합 테스트 를 참고하길 바란다.)
통합 테스트 작성하기
통합 테스트를 작성하기 위해서는 먼저 단위를 나눌 경계를 결정해야 한다. 예를 들면 액션 생성자와 리듀서, 스토어를 묵어서 테스트할 수도 있고, 스토어와 컨테이너 컴포넌트만 묶어서 테스트할 수도 있다. 나누는 범위에 따라 각각의 장단점이 있으므로 상황에 따라 적절한 범위를 선택하면 된다. 여기서는 개별 모듈 단위의 테스트와의 차이를 명확하게 비교하기 위해 스토어와 라우터를 생성하는 메인 모듈을 제외한 모든 모듈을 묶어서 테스트하도록 하겠다.
사실 2부에서 스토어의 상태를 조작하면서 스토어의 상태에 따른 시각적인 표현을 검증했으니 이미 한 쪽 절반은 검증했다고 할 수 있다. 이번에는 반대로 사용자의 입력값에 따라 스토어를 변경하는 코드를 작성해서 나머지 절반을 채워볼 것이다. 개별 모듈 단위 테스트와 비교하기 위해 이번에도 "할 일 추가"라는 기능에 대한 테스트를 작성해 보겠다. 먼저 완성된 코드부터 살펴보자. (이해를 돕기 위해 코드에 번호와 함께 간단한 주석을 달아 두었다.)
(아래 예제에서는 react-testing-library를 사용해서 통합 테스트를 작성하고 있다. Enzyme과는 다르게 큰 단위의 통합 테스트를 지향하는 API를 제공한다. 공식 문서에서 이 라이브러리의 철학과 사용법을 확인해보면 코드를 이해하는 데 도움이 많이 될 것이다.)
import React from 'react';
import axios from 'axios';
import {render, fireEvent} from 'react-testing-library';
import {StaticRouter, Route} from 'react-router-dom';
import {Provider} from 'react-redux';
import MockAdapter from 'axios-mock-adapter';
import 'jest-dom/extend-expect';
import {createStore} from '../store';
import App from '../components/App';
it('should append todo item when input new todo', async () => {
// (1-1) 스토어 현재 상태 설정하기
const initialState = {
todos: [
{
id: 1,
text: 'Have Breakfast',
completed: true
}
]
};
const store = createStore(initialState);
// (1.2) 서버 동기화 요청 확인을 위한 axios 목킹
jest.spyOn(axios, 'put');
// (1.3) 실제 애플리케이션 렌더링
cosnt {getByTestId} = render(
<Provider store={store}>
<StaticRouter location="/All" context={{}}>
<Route path="/:nowShowing" component={App} />
</StaticRouter>
</Provider>
);
// (2) input에 텍스트 입력 후 엔터 키 입력
const todoInput = getByTestId('todo-input');
fireEvent.change(todoInput, {target: {value: 'Have a Coffee'}});
fireEvent.keyDown(todoInput, {keyCode: 13});
// (3-1) 스토어의 현재 상태 검증하기
expect(store.getState().todos).toEqual([
...initialState.todos,
{
id: 2,
text: 'Have a Coffee',
completed: false
}
]);
// (3-2) 서버에 동기화 요청을 했는지 검증하기
await Promise.resolve();
expect(axios.put.mock.calls[0][1]).toEqual(store.getState().todos);
});
한 가지 기능에 대한 테스트 치고는 코드가 꽤 길어보이지만, 전체 코드를 테스트 준비(1), 실행(2), 검증(3)의 세 단계로 나누어보면 하는 일이 명확하게 드러난다. 먼저 준비 단계에서는 스토어의 상태를 설정하고(1-1) 서버 동기화 요청을 목킹한 다음(1-2) 컴포넌트를 렌더링(1-3)한다. 실행 단계에서는 화면에 그려진 입력 박스에 텍스트를 입력한 후에 엔터키를 입력(2)한다. 그리고 마지막으로 스토어의 현재 상태를 검증하고(3-1), 서버 동기화 요청 여부를 검증(3-2)한다.
개별 모듈별로 작성한 테스트와 통합 테스트 코드를 비교해 보자. 우선 불필요한 목킹 작업이 적기 때문에 전체 코드 량이 감소하고 테스트 코드가 하는 일이 더 명확히 드러나는 것을 알 수 있다. 또한 내부 구현 로직에 대한 의존성이 없기 때문에 컴포넌트의 구조나 Props가 변경되더라도 테스트가 영향을 받지 않는다. 반대로, 부모가 전달하는 Props와 자식이 사용하려는 Props가 다르면 테스트가 실패하게 되어 모듈 간의 연결도 검증할 수 있게 된다.
(세부 구현을 테스트하는 것의 단점에 대해서는 react-testing-library를 만든 Kent.C.Dodds가 Testing Implementation Details라는 글에서 좀 더 자세히 설명하고 있으니, 관심있으신 분들은 꼭 읽어보길 바란다.)
DOM에서 애플리케이션의 상태 확인하기
이제 애플리케이션의 상태를 기준으로, 현재 상태를 시각적으로 나타내는 부분과 사용자의 입력을 받아 현재 상태를 변경하는 부분을 모두 테스트했다. 그럼 이제 모든 부분에 대한 테스트를 작성했다고 할 수 있는걸까? 음, 사실 아직 자동화할 수 있는 테스트가 남아있다. 바로 DOM에 대한 테스트이다.
"잠깐, DOM에 대한 테스트는 자동화하기 어렵기 때문에 눈으로 직접 확인하는 것이 가장 효율적이라고 하지 않았나?" 그렇다. 하지만 사실 이 말은 절반만 맞다고 할 수 있다. 왜냐하면 DOM이 단순히 시각적 요소만을 나타내지는 않기 때문이다. 좀 더 구체적으로 말하면 테스트를 자동화하기 어려운 시각적 요소는 레이아웃, 색상, 폰트, 이미지 등의 요소를 말하며, 이는 DOM 트리의 구조와 스타일(CSS) 정보를 합친 것이다. 하지만 DOM에서 이러한 시각적 요소를 분리해내더라도, 텍스트, DOM의 순서, 특정 DOM 엘리먼트의 상태 등은 여전히 데이터로서 검증할 수 있기 때문에 자동화가 가능하다.
예를 들어 스토어의 todos 배열 상태에 따라 화면에 할 일 목록을 그려주는 기능을 생각해보자. 기존 스토리북 테스트에서는 UI에 대한 모든 부분을 사람이 직접 눈으로 테스트해야 했다. 하지만 엄밀히 살펴보면, 할 일 항목의 DOM 엘리먼트가 순서에 맞게 표시되는지, 각 할 일 항목의 텍스트가 스토어에 저장된 값과 동일한지 등은 데이터를 사용해서 자동화할 수 있는 부분이다. 이런 부분을 자동화하면, 스토리북을 사용해서 직접 눈으로 테스트할 때에는 데이터에 대한 부분을 신경 쓰지 않고 나머지 시각적인 요소에 대해서만 집중해서 검증할 수 있다.
직접 코드를 보면서 확인해보자. 화면의 표시된 할 일 목록 상태를 DOM을 사용하여 검증한다면 다음과 같이 테스트 코드를 작성할 수 있다.
it('should render todo items', () => {
const store = createStore({
todos: [
{
id: 1,
text: 'Have Breakfast',
completed: true
},
{
id: 2,
text: 'Have Lunch',
completed: false
}
]
});
const { getAllByTestId } = render(
<Provider store={store}>
<StaticRouter location='/All' context={{}}>
<Route path="/:nowShowing" component={App} />
</StaticRouter>
</Provider>
);
const todoItems = getAllByTestId('todo-item');
expect(todoItems.length).toBe(2);
expect(todoItems[0]).toHaveTextContent('Have Breakfast');
expect(todoItems[0]).toHaveClass('completed');
expect(todoItems[1]).toHaveTextContent('Have Lunch');
expect(todoItems[1]).not.toHaveClass('completed');
});
이 테스트 코드에서는 할 일 항목의 개수와 순서, 각 항목의 텍스트 및 체크 상태를 검증하고 있다. 이런 테스트를 작성할 때 가장 신경써야 하는 것은 DOM 구조에 대한 의존성을 최소화하는 것이다. 예를 들어 특정 DOM 엘리먼트의 부모가 누구인지, 어떤 태그나 클래스를 사용하는지 등은 모두 애플리케이션의 상태 보다는 시각적인 요소의 표현에 더 관련이 깊다. 테스트를 위해 DOM을 탐색할 때 이런 속성들의 변경에 최대한 영향을 받지 않도록 해야 더 안정적인 테스트를 작성할 수 있으며, 그런 의미에서 태그 선택자, 자식 선택자, 클래스 선택자 등은 사용하지 않는 것이 좋다.
react-testing-library에서는 사용자에게 노출되는 정보(주로 텍스트)를 기반으로 DOM을 탐색하기를 권장하고 있으며, 그것만으로 탐색이 불가능한 경우에는 클래스 대신 data-testid 속성을 사용하기를 권장하고 있다. 이 테스트에서는 개별 항목의 개수와 순서를 확인해야 하기 때문에 텍스트만을 이용해서는 테스트하기 까다롭다. 그래서 모든 할 일 항목의 data-testid 속성에 todo-item 이라는 값을 지정해서 테스트하고 있다.
다만, 각 할 일 항목의 체크된 상태를 확인하는 코드에서 completed 클래스를 갖는지를 확인하고 있는데, 이 경우는 completed 클래스가 시각적인 요소 뿐만 아니라 클래스의 상태를 나타내는 역할도 같이 하고 있기 때문이다. DOM 탐색을 위해 클래스를 사용하는 것은 지양해야 하지만, 이처럼 특정 DOM 엘리먼트의 상태를 검증할 때는 클래스를 사용하는 것이 유용하다. 만약 좀 더 엄격하게 분리하기를 원한다면 시각적 표현을 위한 클래스와 상태를 나타내는 클래스를 구분해서 사용할 수도 있다.
통합 테스트(Jest) vs E2E 테스트 (Cypress)
이제 모든 테스트가 끝났다. 작은 범위의 단위 테스트와 비교해서 큰 범위의 통합 테스트가 갖는 장점도 이제 잘 알게 되었을 것이다. 그런데 문제는, 원래 다루기로 약속했던 Cypress에 대한 내용은 아직 언급조차 되지 않았다는 점이다. 사실 Jest와 react-testing-library는 아주 강력한 도구이며, 이 둘을 잘 사용하면 굳이 Cypress를 사용하지 않더라도 효율적인 테스트를 작성할 수 있다. 그렇다면 Cypress는 어떤 문제를 해결할 수 있는걸까?
첫째로, Jest는 실제 브라우저가 아닌 JSDom을 이용한 가상의 브라우저 환경에서 실행되기 때문에 제약이 있다. 예를 들어, 브라우저의 렌더링 엔진을 사용할 수 없기 때문에 실제 렌더링된 결과인 픽셀 정보를 받아올 수 없고, URL 변경 등을 처리하는 방식이 달라서 라우터의 동작을 테스트하기가 어렵다. 앞서 작성한 코드에서 StaticRouter를 매번 목킹해서 주입하고 있는 이유는 애플리케이션 코드의 BrowserRouter를 그대로 사용할 수 없기 때문이다. Cypress는 실제 브라우저 환경에서 실행되기 때문에 이러한 제약 없이 브라우저의 모든 기능을 사용할 수 있다.
둘째는 개인적으로 가장 중요하다고 여기는 내용인데, 바로 디버깅의 용이성이다. 사실 Jest의 인터렉티브한 CLI 환경은 상당히 강력해서 테스트가 실패했을 때 꽤나 유용한 정보를 제공해준다. 하지만 문제는 실제 화면에 표시된 UI를 볼 수 없다는 점이다. 실제 화면에 표시된 UI를 보지 못한 채 프론트엔드 코드를 작성하거나 디버깅을 하는 것은 마치 암흑속에서 코딩하는 것처럼 괴로운 일이다. 특히 위에서 설명한 것처럼 DOM을 사용해 애플리케이션의 상태를 검증할 때, 테스트가 실패하는 이유를 찾으려면 console.log()를 열심히 찍어보거나 복잡한 HTML 문자열을 눈이 빠져라 들여다볼 수 밖에 없다.
반면, Cypress는 브라우저에서 실행되기 때문에 실제 화면에 표시된 UI를 보면서 코드를 작성하거나 디버깅을 할 수 있다. 뿐만 아니라 테스트를 위해 실행한 모든 명령과 해당 시점의 애플리케이션 상태가 명령 로그에 모두 기록되기 때문에, 마치 녹화된 비디오를 돌려보듯이 쉽게 디버깅을 할 수 있다. 또한 브라우저의 개발자 도구를 그대로 사용할 수 있기 때문에 console.log()에 의지하는 것보다 훨씬 더 인터렉티브한 환경에서 디버깅을 할 수 있다.
이 외에도 Cypress는 사용자의 입력을 시뮬레이션할 수 있는 API를 제공하여 직접 DOM 이벤트를 발생시키는 것보다 훨씬 직관적으로 테스트 코드를 작성할 수 있고, 서버 데이터를 목킹할 수 있는 API를 제공하여 특정 라이브러리에 종속되지 않고도 편리하게 서버 데이터를 목킹할 수 있는 장점이 있다.
E2E 테스트와 Cypress
이처럼 Cypress는 기존 Jest 기반의 통합 테스트보다 더 나은 테스트 환경을 제공한다. 하지만 본격적으로 Cypress를 다루기 전에 먼저 전통적인 E2E 테스트와 Cypress의 차이점에 대해 잠깐 언급하고 넘어가도록 하자.
E2E 테스트는 보통 전체 시스템을 사용자의 관점에서 테스트하는 것을 의미한다. 전통적으로 웹 환경에서의 E2E 테스트는 브라우저를 사용해서 전체 시스템을 테스트하는 것을 의미했으며, 테스트 도구로는 셀레니움 웹드라이버가 가장 많이 사용되었다. 하지만 셀레니움 웹드라이버는 설정이나 테스트 코드 작성이 어렵고 테스트 실행 속도마저 느려서, 개발자보다는 QA 등의 전문 테스트 조직에서 부분적으로 활용하는 경우가 많았다.
반면 Cypress는 기존 E2E 테스트 도구와는 다른 목적을 위해 만들어졌다. 바로 프론트엔드 개발자들이 개발 과정에서 테스트를 작성하는 것을 돕는 것이다. 개발 과정에서 테스트를 할 때는 빠른 피드백을 받을 수 있어야 하기 때문에, Cypress는 브라우저와 통합된 형태의 구조를 사용해서 셀레니움에 비해 훨씬 빠른 속도를 제공해준다. 또한 프론트엔드 테스트를 위하여 전체 시스템을 그대로 사용하기보다는 백엔드 API를 목킹하기를 권장하며, 이를 돕기 위해 다양한 목킹 기능을 제공하고 있다. 위에서 설명한 명령 로그 등의 인터렉티브한 기능을 활용하면 별도의 개발 환경이 필요 없이 Cypress 만으로도 개발을 할 수 있으며, 이는 마치 더 진보된 TDD 개발 환경이라는 느낌을 준다.
(사실 백엔드를 목킹한 상태의 테스트는 E2E 테스트라기 보다는 통합 테스트에 가깝다고 할 수 있다. 하지만 위에서 설명했듯이 용어의 정의는 고정된 것이 아니므로 유연하게 적용될 수 있다. 또한 Cypress는 주된 목적이 통합 테스트일 뿐 전통적인 E2E 테스트 용도로도 충분히 사용할 수 있으므로, 이 글에서는 E2E 테스트 도구로 분류하도록 하겠다.)
그 밖의 핵심적인 특징들은 공식 소개 문서에서 자세히 설명하고 있으며, 기존 E2E 테스트 도구와의 차이점과 그에 따른 장단점도 상세하게 정리되어 있으니 꼭 읽어보길 바란다.
Cypress 시작하기
Cypress는 npm을 사용해서 간단하게 설치할 수 있다.
$ npm install cypress --save-dev
설치가 끝나면 아무런 설정 없이도 다음 명령을 통해 바로 실행할 수 있다.
$ npx cypress open
처음 Cypress를 실행하면 프로젝트 폴더에 cypress 라는 폴더가 생성되고, 해당 폴더에는 처음 사용하는 사용자들을 위한 다양한 샘플 파일이 포함되어 있다. 여기서는 예제 파일을 모두 삭제하고 처음부터 테스트를 작성하기로 한다.
먼저 cypress/integration 폴더에 todo.spec.js 라는 파일을 생성하고 간단한 테스트를 작성해 보자. Cypress의 API는 대부분 mocha와 chai를 기반으로 하며, BDD 스타일의 직관적인 API를 제공하므로 처음 사용하는 사람도 쉽게 적응할 수 있다.
it("true is true", () => {
expect(true).to.equal(true);
});
파일이 생성되면 Cypress의 테스트 러너에서 추가된 파일을 바로 확인할 수 있다. 해당 파일을 클릭하면 Cypress에 의해 확장된 크롬 브라우저가 실행되고, 다음과 같이 테스트 결과를 확인할 수 있다.

Cypress 테스트 작성하기
이제 본격적으로 테스트를 작성해 보자. Cypress 테스트는 보통 별도의 로컬 서버를 실행한 다음 해당 URL에 직접 접속하는 방식으로 작성한다. 이 예제에서는 로컬 개발 서버 뿐만 아니라 API 서버까지 함께 사용하고 있으므로, 테스트를 실행하기 전에 두 서버가 모두 실행되어 있어야 한다.
먼저 커맨드 라인에 node server를 입력하면 8081 포트에 API 서버가 실행된다. 그 다음 커맨드 라인에 npm start 입력하면 3000 포트에 webpack-dev-server가 실행되며, 설정에 따라 API 서버를 프록시로 연결해 준다.
테스트를 작성하기 앞서 간단한 설정을 추가해보자. 설정 파일에 기준 URL을 저장해 놓으면 테스트 코드에 로컬 서버의 전체 URL을 매번 작성할 필요 없이 상대 경로만 사용할 수 있다. 프로젝트 루트에 cypress.json 파일에 다음과 같이 baseUrl을 추가하면 된다.
{
"baseUrl": "http://localhost:3000"
}
이제 스토어의 상태에 따라 화면에 할 일 목록을 그려주는 기능을 테스트로 작성해보자. 서버의 응답값을 목킹하기 위해서는 cy.server()를 실행한 다음 cy.route()을 사용해 원하는 URL과 응답값을 설정하면 된다. 그리고 특정 URL로 접속하기 위해서는 cy.visit()을 사용한다.
it("should render todo items", () => {
const todos = [
{
id: 1,
text: "Have Breakfast",
completed: true
},
{
id: 2,
text: "Have Lunch",
completed: false
}
];
cy.server();
cy.route("/todos", todos); // /todos GET 요청의 응답값을 변경한다.
cy.visit("/All"); // 실제 로컬 서버의 주소에 접속한다.
cy.get("[data-testid=todo-item]").within(items => {
expect(items).to.have.length(2);
expect(items[0]).to.contain("Have Breakfast");
expect(items[0]).to.have.class("completed");
expect(items[1]).to.contain("Have Lunch");
expect(items[1]).not.to.have.class("completed");
});
});
마지막에 DOM의 상태를 검증하는 부분은, API가 약간 달라진 것을 제외하면 앞서 Jest를 사용해 작성한 코드와 사실상 거의 차이가 없다. 하지만 준비 과정에서는 스토어를 생성하고 라우터를 직접 조합하는 코드가 사라지고, 서버 데이터를 목킹한 다음 URL에 직접 접속하는 코드로 변경되었다. 이 과정이 cy.route()와 cy.visit()을 사용해 단 2줄로 작성되었기 때문에 이전보다 코드가 훨씬 단순해진 것을 볼 수 있다. 또한 실제 코드에서 서버로부터 데이터를 받아오는 부분과 브라우저 라우터를 직접 사용하는 부분까지도 모두 테스트되고 있어서 테스트의 커버리지가 더 높아졌다. 이 코드를 저장하면 다음과 같은 화면을 볼 수 있을 것이다.

Cypress의 장점은 테스트 진행 이력과 실행 화면을 동시에 볼 수 있다는 점이다. 위의 그림과 같이 왼쪽(명령 로그)에는 테스트를 실행하기 위한 모든 명령이 결과와 함께 표시되고, 오른쪽에는 실제 애플리케이션이 실행된 결과가 표시된다. 왼쪽에서 각 항목을 클릭하면 해당 명령이 실행될 때의 결과 화면을 확인할 수 있다. 또한 어떤 네트워크 요청이 목킹되었는지, 언제 어떤 네트워크 요청이 발생했는지 등의 정보도 한 눈에 확인할 수 있다.
브라우저 URL에 따른 DOM 상태 테스트
위의 예제에서처럼, Cypress를 사용한 테스트는 스토어의 값을 조작하기 위해 스토어를 직접 생성하는 대신 서버 데이터를 목킹하는 것이 더 편리하다. 라우터도 마찬가지인데, 라우터의 상태를 조작하기 위해 매번 목킹된 라우터를 주입하는 대신 브라우저 URL을 직접 변경하면 된다. 위의 예제를 좀 더 발전시켜서 URL에 따라 할 일 목록이 필터링되어 보여지는지를 검증해보자.
const todos = [
{
id: 1,
text: "Have Breakfast",
completed: true
},
{
id: 2,
text: "Have Lunch",
completed: false
}
];
beforeEach(() => {
cy.server();
cy.route("/todos", todos);
});
describe("Initial Render", () => {
it("All", () => {
cy.visit("/All");
cy.get("[data-testid=todo-item").within(items => {
expect(items).to.have.length(2);
expect(items[0]).to.contain("Have Breakfast");
expect(items[0]).to.have.class("completed");
expect(items[1]).to.contain("Have Lunch");
expect(items[1]).not.to.have.class("completed");
});
});
it("Active", () => {
cy.visit("/Active");
cy.get("[data-testid=todo-item").within(items => {
expect(items).to.have.length(1);
expect(items[0]).to.contain("Have Lunch");
expect(items[0]).not.to.have.class("completed");
});
});
it("Completed", () => {
cy.visit("/Completed");
cy.get("[data-testid=todo-item").within(items => {
expect(items).to.have.length(1);
expect(items[0]).to.contain("Have Breakfast");
expect(items[0]).to.have.class("completed");
});
});
});
반복된 작업을 줄이기 위해 공통 초기화 코드를 beforeEach()로 묶고, describe()를 사용해서 그룹을 지정한 것 외에는 위의 코드에서 크게 달라진 것이 없다. 이처럼 cy.visit() 함수의 인자를 변경해서 접속할 주소를 변경하면, 라우터에 상태에 따른 DOM 상태도 손쉽게 검증할 수 있다.
할 일 추가하기
이번에는 할 일을 추가하는 테스트를 작성해보자. 서버에 동기화 요청을 보내는 값을 검증하기 위해서는 스텁(cy.stub())과 cy.route()의 객체 옵션을 사용해야 한다. cy.route()의 상세 옵션에 대한 설명은 API 문서를 참고하기 바란다.
it("Add Todo", () => {
// 1-1. 서버 동기화 요청을 확인하기 위한 스텁 생성 및 목킹
const reqStub = cy.stub();
cy.route({
method: "PUT",
url: "/todos",
onRequest: reqStub,
status: 200
}).as("sync");
// 1-2. 애플리케이션 서버에 접속
cy.visit("/All");
// 2. 텍스트 입력 후 엔터키 입력
cy.get('[data-testid="todo-input"]').type("Have a Coffee{enter}");
// 3-1. 할 일 목록이 추가되었는지 확인
cy.get('[data-testid="todo-item"]').within(items => {
expect(items).to.have.length(3);
expect(items[2]).to.contain("Have a Coffee");
expect(items[2]).not.to.have.class("completed");
});
// 3-2. 서버에 동기화 요청이 전송되었는지 확인
cy.wait("@sync").then(() => {
expect(reqStub.args[0][0].request.body).to.eql([
...todos,
{
id: 3,
text: "Have a Coffee",
completed: false
}
]);
});
});
통합 테스트와의 비교를 위해 주석에 동일한 번호와 설명을 추가했다. 먼저 준비(1) 과정에서는 서버 요청을 목킹할 때 axios라는 특정 라이브러리에 종속되지 않고 네트워크 요청을 직접 목킹할 수 있는 장점이 있으며, 렌더링을 직접할 필요 없이 서버 URL에 접속하기만 하면 된다는 장점이 있다. 실행(2) 과정에서도 change 이벤트와 keydown 이벤트를 직접 발생시킬 필요 없이 cy.type() 을 사용해서 마치 사용자가 입력하듯이 코드를 작성할 수 있다. 마지막 검증(3) 과정에서는 스토어의 값을 직접 확인하지 않고 DOM의 상태를 이용해서 애플리케이션의 상태를 검증하고 있다.
균형있는 E2E 테스트 작성하기
지금까지 테스트의 범위를 점점 넓혀가며 단위 테스트, 통합 테스트, E2E 테스트를 모두 작성해보았다. 테스트의 범위가 커질수록 불필요한 목킹이 줄어들고 테스트의 커버리지가 높아지는 것을 확인했을 것이다. 보통 단위 테스트가 더 작성하기 쉽고 코드도 간단할 것이라는 생각을 많이 하지만, 실제로는 대부분의 목킹 코드가 사라지기 때문에 E2E 테스트 코드가 간결하고 명확한 경우가 많다. 또한 E2E 테스트는 내부 구현 상태에 거의 영향을 받지 않기 때문에, 기능이 변경되지 않는 한 내부 코드 전체를 변경하더라도 테스트는 여전히 성공하게 된다. 그러므로 잘 만들어진 E2E 테스트가 있으면 큰 규모의 리팩토링도 테스트를 믿고 진행할 수 있게 된다.
하지만 그렇다고 모든 테스트를 목킹 없이 E2E 테스트로만 작성해야 하는 것은 아니다. 경우에 따라서는 내부 스토어의 값을 직접 확인해야 할 수도 있고, 특정 네트워크 요청(웹소켓 등)을 제어하기 위해 실제 통신을 담당하는 모듈을 목킹해야 할 수도 있다. 전체 UI를 테스트하는 대신 특정 컴포넌트의 UI만 테스트하는 게 효율적일 때도 있다. 또한 복잡한 연산 등이 포함되어 다양한 입력값을 확인해야 하는 모듈을 테스트할 때는 단위 테스트가 훨씬 효율적이다.
다행히도, Cypress는 단위 테스트나 통합 테스트를 작성할 수 있는 방법들도 제공하고 있다. 사실 cy.visit()을 사용하지 않고, 직접 특정 모듈을 import 해서 테스트하면 jest를 사용하는 것과 비슷한 방식으로 단위 테스트를 작성할 수 있다. Cypress의 블로그의 테스팅 피라미드 역으로 적용하기, 리덕스 스토어 테스트하기 등의 문서에 이런 접근 방식이 자세히 설명되어 있으니 참고하길 바란다.
(Cypress에서 단위 테스트를 작성할 수는 있지만, "정식"으로 지원한다고 할 수는 없다. 이와 관련한 논의는 깃헙 이슈에서 이루어지고 있으니 참고하길 바란다.)
스토리북과 Cypress
이제 정말 모든 테스트가 끝났다. 마지막으로 정리하자면 이 글에서 권장하고 있는 전략은 다음과 같다. 스토리북은 애플리케이션의 현재 상태를 시각적으로 표현하는 부분에 대한 테스트를 담당하고, Cypress는 사용자의 입력이나 서버 데이터를 받아서 애플리케이션의 현재 상태를 변경하는 부분을 테스트하는 것이다. 그런데, 이렇게 정리하면 둘의 역할이 명확하게 구분되는 것 같지만 실제로는 둘 사이에는 약간의 회색 지대가 존재한다. 스토리북도 어느 정도의 사용자 액션을 처리할 수 있고, Cypress를 사용해도 시각적인 부분을 검증할 수 있기 때문이다. 물론 개인적으로는 둘의 주된 용도가 다르기 때문에 구분해서 사용하는 것이 좋다고 생각하지만, 도구를 두 개나 사용해야 하는 것이 부담스러운 사람들을 위해 이 부분도 간략하게 살펴보겠다.
먼저 스토리북을 살펴보자. 스토리북의 공식 가이드 문서에는 인터렉션 테스트라는 항목이 있다. 이 항목에 설명되어 있듯이 스토리북의 Specs 애드온을 사용하면 개별 스토리 내에서 Jest나 Mocha의 API를 활용해서 테스트를 작성할 수 있다. 또한 Actions 애드온을 사용하면 컴포넌트에서 사용자 입력에 따라 어떤 액션이 발생했는지를 확인할 수도 있어서, 사용자 입력을 간단하게 테스트할 때 활용할 수 있다.
Cypress는 모든 테스트 결과를 시각적으로 확인할 수 있기 때문에 굳이 스토리북이 없어도 시각적인 검증을 할 수 있다. 다만 스토리북처럼 각각의 상태가 잘 한 눈에 정리되어 있지 않기 때문에, 수동으로 검증할 때 사용하기에는 스토리북에 비해 훨씬 번거롭다. 대신 스크린샷 기능을 활용해서 시각적 회귀 테스트를 한다면 더 유용하게 사용할 수 있는데, Cypress는 이미지를 비교해주는 기능을 제공하지는 않기 때문에 직접 구현하거나 cypress-image-snapshot와 같은 플러그인을 함께 사용해야 한다.
(2부에서 소개한 시각적 테스트 도구들은 스토리북 뿐만 아니라 Cypress와의 연동 기능도 제공하고 있다. 관심 있는 분들은 Applitools나 Percy의 문서를 참고하기 바란다)
3부 정리
지금까지 총 3부에 걸쳐 프론트엔드 테스팅 전략에 대해 살펴보았다. 좋은 테스트의 조건, 시각적 테스트 자동화, 스토리북 테스트, 단위 테스트, 통합 테스트, E2E 테스트, Cypress 등의 많은 주제를 오랜 시간에 걸쳐서 설명을 한 것 같은데, 핵심 내용이 잘 전달되었을 지 모르겠다.
흔히 볼 수 있는 테스팅 피라미드 그림을 보면 "단위 테스트" > "통합 테스트" > "E2E 테스트"의 순으로 테스트를 작성하기를 권장한다. 주로 단위 테스트를 작성하되 통합 테스트나 E2E 테스트를 보조적으로 활용하라는 의미이다. 하지만 이 가이드에서는 정반대의 접근 방식을 권장하고 있다. E2E 테스트를 주로 작성하되, 경우에 따라 단위 테스트나 통합 테스트를 보조적으로 활용하는 것이다. 또한 시각적인 요소에 대한 테스트는 자동화에서 배제하고 스토리북을 활용해서 눈으로 직접 확인하길 권장하고 있다.
프론트 엔드의 코드는 단순히 데이터를 다루는 것이 아니라 사용자에게 보여지는 화면을 다루기 때문에 일반적인 테스트 전략과는 다른 전략을 세워야만 한다. 관행처럼 개별 모듈에 대한 단위 테스트만 작성하고 결과를 단순히 데이터로만 검증하려 한다면 오히려 개발 속도를 늦추고 코드의 품질을 낮추는 결과를 낳을 수도 있다.
스토리북과 Cypress라는 훌륭한 도구의 출현에 힘입어 프론트엔드 테스트를 위해 더 효율적인 전략을 세울 수 있는 길이 열렸다. 아직 몇 년 지나지 않았는데도 프론트엔드 코드를 테스트하는 방식에 많은 변화를 주고 있으며, 아직도 많은 잠재력을 갖고 있다고 생각한다. 이 가이드에서 권장하는 방식이 모두에게 최선은 아니겠지만, 더 나은 테스트 전략을 위해서 조금이나마 영감을 주었길 바라며, 새로운 도구를 사용해서 각자 다양한 시도를 해 보길 바란다.