End-to-End Testing Web Apps: The Painless Way

Michael Lynch

웹 앱 End-to-End 테스트를 고통 없이 하는 방법

좋습니다, 의심스러우시죠. 다른 가이드들은 고통 없는 웹 앱 테스트를 약속해 놓고는, 알고 보면 특정 기술 스택이나 유료 외부 서비스가 필요하다고 밝히곤 합니다. 저는 그러지 않겠습니다.

이 가이드에서는 거의 모든 웹 앱에 적용할 수 있는 직관적이고 유연한 end-to-end 테스트 템플릿을 소개합니다. 유일한 요구사항은 앱이 Docker에서 실행될 수 있다는 것뿐입니다.

정말 그게 전부입니다! Ruby 앱이든, React 앱이든, Enterprise Java Beans 앱이든, 심지어 직접 만든 기상천외한 웹 스택이라도 테스트할 수 있습니다. Windows에서 개발하든 Linux에서 하든 Mac에서 하든 상관없습니다. 무엇보다 복잡한 설정이나 Docker 외에 다른 소프트웨어를 설치할 필요도 없습니다.

이 튜토리얼은 무료 오픈소스 도구만 사용하며, 어디에도 계정을 만들 필요 없이 바로 실행할 수 있습니다. Circle이나 Travis 같은 지속적 통합 환경에서 테스트를 실행할 때도 특별한 작업이 필요 없습니다. 개발 머신에서 사용하는 것과 동일한 한 줄 명령어로 테스트를 실행하면 됩니다.

Cypress, 이번 이야기의 주인공

업데이트(2022-10-25): 이제 웹 애플리케이션의 end-to-end 테스트에 Cypress를 권장하지 않습니다. 새 프로젝트에서는 대신 Playwright를 사용할 것을 권장합니다.

이 테스트를 가능하게 하는 도구는 Cypress입니다. 브라우저 자동화 분야에 비교적 최근에 등장한 도구죠. 오픈소스 end-to-end 테스팅 프레임워크로, 전담 팀이 활발하게 개발하고 있습니다. 이들의 비즈니스 모델은 Docker와 유사합니다. 두 회사 모두 무료 오픈소스 도구를 공개하고, 그 도구를 위한 관리형 서비스를 판매해 개발 자금을 마련합니다.

Cypress 로고

Cypress는 웹 앱 자동화 테스트를 위한 오픈소스 도구입니다.

저는 작년에 지역 소프트웨어 콘퍼런스에서 Gleb Bahmutov가 Cypress를 시연하는 것을 보고 처음 알게 되었습니다. 그가 Cypress는 Selenium에 의존하지 않는다고 말했을 때 흥미가 생겼습니다. end-to-end 테스트에 대한 이전 경험은 모두 끔찍했는데, 그 고통의 근원에는 항상 Selenium이 있었습니다.

Selenium 로고

Selenium은 가장 오래되고 널리 쓰이는 브라우저 자동화 도구이지만, 무겁고 낡았습니다.

Selenium은 단연 가장 인기 있는 브라우저 자동화 프레임워크이지만, 15년 전에 설계된 Java 기반 도구답게 예상 가능한 온갖 문제를 안고 있습니다. 설치가 번거롭고, 문법이 어색하며, 테스트가 실패했을 때 제공하는 정보도 빈약합니다. Gleb의 매끄러운 Cypress 데모에서는 이런 문제들을 모두 해결해 주겠다고 약속했습니다.

Cypress 로고

Cypress의 매끄러운 기능 중 하나는 진단에 도움이 되도록 테스트의 모든 단계에서 브라우저 화면을 녹화한다는 점입니다.

Cypress 문서를 열심히 읽어봤지만, 거의 모든 문서가 사용자가 Node.js 스택을 사용하고 헤드리스 콘솔이 아닌 그래픽 환경에서 개발한다고 가정하고 있어 실망했습니다.

그래도 Cypress는 미래가 유망해 보였습니다. 1년 뒤 진행 상황을 다시 확인해보니 Cypress와 Docker Compose를 결합한 새로운 샘플 애플리케이션을 발견했습니다. 갑자기 모든 것이 명확해졌습니다. Docker Compose에서 동작하는 Cypress를 본 순간, 그 패턴을 어떤 웹 앱에도 적용할 수 있겠다는 생각이 들었습니다. 오늘은 그 패턴과 여러분의 앱에서 활용하는 방법을 소개하겠습니다.

재사용 가능한 end-to-end 테스트 패턴

