On Migrating from Cypress to Playwright

Michael Lynch

CypressからPlaywrightへの移行について

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にはスポンサー制度が用意されていませんでした。

Playwrightの気に入っている点

PlaywrightはCypressより大幅に高速です

CircleCI上では、同等のCypressテストと比べてPlaywrightのテストスイートは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は一貫性のあるアサーションを提供します

Cypressは9つの異なるサードパーティライブラリをバンドルしているため、APIが寄せ集めで一貫性がありません。shouldexpectassertが混在し、文脈によって使い分けるキーワードが変わります。

たとえば、次の2つのコードスニペットは同じアサーションを行います。

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上で行っています。Cypressを4年間使ってきましたが、デスクトップアプリを使ったことは一度もありません。その代わり、CypressをDockerコンテナ内で実行していますが、これはデスクトップGUIでの作業を前提としたツールにとっては、ときに障害になります。

このGUIの問題は、CI環境でCypressのテストを実行しようとしたときにも再び顔を出します。そこにも一般的にデスクトップGUIはありません。Cypressの回答は、同社の有料CIサービスを使うことであり、それが同社の主な収益源となっています。

オープンソース製品をどのように収益化するかは企業の自由だと思っていますが、CypressのCI製品は私には魅力的ではありませんでした。CI環境をDockerコンテナでローカルに再現したいのです。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中心だったのに対し、Cypressは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を取得すれば回避できますが、まったく別のアサーション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よりもはるかにシンプルな構文で、代わりにinnerTextを見るようにPlaywrightに指示することもできます。

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: 「Locatorが指定されたテキストを持つ要素を指していることを保証します。値には正規表現も使えます。」
  • toContainText: 「Locatorが指定されたテキストを含む要素を指していることを保証します。値には正規表現も使えます。」

一方の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();

追記(2022-10-26): Redditユーザーの/u/Daffodils2が指摘しているように、CypressにはincludeShadowDomオプションがあり、これを使うとShadow DOM越しの要素選択でPlaywrightと同じように振る舞います。

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にバグを報告した際も、1営業日以内にトリアージされ、的確な返答をもらえました。

PlaywrightはVS Codeとの連携が優れています

Playwrightは公式のVS Codeプラグインを提供しており、コンテキストに応じた自動補完が使えます。Playwrightでそれを見るまで、Cypressでそれが欠けていることに気づきもしませんでした。

Playwright APIのVS Codeにおける自動補完候補

PlaywrightのVS Codeプラグインはコンテキストに応じた自動補完を提供します。

Cypressでは関数自体の数は少なく、特別な文字列値を渡すことで異なる機能を使い分けます。IDEがそうしたセマンティクスを支援するのは困難です。一方、Playwrightは明示的なTypeScript関数を列挙しているため、IDEがサポートしやすくなっています。Cypress用のサードパーティ製VS Codeプラグインはありますが、Cypressチームが公式にサポートしているものはありません。

Playwrightでは並列テストが無料です

理論上、Cypressでも並列テストを無料で実行できますが、意図的に不便に作られています。並列テストはCypressの有料SaaSツールの目玉機能の一つなので、無料版を便利にすれば収益が減るわけで、責める気にはなれません。

MicrosoftはCypressとは比べものにならないほど資金が豊富なので、Playwrightのすべての機能を無料で提供する余裕があります。そのため、Playwrightは並列テストを最初からサポートしています。

Cypressで恋しくなる点

Cypressの構文はより一貫して流暢です

CypressもPlaywrightも、流暢なスタイルの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、とりわけエンジニアリング担当VPのGleb Bahmutov氏に個人的な敬意を抱いています。Gleb氏は質の高いブログ記事を公開し、優れたカンファレンススピーカーでもあります。

私がCypressについてのブログ記事を書いたとき、Gleb氏は記事を改善するためのフィードバックを惜しみなくくれました。公開後には、Cypressが自社のブログで私の記事を紹介してくれました。

一方のMicrosoftは、歴史的にオープンソースに対して敵対的でした。今はフレンドリーな時期にありますが、風向きが変わり、オープンソースを潰した方が儲かると判断すれば、きっとそうするでしょう。

これが映画なら、Cypressは思わず応援したくなる気骨のある弱者で、Microsoftは第三幕で主人公を裏切りそうな更生した悪役といったところでしょう。

Cypressのテスト成果物はCIでうまく動きます

Cypressのテストが失敗すると、失敗時点のアプリのスクリーンショットを撮ってディスクに保存します。CIプラットフォームでこれらの画像をテスト成果物として保持するように設定するのは簡単で、デバッグに役立ちます。同様に、Cypressでは各テストの動画を保存し、それも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への移行と同じくらい大きな飛躍でした。

原文は Michael Lynch により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。