Cypress로 컴포넌트 테스트하기

·

4 min read

Jest와 다른 테스트 프레임워크

jest의 대체 도구로 검색되는 프레임워크들과 다운로드 횟수를 비교해보면 알 수 있듯, 리액트 앱을 테스트하기 위한 프레임워크로 가장 인기가 많은건 jest인 것 같다.

특히 jsdom을 함께 설치하면, 노드 환경에서 브라우저 환경을 시뮬레이션하고 DOM 인터페이스를 제공하기 때문에 jest에서 컴포넌트 테스트도 가능하다.

Jsdom의 여러가지 이슈

하지만 jsdom은 실제 브라우저 환경과는 다르기 때문에, 실제 브라우저와 달리 동작에 차이가 있거나, 브라우저에서는 동작하는게 jsdom에서는 동작하지 않을 수도 있다.

예를들어 내가 사용한 @radix-ui/react-select라는 SelectBox라이브러리는 내부적으로 ResizeObserver를 사용하는데, jsdom에서는 ResizeObserver가 구현되어있지 않아서 테스트 실행중에 이 라이브러리로 만든 UI와 인터랙션을 시도하면 오류가 발생한다.

관련 이슈에서는, 폴리필을 직접 만들어서 해결한 사람도 있었고, 설치해서 해결한 사람도 있었다.

동일한 컴포넌트에서 발생하는 문제로, jsdom포인터 이벤트가 구현되어있지 않아서 fireEvent나, userEvent로 SelectBox를 open할 수 없는 이슈가 등록되어있으며, 코멘트에 이 문제를 해결하기 위한 폴리필을 제공하고 있다.

다른 예시로 react-hot-toast를 사용할 때, 테스트 환경에서 토스트를 팝업시키려면 matchMedia 모킹 함수가 필요하며, 관련 코드는 Jest Docs를 참고하면 확인할 수 있다.

이 외에도, dompurify를 통해 html을 sanitize하는 코드가 jsdom환경에서 실행될 때 TextEncoder에 대한 ReferenceError가 발생하는 문제가 있으며, 이 이슈에서 해결법을 확인할 수 있다.

이처럼 실제 브라우저 환경과 jsdom이 제공하는 환경은 어느 정도 차이가 있어서, jest를 사용해서 컴포넌트를 테스트할 때 추가적인 셋업이 필요한 경우가 빈번했다.

그래서 조금 더 편리하게 테스트할 방법이 없나 찾아본 결과, cypress의 컴포넌트 테스트를 발견했다.

Cypress의 특징

cypress는 브라우저 기반의 테스트 러너이며, e2e 테스트를 위한 도구로 알려져있지만, 이 포스트이 포스트를 참고하면, cypress 10에는 컴포넌트 테스트를 베타버전으로, cypress 11부턴 stable API로 제공함을 알 수 있다. 현재 cypress 버전이 13이므로 컴포넌트를 테스트하는데는 무리가 없어 보인다.

jest와 testing library를 사용해서 컴포넌트를 테스트할 때는 엘리먼트를 쿼리하고, 인터랙션을 수행한 뒤 Assertion을 하는 흐름으로 테스트를 진행한다.

cypress도 흐름은 완전히 동일하며, 엘리먼트를 쿼리하거나 인터랙션을 수행하거나, Assertion하는 API를 모두 제공하므로, 사실상 배울 내용이 거의 없다.

동작 방식에 있어서 약간의 차이가 있지만, 이 가이드cypress의 컨셉, 동작하는 방식 및 주의사항이 대부분 소개되어있다. cypress의 특징을 정리하면 다음과 같다.

  1. 브라우저 환경에서 동작한다.

    테스트가 전부 브라우저 위에서 실행되므로, jsdom처럼 모킹 함수나 폴리필을 등록할 필요가 없다.

  2. jQuery와 동일한 API를 제공한다.

    cypress내에서 jQuery를 번들하여 API들을 제공하고 있다. jQuery에 익숙하지 않아도, 생각하는 대부분의 쿼리 방식은 jQuery문서 혹은 cypress 문서에서 찾아보거나 cypress 테스트 예시에서 찾을 수 있다.

  3. 비동기 작업을 기다리는 코드를 넣을 필요가 없다.

    cypress는 모든 DOM 쿼리와 assertion에 대해서, 재시도 및 타이머를 적용한 로직으로 래핑하므로 waitFor과 같은 API를 사용하지 않아도 된다.

    예를들어 테스트하고자 하는 컴포넌트가 마운트 되기 전에 로딩스피너가 잠깐 나타난다고 해도, 쿼리하고자 하는 엘리먼트가 존재하지 않으면 타임아웃이 될 때 까지 쿼리를 재시도하는 동작을 내부적으로 수행한다.

  4. Mocha, Chai, Chai-jQuery, Sinon, Sinon-Chai등 많은 테스트 라이브러리의 API를 차용한다.

    자세한 내용은 여기서 확인할 수 있다. 간단하게 정리하자면,

    • 테스트 코드의 골격을 작성할 때는 Mocha API를 사용한다.

    • Assertion에는 Chai API 또는 Mocha API를 사용한다.

    • 함수를 다루거나, 시간에 의존적인 코드를 결정론적으로 테스트하려면 Sinon API를 사용한다. 그리고, Sinon이 제공하는 기능을 assertion하기 위해 Sinon-Chai API를 사용한다.