Cypress와 Docker Compose를 결합하면 거의 모든 웹 앱에 적용할 수 있을 만큼 유연한 테스트 패턴이 만들어집니다. 앱 구현에 대해 이것저것 가정하는 다른 테스트 도구와 달리, 이 솔루션은 테스트 프레임워크와 테스트 대상 앱을 완전히 분리합니다.

Docker 컨테이너 아키텍처 다이어그램

Docker Compose, Cypress, 웹 앱이 함께 동작하는 구조

Docker Compose를 사용하면 Cypress는 하나의 컨테이너에서, 앱은 다른 컨테이너에서 실행할 수 있습니다. 앱은 Cypress에 대해 아무것도 알 필요가 없고, Cypress가 앱에 대해 알아야 할 것은 HTTP 요청을 보낼 네트워크 포트뿐입니다.

테스트할 간단한 웹 앱

테스트할 예제 웹 앱으로 Sentimentalyzer를 소개합니다. 세계에서 가장 단순한 텍스트 감정 분석기입니다. 사용자의 글에서 기분을 추측해 보려고 시도합니다.

텍스트 It's a nice day today를 입력하면, Sentimentalyzer는 사용자가 행복하다고 판단합니다:

Sentimentalyzer에 텍스트 입력하기Sentimentalyzer가 결과를 생성함

행복한 텍스트를 분석하는 Sentimentalyzer

텍스트 Who ate ALL MY WAFFLES?를 입력하면, Sentimentalyzer는 사용자가 화가 났다고 판단합니다:

Sentimentalyzer에 텍스트 입력하기Sentimentalyzer가 결과를 생성함

화난 텍스트를 분석하는 Sentimentalyzer

알고리즘은 단순합니다. 문자 중 50% 이상이 대문자이면 사용자가 소리를 지르는 것이므로 화가 난 것으로 간주합니다. 그렇지 않으면 기분이 괜찮은 것으로 판단합니다.

프로젝트 구조

예제 프로젝트의 파일 구조는 다음과 같습니다:

main.go               <- source for my web app, Sentimentalyzer
Dockerfile            <- defines how to run Sentimentalyzer in a Docker container
e2e/                  <- folder that contains all the files for my end-to-end tests
  cypress.json        <- Cypress configuration
  docker-compose.yml  <- glue that binds together my app container with the Cypress container
  integration/
    spec.js           <- defines the end-to-end test for Sentimentalyzer

모든 프로덕션 로직은 루트 폴더에 있고, 모든 end-to-end 테스트 코드는 e2e 폴더에 있습니다.

Sentimentalyzer를 로컬에서 실행하기

여기서 일부러 앱의 소스 코드를 보여드리지 않는 이유는 Cypress 테스트를 앱 구현을 전혀 보지 않고도 작성할 수 있다는 점을 강조하기 위해서입니다. Sentimentalyzer는 Go로 만든 앱이지만, Python이나 Angular로 구현했더라도 테스트는 동일했을 것입니다. 궁금하시다면 소스 코드는 GitHub에서 확인할 수 있습니다.

Sentimentalyzer를 직접 실행해 보려면 다음 명령어를 실행하세요:

git clone https://github.com/mtlynch/hello-world-cypress.git
cd hello-world-cypress
docker build --tag sentimentalyzer .
docker run \
  --interactive \
  --tty \
  --env PORT=8123 \
  --publish 8123:8123 \
  sentimentalyzer

위 명령어는 로컬 머신에 Sentimentalyzer 서버를 띄우며, 주소는 http://localhost:8123입니다.

이제 앱을 Docker 컨테이너에서 실행할 수 있으니, Cypress로 end-to-end 테스트를 만들 준비가 되었습니다.

end-to-end 테스트 만들기

첫 번째 Cypress end-to-end 테스트를 작성하려면 세 개의 파일만 있으면 됩니다:

  • cypress.json
  • docker-compose.yml
  • integration/spec.js

cypress.json

이 파일은 Cypress의 설정 옵션을 지정합니다:

{
  "pluginsFile": false,
  "supportFile": false
}

cypress.json 다운로드

이 설정들은 그다지 흥미롭진 않지만, 불필요한 헬퍼 파일이 자동으로 생성되는 것을 막기 위해 false로 설정했습니다.

docker-compose.yml

이 파일은 Sentimentalyzer용 Docker 컨테이너와 Cypress용 Docker 컨테이너를 정의하고, 두 컨테이너가 서로 통신할 수 있게 합니다:

version: "3.2"
services:
  sentimentalyzer:
    build: ../
    environment:
      - PORT=8123
  cypress:
    image: "cypress/included:4.4.0"
    depends_on:
      - sentimentalyzer
    environment:
      - CYPRESS_baseUrl=http://sentimentalyzer:8123
    working_dir: /e2e
    volumes:
      - ./:/e2e

