실용적인 프론트엔드 테스트 전략 (3) : TOAST Meetup
스토리북을 사용해서 시각적 요소에 대한 테스트를 자동화하는 방법에 대해 알아보았다. 기억을 되살리기 위해 우리가 작성하고 있는 할 일 관리 애플리케이션의 실행 단계를 다시 정리해보자.
meetup.toast.com
2부에서 스토리북으로 뷰에 해당하는 테스트를 진행했다.
이제 애플리케이션 상태를 관리(조작) 하는 단계를 Cypress를 사용하여 테스트해본다.
통합 테스트(Jest) vs E2E 테스트 (Cypress)
1. Jest는 실제 브라우저가 아닌 JSDom을 이용한 가상의 브라우저 환경에서 실행되기 때문에 제약이 있다. 예를들어, 브라우저의 렌더링 엔진을 사용할 수 없기 때문에 실제 렌더링 된 결과인 픽셀 정보를 받아 올 수 없고, URL 변경 등을 처리하는 방식이 달라서 라우터의 동작을 테스트하기가 어렵다. Cypress는 실제 브라우저 환경에서 실행되기 때문에 이러한 제약이 없다.
2. 디버깅의 용이성. Jest의 인터렉티브한 CLI 환경은 상당히 강력해서 테스트가 실패했을 때 꽤나 유용한 정보를 제공한다. 하지만 문제는 실제 화면에 표시된 UI를 볼 수 없다는 점이다. 실제 화면에 표시된 UI를 보지 못한 채 프론트엔드 코드를 작성하거나 디버깅을 하는 것은 마치 암흑속에서 코딩하는 것처럼 괴로운 일이다.
반면 Cypress는 실제 화면에 표시된 UI를 보면서 코드를 작성하거나 디버깅을 할 수 있다. 뿐만 아니라 테스트를 위해 실행한 모든 명령과 해당 시점의 애플리케이션 상태가 명령 로그에 모두 기록되기 때문에, 마치 녹화된 비디오를 돌려보듯 쉽게 디버깅을 할 수 있다. 브라우저의 개발자 도구를 그대로 사용할 수 있기 때문에 console.log()에 의지하는 것 보다 훨씬 더 인터렉티브한 환경에서 디버깅 할 수 있다. 이 외에도 사용자의 입력을 시뮬레이션할 수 있는 api를 제공하여 직접 dom이벤트를 발생시키는 것보다 직관적으로 테스트 코드를 작성할 수 있고, 서버 데이터를 목킹할 수 있는 api를 제공하여 특정 라이브러리에 종속되지 않고도 편리하게 서버 데이터를 목킹할 수 있는 장점이 있다.
E2E 테스트와 Cypress의 차이
- E2E 테스트는 보통 전체 시스템을 사용자의 관점에서 테스트하는 것을 의미한다. 전통적으로 웹 환경에서의 E2E 테스트는 브라우저를 사용해서 전체 시스템을 테스트 하는 것을 의미했으며, 테스트 도구로는 셀레니움 웹 드라이버가 많이 사용되었다. 하지만 셀레니움 웹드라이버는 설정이나 테스트 코드 작성이 어렵고 테스트 실행 속도마저 느려서, 개발자보다 QA등의 전문 테스트 조직에서 부분적으로 활용하는 경우가 많았다.
- Cypress는 기존 E2E 테스트 도구와는 다른 목적을 위해 만들어졌다. 바로 프론트엔드 개발자들이 개발 과정에서 테스트를 작성하는 것을 돕는 것이다. 개발 과정에서 테스트를 할 때는 빠른 피드백을 받을 수 있어야 하기 때문에, Cypress는 브라우저와 통합된 형태의 구조를 사용해서 셀레니움에 비해 훨씬 빠른 속도를 제공해준다. 또한 프론트엔드 테스트를 위해 전체 시스템 그대로 사용하기보다는 백엔드 API를 목킹하기를 권장하며 이를 돕기위해 다양한 목킹기능도 제공한다.
Cypress 시작하기
$ npm install cypress --save-dev
설치
npm cypress
실행
cypress를 실행하면 프로젝트 폴더에 cypress라는 폴더가 생성되고, 해당 폴더에는 처음 사용하는 사용자들을 위한 샘플파일이 포함된다. 예제를 모두 삭제하고 처음부터 테스트를 작성하자
cypress/integration 폴더에 todo.spec.js 파일 생성 후 간단한테스트를 실행하자. Cypress api는 대부분 mocha와 chai를 기반으로 하며, BDD 스타일의 직관적인 api를 제공하므로 쉽다.
it("true is true", () => {
expect(true).to.equal(true);
});
결과