Jest와 비교

cypress의 테스트는 cy라는 객체의 메서드를 체이닝하는 방식으로 진행된다. 메서드에는 커맨드, Assertion 두 종류가 있다.

커맨드는 브라우저와의 인터랙션을 수행하는 메서드를 의미하며, DOM 엘리먼트를 쿼리하거나 클릭하는 등의 작업을 포함한다. Assertion은 예상되는 값을 검증하는 메서드이다.

커맨드를 실행하려면, 커맨드가 적용될 대상이 필요한데, 예를들어 click을 수행하려면 클릭 가능한 엘리먼트가 대상이 되어야 하고, type을 수행하려면 인풋 엘리먼트가 대상이 되어야 한다.

커맨드의 실행 결과로 항상 값이 yield되며(Docs에서도 return이 아닌, yield라고 언급한다), 다음 커맨드 체인이나 Assertion의 대상으로 사용된다.

테스트 코드를 cypressjest로 각각 작성해서 스타일을 비교해보았다. 작성한 테스트는 다음과 같다.

  1. 인풋 엘리먼트에 텍스트를 입력하고

  2. 제출 버튼을 클릭하면

  3. 모달이 팝업된다.

cypress

cy.mount(<Component />);

cy.get('.input-element')
  .clear()
  .type('value');

cy.get('.submit-button-element')
  .click();

cy.get('.dialog-element')
  .should('be.exist');

jest + testing library

const screen = render(<Component />);

const input = screen.getByPlaceholderText(/placeholder/);
await userEvent.clear(input);
await userEvent.type(input, 'value');

const submitButton = screen.getByRole('button', { name: /submit/ } );
await userEvent.click(submitButton);

expect(screen.getByRole('dialog')).toBeInTheDocument();

cypress로 테스트를 작성할 때는 프로미스를 await하는 코드를 넣을 필요가 없다. 각 커맨드가 실행될 때 타이머가 함께 실행되어서, 타임아웃되거나 커맨드가 성공할 때 까지 재시도되기 때문이다.

cypress는 DOM selector로 엘리먼트를 쿼리한다는 차이가 있는데, Cypress Testing Library를 사용하면 cypress에서도 testing library의 API를 사용할 수 있다. 그래서 다음과 같은 방식으로도 작성할 수 있다.

cypress + testing library

cy.findByPlaceholderText(/placeholder/)
  .clear()
  .type('value');

cy.findByRole('button', { name: /submit/ })
  .click();

cy.findByRole('dialog')
  .should('be.exist');

테스트 실행하기

작성한 테스트코드를 실행시키기 위해서 먼저 cypress app을 실행시켜야 한다 (가이드). cypress app을 실행시키면 e2e테스트와 컴포넌트 테스트중 하나를 선택하라는 안내가 나온다. 여기서 컴포넌트 테스트를 선택하면 된다. 컴포넌트 테스트는 컴포넌트를 마운트하는 것으로, e2e테스트는 특정 url을 방문하는 것으로 테스트를 시작한다.

다음은 cypress app에서 컴포넌트 테스트를 진행하는 모습이다.

각 커맨드 및 Assertion마다의 스냅샷이 저장되어서, 테스트 종료 후에 디버깅을 쉽게 할 수 있다. 좌측에 출력되어 있는 커맨드 혹은 Assertion에 마우스를 올리면 오른쪽에 스냅샷이 보인다.

Cypress + Storybook

다른 사람들이 cypress를 사용하는 방법들을 찾아보다가 발견한 코드가 있다. 이 코드에선 cy.visitStory(storyName)로 테스트를 시작하는데, visitStory는 cypress에서 제공하는 커맨드는 아니므로, cy.visit을 래핑한 커스텀 커맨드라는걸 알 수 있다.

스토리마다 할당되는 url이 있기 때문에, 특정 스토리로 방문하는 코드라는걸 짐작할 수 있었고,

특히 Storybook과 MSW를 함께 사용할 목적으로 MSW 애드온을 사용한다면, 각 스토리마다 다른 MSW handler를 적용시키는게 가능해서 다양한 시나리오로 스토리를 작성할 수 있는데, 이런 상황에서 각 스토리를 테스트하기 좋아보였다.

visitStory커맨드 코드는 여기에서 확인할 수 있다. 만약 스토리 이름을 한글로 사용하는 경우 encodeURI로 래핑해야 오류가 발생하지 않는다. 그리고 cypress app으로 테스트하기 전에는, 반드시 로컬 Storybook서버를 열어놔야 한다.

로그인을 하지 않은 경우나, 유저의 상태에 따라 여러 스토리를 미리 작성해두고

다음과 같이 각 스토리에 방문해서 테스트를 진행하면 된다.

참고할 만한 링크

cypress를 익히는데 많은 도움을 얻었던 링크들이다.