On Migrating from Cypress to Playwright

Michael Lynch

Cypress에서 Playwright로 갈아타기

원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기

Cypress는 웹 애플리케이션을 엔드투엔드로 테스트하는 오픈소스 도구다. 2018년 뉴욕에서 열린 웹 개발 밋업에서 Gleb Bahmutov가 Cypress를 시연하는 모습을 처음 봤는데, 완전히 매료됐다.

Cypress 라이브 데모 스크린샷

2018년 개발자 밋업에서 시연을 본 이후로 계속 Cypress를 써 왔다.

Cypress를 알기 전에는 마지못해 Selenium을 쓰고 있었다. Cypress는 Selenium을 쓰기 어렵게 만들던 수많은 고충을 우아하게 해결해 줘서, 정말 상쾌한 도약처럼 느껴졌다.

최근에는 Cypress에 대한 Microsoft의 대안인 Playwright를 써 봤다. 하루 정도 만져본 뒤, 이제는 Cypress에서 Playwright로 완전히 갈아탈 준비가 됐다.

이 말을 하게 되어 마음이 아프다. Cypress의 작고 끈질긴 팀에 대한 애정이 있기 때문이다. Microsoft 같은 거대 기업에 대한 의존성을 추가하는 게 반갑지는 않다. 하지만 Playwright가 너무나 뛰어나서 Cypress에 계속 남아 있을 이유를 찾을 수가 없다.

아래는 기억이 생생할 때 적어 둔, Cypress에서 Playwright로 옮겨가며 남긴 노트다.

Cypress와 Playwright에 대한 나의 기존 경험

지난 4년간 내가 만든 거의 모든 웹 앱에 Cypress 엔드투엔드 테스트를 작성해 왔다. 스스로를 중급 Cypress 사용자 정도로 평가한다. 대부분 기본 API만으로 충분한 단순한 테스트였고, 커스텀 플러그인을 직접 만든 적은 없지만 서드파티 플러그인은 몇 개 써 봤다.

Playwright는 단 하루 써 봤다. 직접 부딪혀 보기 위해 내 앱 중 하나의 테스트 스위트를 Cypress에서 Playwright로 포팅해 봤다. 미니멀한 파일 공유 도구인 PicoShare를 골랐는데, 엔드투엔드 테스트가 딱 10개뿐이다. Playwright API를 익히는 시간까지 포함해 약 5시간 만에 전부 Cypress에서 Playwright로 옮길 수 있었다.

Cypress나 Playwright에 돈을 지불한 적은 없으니 어느 쪽에도 뭔가를 요구할 자격은 없다. Cypress에는 유료 SaaS가 있지만 내 워크플로우에는 맞지 않아 구매한 적이 없다. 내가 쓰는 다른 오픈소스 프로젝트들처럼 Cypress도 기꺼이 후원하고 싶었지만, Cypress는 후원 옵션을 제공하지 않는다.

Playwright에서 마음에 들었던 점

Playwright는 Cypress보다 훨씬 빠르다

내 Playwright 테스트 스위트는 CircleCI에서 동일한 Cypress 테스트보다 34% 더 빠르게 실행된다. 로컬 개발 머신에서는 Playwright가 Cypress보다 5배 빠르다. 엄밀한 측정은 아니지만, 둘 사이에 상당한 속도 차이가 있다는 건 분명하다.

작업CypressPlaywright차이
CircleCI에서 테스트 실행127s84s-34%
개발 머신에서 테스트 실행40s7s-83%

CI에서의 성능 차이 중 일부는 Playwright Docker 컨테이너가 Cypress 컨테이너보다 훨씬 작기 때문이다. 로컬 개발에서는 한 번만 다운로드하면 되니 큰 문제가 아니다. 하지만 CI에서 Cypress를 실행할 때는 매번 CircleCI가 약 1GB짜리 이미지를 다운로드하고 압축을 푸는 시간을 기다려야 한다.

cypress/included:10.9.0playwright:v1.26.0-focal-amd64
크기940 MB651 MB

Playwright는 일관된 assertion API를 제공한다

Cypress는 9개의 서로 다른 서드파티 라이브러리를 하나로 묶어 제공하는데, 그 때문에 일관성 없는 API가 뒤섞여 있다. should, expect, assert가 따로 있고, 어떤 맥락이냐에 따라 다른 키워드를 써야 한다.