docker-compose.yml 다운로드

몇 가지 주목할 만한 부분을 짚어보겠습니다:

image: "cypress/included:4.4.0"

cypress/included는 Cypress가 이미지에 미리 설치되어 있는 Cypress Docker 이미지 계열입니다. cypress/basecypress/browsers 같은 다른 계열은 클라이언트가 런타임에 Cypress를 설치한다고 가정합니다. cypress/included 이미지를 사용하면 컨테이너가 시작되자마자 Cypress가 테스트를 실행하도록 보장할 수 있습니다.

depends_on:
  - sentimentalyzer

depends_on 구문은 Cypress가 테스트를 실행하기 전에 Sentimentalyzer가 먼저 실행되어 준비되도록 보장합니다.

environment:
  - CYPRESS_baseUrl=http://sentimentalyzer:8123

CYPRESS_baseUrl 환경 변수는 Cypress가 Sentimentalyzer에 접근할 수 있는 URL을 알려줍니다. Cypress와 Sentimentalyzer가 동일한 Docker Compose 설정에서 실행되므로, Cypress는 컨테이너 이름(sentimentalyzer)을 호스트 이름으로 사용해 Sentimentalyzer에 네트워크 요청을 보낼 수 있습니다.

working_dir: /e2e
volumes:
  - ./:/e2e

마지막으로 Docker의 볼륨 마운트 기능을 사용해 Cypress Docker 컨테이너가 호스트 머신의 파일 시스템 일부를 공유하도록 합니다.

호스트 머신의 ./e2e 디렉터리에 있는 모든 것은 Docker 컨테이너 안의 /e2e 경로에 나타납니다. 이렇게 하면 Cypress가 실행 중에 로그, 스크린샷, 비디오를 작성해도 호스트에서 컨테이너로 수동 복사할 필요 없이 즉시 호스트 머신에서 확인할 수 있습니다. 이런 식으로 호스트 볼륨을 바인딩하면 전체 Docker 이미지를 다시 빌드하지 않고도 테스트를 편집하고 다시 실행하기 쉽습니다.

working_dir 설정은 Cypress가 파일 시스템에서 /e2e 디렉터리를 현재 작업 디렉터리로 인식하도록 합니다.

integration/spec.js

이제 설정은 끝났으니, 재미있는 부분인 테스트 작성으로 넘어가 보겠습니다.

it("detects angry sentiment", () => {
  cy.visit("/analyze");

  cy.get("#feelings").type("I REALLY need some COFFEE");
  cy.get("form").submit();

  cy.get(".results p").should("contain", "You are feeling: Angry");
});

it("detects content sentiment", () => {
  cy.visit("/analyze");

  cy.get("#feelings").type("I think coffee in the morning is just swell!");
  cy.get("form").submit();

  cy.get(".results p").should("contain", "You are feeling: Content");
});

spec.js 다운로드

Cypress API가 익숙하지 않더라도, 의미가 충분히 읽기 쉬워 테스트를 직관적으로 이해할 수 있을 것입니다. 쉽게 풀어 쓰면 두 테스트 모두 같은 순서를 따릅니다:

  1. 브라우저에서 Sentimentalyzer 웹 앱의 /analyze 경로로 이동합니다.
  2. 텍스트 필드를 찾습니다.
  3. 텍스트를 입력합니다.
  4. 폼을 제출합니다.
  5. 결과를 확인합니다.

첫 번째 테스트를 한 줄씩 살펴보겠습니다:

cy.visit("/analyze");

이 줄은 Cypress에게 Sentimentalyzer의 /analyze 경로를 브라우저에 로드하라고 지시합니다. Cypress는 위에서 docker-compose.yml에 정의한 CYPRESS_baseUrl 환경 변수와 이를 결합하므로, 전체 URL은 http://sentimentalyzer:8123/analyze가 됩니다. 이 URL은 개발 머신에서는 접속할 수 없지만, Cypress 컨테이너 안에서는 유효한 주소입니다.

cy.get("#feelings").type("I REALLY need some COFFEE");

다음으로 Cypress에게 텍스트 필드를 찾으라고 지시합니다. 이 필드는 고유한 ID인 feelings를 가지고 있어 CSS 선택자 문법인 #feelings로 쉽게 지정할 수 있습니다.

feelings 요소의 HTML id 찾기

type() 함수는 Cypress에게 지정한 필드에 텍스트를 입력하라고 지시합니다.

다음으로 Cypress는 폼을 제출해야 합니다. Cypress는 이 흔한 작업을 위해 submit() 함수를 제공합니다. 페이지에는 <form> 요소가 하나뿐이므로, CSS 선택자 form으로 쉽게 가져온 뒤 폼을 제출하면 됩니다:

