이 글에서는 개별 모듈 단위의 작은 단위 테스트 대신, 여러 모듈이 조합된 형태를 하나의 단위로 보고 좀 더 큰 규모의 단위 테스트를 작성하기를 권장한다. 용어를 통일하기 위해 지금부터는 상대적으로 큰 규모의 단위 테스트를 통합 테스트라고 부르도록 하겠다.
통합 테스트 작성하기
통합 테스트를 작성하기 위해서는 먼저 단위를 나눌 경계를 결정해야 한다. 예를 들면 액션 생성자와 리듀서, 스토어를 묵어서 테스트할 수도 있고, 스토어와 컨테이너 컴포넌트만 묶어서 테스트할 수도 있다. 나누는 범위에 따라 각각의 장단점이 있으므로 상황에 따라 적절한 범위를 선택하면 된다. 여기서는 개별 모듈 단위의 테스트와의 차이를 명확하게 비교하기 위해 스토어와 라우터를 생성하는 메인 모듈을 제외한 모든 모듈을 묶어서 테스트하도록 하겠다.
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 테스트 도구로 분류하도록 하겠다.)
Cypress 테스트 작성하기
이제 본격적으로 테스트를 작성해 보자. Cypress 테스트는 보통 별도의 로컬 서버를 실행한 다음 해당 URL에 직접 접속하는 방식으로 작성한다. 이 예제에서는 로컬 개발 서버 뿐만 아니라 API 서버까지 함께 사용하고 있으므로, 테스트를 실행하기 전에 두 서버가 모두 실행되어 있어야 한다.
먼저 커맨드 라인에 node server를 입력하면 8081 포트에 API 서버가 실행된다. 그 다음 커맨드 라인에 npm start 입력하면 3000 포트에 webpack-dev-server가 실행되며, 설정에 따라 API 서버를 프록시로 연결해 준다.
테스트를 작성하기 앞서 간단한 설정을 추가해보자. 설정 파일에 기준 URL을 저장해 놓으면 테스트 코드에 로컬 서버의 전체 URL을 매번 작성할 필요 없이 상대 경로만 사용할 수 있다. 프로젝트 루트에 cypress.json 파일에 다음과 같이 baseUrl을 추가하면 된다.
{
"baseUrl": "http://localhost:3000"
}
TypeScript
복사
이제 스토어의 상태에 따라 화면에 할 일 목록을 그려주는 기능을 테스트로 작성해보자. 서버의 응답값을 목킹하기 위해서는 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");
});
});
TypeScript
복사
마지막에 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");
});
});
});
TypeScript
복사
반복된 작업을 줄이기 위해 공통 초기화 코드를 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
}
]);
});
});
TypeScript
복사
통합 테스트와의 비교를 위해 주석에 동일한 번호와 설명을 추가했다. 먼저 준비(1) 과정에서는 서버 요청을 목킹할 때 axios라는 특정 라이브러리에 종속되지 않고 네트워크 요청을 직접 목킹할 수 있는 장점이 있으며, 렌더링을 직접할 필요 없이 서버 URL에 접속하기만 하면 된다는 장점이 있다. 실행(2) 과정에서도 change 이벤트와 keydown 이벤트를 직접 발생시킬 필요 없이 cy.type() 을 사용해서 마치 사용자가 입력하듯이 코드를 작성할 수 있다. 마지막 검증(3) 과정에서는 스토어의 값을 직접 확인하지 않고 DOM의 상태를 이용해서 애플리케이션의 상태를 검증하고 있다.