예를 들어, 다음 두 코드 조각은 동일한 검증을 수행한다:

cy.get("#error-message").should("be.visible");
cy.get("#error-message").should(($el) => expect($el).to.be.visible);

Playwright에서는 하나의 일관된 API만 있다. ID가 error-message인 요소가 화면에 보이는지 검증하려면 간단한 함수 호출 한 번이면 된다:

expect(page.locator("#error-message")).toBeVisible();

Playwright는 GUI 환경에 의존하지 않는다

Cypress가 가장 내세우는 기능 중 하나가 데스크톱 GUI 앱이다:

Cypress 데스크톱 앱 스크린샷

Cypress는 테스트 실행을 보여주기 위해 데스크톱 앱을 사용한다

Cypress 데스크톱 앱에서는 테스트를 “시간 여행”하듯 되돌아볼 수 있어서, 테스트의 각 시점에서 브라우저 창이 어떤 모습이었는지 확인할 수 있다.

하지만 GUI 없이 개발한다면 어떨까? 나는 모든 개발을 헤드리스 서버 VM에서 한다. 4년간 Cypress를 쓰면서 데스크톱 앱을 한 번도 써 본 적이 없다. 대신 Docker 컨테이너 안에서 Cypress를 실행하는데, 데스크톱 GUI에서 작업하길 기대하는 도구에게는 가끔 걸림돌이 된다.

GUI 문제는 CI 환경에서 Cypress 테스트를 실행하려고 할 때도 다시 불거진다. CI에도 보통 데스크톱 GUI는 없다. Cypress의 해답은 자신들의 유료 CI 서비스를 쓰라는 것이고, 이는 회사를 유지하는 주요 수익원이기도 하다.

오픈소스 제품을 어떻게 수익화하든 회사의 자유라고 생각한다. 하지만 Cypress의 CI 제품은 내게는 전혀 매력적이지 않았다. Docker 컨테이너로 CI 환경을 로컬에서 그대로 재현하고 싶은데, Playwright는 그게 가능하지만 Cypress의 CI 서비스는 그렇지 않다.

CircleCI에서 Cypress를 실행하려면 Docker Compose로 약간의 꼼수를 써야 했다. 엄청난 부담은 아니지만, 테스트 스택을 내가 원하는 것보다 조금 더 복잡하게 만든다.

Playwright를 써 보니 헤드리스로 동작하도록 설계된 도구를 쓰는 게 얼마나 신선한지 알 수 있었다. Playwright는 헤드리스 환경에서 그냥 바로 동작하니, CI에서 실행하기 위해 별다른 꼼수를 쓸 필요가 없다.

Playwright에도 Cypress와 동일한 시간 여행 기능이 있지만, 데스크톱 GUI가 아니라 웹 UI로 구현되어 있어 더 많은 환경에서 동작한다.

시간 여행 기능은 정말 괜찮다! Playwright의 스냅샷은 단순한 정적 스크린샷이 아니다. 테스트의 각 단계에서 브라우저와 직접 상호작용할 수 있는데, 약간 마법 같은 느낌이 든다.

Playwright 웹 UI에서는 앱 실행의 다양한 상태로 시간 여행을 하며 페이지의 모든 요소와 상호작용할 수 있다.

Playwright는 기능적 빈틈이 적다

Cypress는 기본적인 엔드투엔드 테스트를 시작하기엔 쉽지만, 앱이 커질수록 테스트 도구에서 기능적 빈틈을 자주 마주하게 됐다.

예를 들어 파일 업로드 기능을 추가하고 나서 Cypress로는 파일 업로드를 테스트할 수 없다는 걸 깨닫곤 했다. 하던 일을 멈추고 그 빈틈을 메워 줄 서드파티 Cypress 플러그인을 찾아야 했다.

이 글을 쓰는 중에 Cypress가 올해 초에야 파일 업로드에 대한 네이티브 지원을 추가했다는 걸 알게 됐다. 극히 일반적인 시나리오를 지원하는 데 7년이 걸렸다는 건 다소 갸우뚱하게 만든다.