Cypress 테스트 작성하기
Cypress 테스트는 별도의 로컬 서버를 실행한 다음 해당 URL에 직접 접속하는 방식으로 작성한다. 이 예제에서는 로컬 개발 서버뿐만 아니라 API 서버까지 함께 사용하고 있으므로, 테스트를 실행하기 전에 두 서버가 모두 실행되어 있어야 한다.
node server로 8081포트 api서벌르 실행하고, npm start를 입력해서 웹팩 dev 서버를 실행하자
cypress의 설정 파일에 기준 URL을 저장해두자
cypress.json파일에 추가
{
"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()의 객체 옵션을 사용해야한다.
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의 상태를 이용해서 애플리케이션의 상태를 검증하고 있다.
실행 3 검증 과성에서는 스토어의 값을 직접 확인하지 않고 DOM의 상태를 이용해서 애플리케이션의 상태를 검증한다.
균형있는 E2E 테스트 작성하기
테스트의 범위가 커질수록 불필요한 목킹이 줄어들고 테스트의 커버리지가 높아지는 것을 확인할 수 있다. 보통 단위테스트가 더 작성하기 쉽고 코드도 간단하다고 생각을 많이 하지만, 실제로는 대부분의 목킹 코드가 사라지기 때문에 E2E 테스트 코드가 간결하고 명확한 경우가 많다. 또한 E2E 테스트는 내부 구현에 거의 영향을 받지 않기 때문에, 기능이 변경되지 않는 한 내부 코드 전체를 변경하더라도 테스트는 여전히 성공하게 된다. 그러므로 잘 만들어진 E2E 테스트가 있으면 큰 규모의 리팩토링도 테스트를 믿고 진행할 수 있게 된다.
하지만 모든 테스트를 목킹없이 E2E 테스트로만 작성해야 하는 것은 아니다. 경우에 따라서는 내부 스토어의 값을 직접 확인해야 할 수도 있고, 특정 네트워크 요청을 제어하기 위해 실제 통신을 담당하는 모듈을 목킹해야 할 수도 있다. 전체 UI를 테스트하는 대신 특정 컴포넌트의 UI만을 테스트하는 게 효율적일 때도 있다. 또한 복잡한 연산등이 포함되어 다양한 입력값을 확인해야 하는 모듈을 테스트할 때는 단위 테스트가 훨씬 효율적이다.
다행히도 cypress는 단위테스트나 통합 테스트를 작성할 수 있는 방법들도 제공하고 있다.
스토리북과 Cypress
마지막으로 정리하자면 이 글에서 권장하고 있는 전략은 다음과 같다. 스토리북은 애플리케이션의 현재 상태를 시각적으로 표현하는 부분에 대한 테스트를 담당하고, Cypress는 사용자의 입력이나 서버 데이터를 받아서 애플리케이션의 현재 상태를 변경하는 부분을 텧스트하는 것이다. 그런데 이렇게 정리하면 둘의 역할이 명확하게 구분되는 것 같지만 실제로는 둘 사이에는 약간의 회색 지대가 존재한다. 스토리북도 어느정도의 사용자 액션을 처리할 수 있고, Cypress도 시각적인 부분을 검증할 수 있다. 물론 둘의 주된 용도가 다르기 때문에 구분해서 사용하는것이 좋다고 생각하지만, 도구를 두 개나 사용해야 하는 것이 부담스러운 사람들을 위해 이 부분도 간략하게 살펴보겠다.
먼저 스토리북은 가이드 문서에 인터렉션 테스트(storybook.js.org/docs/react/workflows/interaction-testing/) 라는 항목이 있다. 이 항목의 설명과 같이 스토리북의 Specs 애드온을 사용하면 개별 스토리 내에서 Jest나 Mocha의 API를 활용해서 테스트를 작성할 수 있다. 또한 Actions 애드온을 사용하면 컴포넌트에서 사용자 입력에 따라 어떤 액션이 발생했는지를 확인할 수도 있어서, 사용자 입력을 간단하게 테스트할 때 활용할 수 있다.
Cypress는 모든 테스트 결과를 시각적으로 확인할 수 있기 때문에 굳이 스토리북이 없어도 시각적인 검증을 할 수 있다. 다만 스토리북처럼 각각의 상태가 잘 한 눈에 정리되어 있지 않기 때문에, 수동으로 검증할 때 사용하기에는 스토리북에 비해 훨씬 번거롭다. 대신 스크린샷 기능을 활용해서 시각적 회귀 테스트를 한다면 더 유용하게 사용할 수 있는데, Cypress는 이미지를 비교해주는 기능을 제공하지 않기 때문에 직접 구현하거나 cypress-image-snapshot과 같은 플러그인을 함께 사용해야 한다.
잘 모르겠는것 - 스텁의 의미?
'공부' 카테고리의 다른 글
리액트 Docs - 고급 안내서 (0) | 2020.12.16 |
---|---|
리액트 Docs - 주요개념 (0) | 2020.12.15 |
실용적인 프론트엔드 테스트 전략 - 2 (0) | 2020.12.09 |
실용적인 프론트엔드 테스트 전략 - 1 (0) | 2020.12.09 |
lazy loading (0) | 2020.12.08 |