cy.get("form").submit();

폼을 제출하면 Cypress는 Sentimentalyzer의 결과 페이지로 이동해야 합니다. Cypress는 "You are feeling: Angry"라는 텍스트를 확인해야 하는데, 이를 담고 있는 <p> 태그에는 ID 속성이 없어 조금 더 까다롭습니다:

결과 <p> 태그의 CSS 선택자 찾기

다시 CSS 선택자 문법을 사용해 클래스가 "results"인 DOM 노드 아래의 <p> 요소를 지정해 해당 텍스트를 찾습니다:

cy.get(".results p").should("contain", "You are feeling: Angry");

contain 어서션<p> 태그가 예상한 텍스트를 포함하고 있는지 검증합니다.

테스트 실행하기

이제 모든 준비가 끝났으니 Cypress가 동작하는 모습을 확인해 보겠습니다. 다음 간단한 명령어로 테스트를 실행합니다:

cd e2e
docker-compose up --exit-code-from cypress

--exit-code-from cypress 플래그는 Docker Compose에게 Cypress 컨테이너의 종료 코드를 docker-compose 명령어의 종료 코드로 사용하라고 지시합니다. 즉, 테스트가 통과하면 종료 코드가 0이 되고, 실패하면 0이 아닌 값이 반환됩니다. 이 동작은 명령어의 종료 코드로 성공 여부를 판단하는 빌드 스크립트나 지속적 통합 설정에 유용합니다.

콘솔에서 전체 과정은 다음과 같이 보입니다:

Cypress는 모든 테스트 실행을 비디오로 녹화합니다. 테스트 실패를 진단하는 데 큰 도움이 되기 때문에 제가 가장 좋아하는 기능입니다:

end-to-end 테스트의 Cypress 녹화 영상(1/4 속도로 느리게 재생)

테스트 실패 스크린샷

위에서는 통과한 테스트를 보여드렸습니다. Cypress 테스트가 실패하면 어떻게 될까요? 여전히 테스트 실행 비디오를 생성하지만, 실패한 어서션을 보여주는 스크린샷도 함께 출력합니다:

실패 시 Cypress 스크린샷 출력

테스트가 실패했을 때 Cypress가 생성한 스크린샷(Cypress는 “Furious”라는 단어를 예상했지만 실제로는 “Angry”를 발견했습니다)

이는 제가 다른 도구에서 겪었던 큰 불편을 해결해 줍니다. Selenium도 스크린샷을 지원하지만 어서션 전이나 후에만 찍을 수 있습니다. 그 제약 때문에 Selenium은 테스트가 실패했다고 주장하지만, 테스트 실패 이후 브라우저 상태가 바뀌어 스크린샷에는 정상적인 모습이 찍히는 답답한 상황이 발생하곤 했습니다.

Cypress는 스크린샷이 어서션과 동시에 일어나기 때문에 이런 문제를 피할 수 있습니다. 테스트가 실패하면 스크린샷은 실패 시점에 Cypress가 본 것을 정확히 보여줍니다.

여러분의 웹 앱에 적용하기

이 세 파일만 있으면 웹 앱의 end-to-end 테스트를 시작할 수 있습니다. 단계는 다음과 같습니다:

  1. e2e 폴더를 프로젝트에 복사합니다.
  2. docker-compose.yml에서 sentimentalyzer 섹션을 여러분 앱을 위한 Docker 컨테이너로 교체합니다.
  3. 앱의 UI 흐름에 맞게 integration/spec.js를 다시 작성합니다.

소스 코드 및 추가 예제

이 데모의 전체 소스는 GitHub에서 확인할 수 있습니다:

다른 일반적인 Cypress 시나리오를 보여주기 위해 몇 가지 브랜치도 만들었습니다:

더 읽어보기

이 가이드는 Cypress에 대한 기본적인 소개를 제공했습니다. 더 고급 기능은 공식 Cypress 문서를 참고하세요:

업데이트(2019-05-02): 이 글에 대한 응답으로 Cypress 팀은 Cypress가 미리 설치된 공식 Docker 이미지를 공개했습니다. 새로운 이미지를 반영하도록 이 튜토리얼을 수정했습니다. 이미지에 대한 자세한 내용과 Cypress와 Docker를 함께 사용하는 더 많은 팁은 Cypress 블로그 글을 확인하세요.


삽화: Loraine Yow. 이 글에 대해 초기 피드백을 제공해 준 Cypress 팀의 Gleb Bahmutov에게 감사드립니다.

원문은 Michael Lynch님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.