마찬가지로 거의 모든 웹 UI 프레임워크에 있는 마우스 호버를 시뮬레이션하고 싶어도 Cypress로는 할 수 없다. 그 버그는 거의 8년째 열려 있다.

Playwright에도 분명 빈틈이 있겠지만, Cypress에서 Playwright로 테스트를 포팅한 하루 동안에는 하나도 마주치지 않았다. Cypress의 빈틈 때문에 넣어 둔 모든 임시방편이 Playwright에서는 네이티브로 해결됐다.

Playwright는 도메인 특화 지식이 덜 필요하다

내가 Cypress를 처음 발견했을 때 마음에 들었던 점 중 하나는 Selenium이 Java 중심이었던 것과 달리 JavaScript를 위해 설계됐다는 것이었다.

기본적인 테스트에서는 Cypress의 문법이 JavaScript를 아는 사람에게는 자연스럽고 친숙하게 느껴진다. 하지만 정해진 경로에서 조금만 벗어나도 Cypress는 갑자기 JavaScript 같지 않고 독자적인 도메인 특화 프레임워크처럼 느껴진다.

예를 들어 내 앱 PicoShare에는 인증되지 않은 사용자와 공유하려는 파일의 URL을 생성하는 기능이 있다. 이 기능을 테스트하려면 PicoShare의 공유 기능을 거쳐 이동하고, 사용자 세션에서 로그아웃한 뒤, 몇 단계 전에 생성된 URL에 브라우저가 여전히 접근할 수 있는지 확인해야 했다.

다음은 이 테스트를 Cypress에서 원래 구현했던 방식이다:

// Save the route to the guest link URL so that we can return to it later.
cy.get('.table td[test-data-id="guest-link-label"] a')
  .invoke("attr", "href")
  .then(($href) => {
    // Log out.
    cy.get("#navbar-log-out").click();
    cy.location("pathname").should("eq", "/");

    // Make sure we can still access the guest link after logging out.
    cy.visit($href);

    // Continue with the test
  });

then이 보이니 invokePromise를 반환했다고 생각할 수 있다. 하지만 그 promise를 await 해 보면 undefined가 반환된다. Cypress가 반환한 것이 실제로는 Promise인 척하는 가짜였기 때문이다.

사소해 보일 수도 있지만, 앱에서 값을 동적으로 참조해야 할 때마다 Cypress는 필요한 값마다 새로운 중첩 클로저를 강제한다. await를 지원해 달라는 요청은 널리 지지를 받고 있지만 4년째 진전이 없고, Cypress는 최근 당장 구현할 계획이 없다고 밝혔다.

동일한 테스트를 Playwright로 작성하면 이렇게 된다:

// Save the route to the guest link URL so that we can return to it later.
const guestLinkRouteValue = await page
  .locator('.table td[test-data-id="guest-link-label"] a')
  .getAttribute("href");
expect(guestLinkRouteValue).not.toBeNull();
const guestLinkRoute = String(guestLinkRouteValue);

// Log out.
await page.locator("#navbar-log-out").click();
await expect(page).toHaveURL("/");

// Make sure we can still access the guest link after logging out.
await page.goto(guestLinkRoute);

// Continue with the test.

Playwright에서는 DOM 요소에 대한 참조가 있으면 getAttribute 같은 일반 API를 호출해 클로저의 복잡함 없이 예상한 단순한 값을 바로 돌려받는다. 그리고 Playwright가 반환하는 promise처럼 보이는 값들은 실제로 await할 수 있는 진짜 Promise라 코드가 훨씬 깔끔하다.

Playwright에서는 텍스트 비교가 더 쉽다

Cypress에서 항상 답답했던 점 중 하나는 요소가 특정 텍스트 값을 포함하는지 검증하기가 어렵다는 것이다.

다음은 PicoShare의 <p> 요소 예시다:

<p data-test-id="github-instructions">
  Visit our
  <a href="https://github.com/mtlynch/picoshare">GitHub repo</a> to create your
  own PicoShare server.
</p>

Cypress에서 예상치 못한 텍스트 비교 결과

Cypress에서 텍스트 값을 검증하는 순진한 접근법은 다음과 같다:

cy.get("[data-test-id='github-instructions']").should(
  "have.text",
  "Visit our GitHub repo to create your own PicoShare server.",
);

안타깝게도 이 테스트는 실패한다:

Timed out retrying after 10000ms
+ expected - actual

-'
      Visit our
      GitHub repo to create
      your own PicoShare server.
    '
+'Visit our GitHub repo to create your own PicoShare server.'

Cypress는 textContent 속성을 가져오는데, 여기에는 브라우저에 보이는 모습이 아니라 원시 HTML에 나타난 그대로의 모든 공백이 포함된다.

요소의 innerText를 가져오면 우회할 수는 있지만, 전혀 다른 assertion API를 써야 해서 문법이 복잡하고 기억하기도 어렵다:

cy.get("[data-test-id='github-instructions']").should(($el) => {
  expect($el.get(0).innerText).to.eq(
    "Visit our GitHub repo to create your own PicoShare server.",
  );
});

Playwright에서는 당연하게 동작하는 텍스트 비교

Playwright에서는 순진한 검증이 기대한 대로 정확히 동작한다:

await expect(page.locator("data-test-id=github-instructions")).toHaveText(
  "Visit our GitHub repo to create your own PicoShare server.",
);

Playwright도 요소의 textContent를 보지만, 브라우저처럼 공백을 자동으로 다듬고 합쳐 준다.

Cypress보다 훨씬 간단한 문법으로 Playwright가 대신 innerText를 보도록 강제할 수도 있다:

await expect(page.locator("data-test-id=github-instructions")).toHaveText(
  "Visit our GitHub repo to create your own PicoShare server.",
  { useInnerText: true },
);

Playwright는 이름이 비슷해 거의 동일해 보이는 API가 두 개 있어 약간 감점이다:

  • toHaveText: “Ensures the Locator points to an element with the given text. You can use regular expressions for the value as well.”
  • toContainText: “Ensures the Locator points to an element that contains the given text. You can use regular expressions for the value as well.”

한 API는 요소가 “주어진 텍스트를 가지고” 있음을 검증하고, 다른 API는 요소가 “주어진 텍스트를 포함”함을 검증한다? “가지고 있는” 텍스트와 “포함하는” 텍스트의 차이가 뭘까?

문서를 더 읽어 보니 차이는 요소의 자식 요소에 대해 무엇을 기대하는지에 대한 미묘한 차이로 귀결되는 것 같지만, 문서는 분명 개선이 필요하다.

Playwright는 shadow DOM 탐색을 더 쉽게 만든다

나는 HTML 커스텀 엘리먼트를 이용해 웹 앱을 많이 만들기 때문에 코드에 중첩된 shadow DOM이 자주 들어간다.

Cypress에서는 shadow DOM 안의 페이지 요소를 지정하는 게 다소 어색하다. shadow DOM 경계를 만날 때마다 CSS 선택자를 끊어야 하기 때문이다:

cy.get("#upload-result upload-links")
  .shadow()
  .find("#verbose-link-box")
  .shadow()
  .find("#link")
  .should("be.visible");

Playwright는 기본적으로 shadow DOM을 관통하므로 CSS 선택자가 간결해진다:

await expect(
  page.locator("#upload-result upload-links #verbose-link-box #link"),
).toBeVisible();

Playwright는 앱을 직접 실행해 준다

Cypress의 이상한 설계 결정 중 하나는 앱을 대신 실행해 주지 않는다는 것이다. 앱을 직접 실행하는 방법을 알아낸 뒤, 앱이 서빙을 시작한 뒤에 Cypress 테스트가 시작되도록 오케스트레이션을 스스로 해야 한다.

Playwright는 이런 오케스트레이션 고민을 없애고 앱을 실행하기 위한 간단한 설정 옵션을 제공한다. PicoShare에서의 모습은 다음과 같다:

webServer: {
  command: "PS_SHARED_SECRET=dummypass PORT=6001 ./bin/picoshare",
  port: 6001,
},

Playwright의 로깅은 실제로 동작한다

Cypress의 큰 고충 중 하나는 터미널로 디버그 로그를 남기지 못한 채 살아가는 법을 배워야 한다는 것이다. Cypress에는 stdout이나 stderr로 출력하는 공식적인 방법이 없다.

console.log 호출을 넣어도 아무 일도 일어나지 않는다:

console.log("hello from Cypress"); // this does nothing

Cypress에는 자체 cy.log API가 있으니 그걸 써 보면 어떨까?

cy.log("hello from Cypress"); // this prints nothing to the terminal

그것도 안 된다. 그건 Cypress 데스크톱 GUI나 Cypress의 독자적인 SaaS 대시보드 안에서만 출력을 보여준다.

Cypress 개발자 Zach Bloomquist가 브라우저 콘솔 출력을 터미널에 찍어 주는 비공식 플러그인을 공개했지만, 서드파티 플러그인일 뿐 Cypress가 공식 지원하는 것은 아니다.

Playwright에서는 console.log가 그냥 동작한다. 번거로울 게 전혀 없다:

console.log("hello from Playwright");

테스트를 실행하면 터미널 출력에서 로그 메시지를 볼 수 있다:

[chromium] › auth.spec.ts:3:1 › logs in and logs out
hello from Playwright

Playwright 팀은 리소스에 쪼들리는 느낌이 없다

Cypress 코어 저장소에는 2,782개의 열린 버그가 있고, 그중 일부는 수년간 방치된 중요한 기능 요청이다. 가끔 사람들이 플러그인으로 빈틈을 메우기도 하지만, Cypress 코어가 현대 웹 개발 속도를 따라갈 리소스가 부족하다는 느낌이 자주 든다.

1년 전에 Cypress에 논란의 여지 없는 PR을 올렸지만 아직까지 아무런 응답도 받지 못했다. 외부 풀 리퀘스트를 검토할 리소스조차 없는 게 아닐까 싶다.

반면 Playwright는 비슷한 양의 버그 리포트를 받으면서도 열린 버그가 603개에 불과하다. 내가 Playwright에 버그를 제보했을 때는 하루도 안 돼 분류하고 의미 있는 답변을 줬다.

Playwright는 VS Code와 더 잘 통합된다

Playwright는 공식 VS Code 플러그인을 제공하는데, 문맥을 인식하는 자동 완성을 지원한다. Playwright에서 보기 전까지는 Cypress에서 그게 없다는 걸 깨닫지 못했을 정도다:

VS Code에서 Playwright API 자동 완성 옵션

Playwright의 VS Code 플러그인은 문맥을 인식하는 자동 완성을 제공한다.

Cypress에는 함수가 몇 개뿐이고, 특별한 문자열 값을 넘겨 다양한 기능을 실행한다. IDE가 이런 문법을 돕기는 어렵다. 반면 Playwright는 명시적인 TypeScript 함수 목록을 제공하므로 IDE가 도움을 주기 쉽다. Cypress용 서드파티 VS Code 플러그인은 있지만 Cypress 팀이 공식 지원하는 것은 없다.

Playwright에서는 병렬 테스트가 무료다

이론상 Cypress에서도 병렬 테스트를 무료로 실행할 수 있지만, 의도적으로 불편하게 만들어 놨다. 병렬 테스트가 Cypress 유료 SaaS의 대표 기능 중 하나라 무료 버전을 더 쓸모 있게 만들면 손해를 보니, 탓할 수는 없다.

Microsoft는 Cypress보다 주머니가 훨씬 두둑하니 Playwright의 모든 기능을 무료로 풀 수 있다. 그래서 Playwright는 병렬 테스트를 기본으로 지원한다.

Cypress가 그리운 점

Cypress의 문법은 더 일관되게 유창하다

Cypress와 Playwright 모두 유창한(fluent) 스타일의 API를 제공한다. 일련의 동작을 하나의 문장으로 이어 붙이는 방식이다.

Cypress는 유창한 스타일을 더 엄격하게 지켜서, 개발자가 테스트 로직을 왼쪽에서 오른쪽으로 읽어 내려갈 수 있게 한다.

cy.get(".navbar-item [data-test-id='log-in']").should("be.visible");

Cypress에서는 코드를 작성하는 순서가 테스트를 생각하는 순서와 일치한다. 먼저 요소에 대한 참조를 가져오고, 그 다음 어떤 검증을 할지 생각한다.

Playwright에서는 순서가 약간 뒤섞인다. 테스트하려는 요소를 찾기 시작하기 전에 코드를 expect 호출로 감싸야 한다:

await expect(
  page.locator(".navbar-item [data-test-id='log-in']"),
).toBeVisible();

Playwright의 문법은 Cypress에서 익숙해진 왼쪽에서 오른쪽으로 읽는 흐름을 끊는다. Playwright 문법이 이렇게 생겼으면 좋겠다:

// INVALID - not how Playwright actually behaves
await page
  .locator(".navbar-item [data-test-id='log-in']")
  .expect()
  .toBeVisible();

Cypress는 작고 독립적인 팀이 만든다

나는 오픈소스 기업으로서 Cypress, 특히 엔지니어링 부사장인 Gleb Bahmutov를 개인적으로 좋아한다. Gleb은 수준 높은 블로그 글을 쓰고, 훌륭한 컨퍼런스 연사이기도 하다.

내가 Cypress에 대한 블로그 글을 썼을 때 Gleb은 글을 개선할 수 있도록 친절하게 피드백을 줬다. 글을 발행한 뒤에는 Cypress가 자사 블로그에서 내 글을 소개해 주기도 했다.

반면 Microsoft는 역사적으로 오픈소스에 적대적이었다. 지금은 우호적인 시기이지만, 바람이 바뀌어 오픈소스를 짓밟는 게 더 돈이 된다고 판단하면 아마 그렇게 할 것이다.

이게 영화라면 Cypress는 응원하지 않을 수 없는 끈질긴 약자이고, Microsoft는 3막에서 주인공을 배신할 것 같은 갱생한 악당일 것이다.

Cypress의 테스트 산출물은 CI에서 잘 동작한다

Cypress 테스트가 실패하면 실패 지점에서 앱을 스크린샷으로 찍어 디스크에 저장한다. CI 플랫폼에서 이 이미지를 테스트 산출물로 보관하도록 설정하면 디버깅이 쉽다. 마찬가지로 각 테스트의 비디오를 저장해 CI 테스트 산출물로 게시할 수도 있다.

CircleCI 대시보드 아티팩트 탭의 Cypress 비디오 파일 스크린샷

Cypress는 CI 산출물로 쉽게 볼 수 있는 테스트 산출물을 생성한다

Playwright는 좀 더 복잡한 형태의 테스트 산출물을 만든다. 단순한 이미지와 비디오 대신, 모든 테스트 산출물을 보기 위한 정적 웹 앱을 생성한다.

안타깝게도 Playwright의 리포트 뷰어는 CircleCI에서 동작하지 않아, CircleCI 대시보드에서 바로 보는 대신 에셋을 다운로드해 로컬에서 Playwright 서버를 실행해야 한다.

Cypress의 Docker 이미지는 실제로 소프트웨어를 포함한다

엔드투엔드 테스트 도구에서만 본 패턴인데, Cypress와 Playwright의 공식 Docker 이미지는 실제로 도구 자체를 포함하지 않는다. 즉, Cypress Docker 이미지에는 Cypress가 없고, Playwright Docker 이미지에도 Playwright가 없다.

대신 Docker 이미지에는 각각 Cypress나 Playwright를 설치하는 데 필요한 의존성만 들어 있다. 그래서 Playwright Docker 이미지를 실행하면서도 환경 설정 과정의 일부로 Playwright를 따로 설치해야 한다.

분명 타당한 이유가 있겠지만, 나는 이해한 적이 없다. 이 점을 Cypress 팀에 불평했더니, Cypress 도구 자체를 포함하는 특별한 cypress/included 이미지를 추가해 줬다. Playwright에는 이에 상응하는 Docker 이미지가 없는 것 같다.

요약

Playwright를 쓴 지 몇 시간밖에 되지 않았지만, Cypress보다 훨씬 나은 경험이었다. 더 명확한 API, 더 간단한 테스트 설정, 그리고 속도 덕분에 Playwright에서는 Cypress에서보다 50~100% 더 생산적인 것 같다.

앞으로는 모든 신규 앱을 Playwright로 테스트할 생각이다. 테스트 실행 시간이 5분을 넘어선 앱들의 기존 Cypress 테스트도 Playwright로 옮길 가능성이 높다.

Cypress 사용자라면 Playwright를 꼭 한번 살펴보길 강력히 권한다. 내게 Cypress에서 Playwright로의 도약은 Selenium에서 Cypress로 옮겨갈 때만큼이나 크다.

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

댓글