On Migrating from Cypress to Playwright

Michael Lynch

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

原文は Michael Lynch により に公開されました。 このブログを購読する

CypressはWebアプリケーションをエンドツーエンドでテストするためのオープンソースツールです。2018年にニューヨークで開かれたWeb開発のミートアップでGleb Bahmutov氏によるCypressのデモを初めて見たとき、大きな衝撃を受けました。

Cypressのライブデモのスクリーンショット

2018年の開発者ミートアップでデモを見て以来、Cypressを使い続けています。

Cypressに出会う前は、仕方なくSeleniumを使っていました。CypressはSeleniumを使う上で不便だった数多くのペインポイントをエレガントに解決してくれ、爽快なほどの進歩でした。

最近、MicrosoftによるCypressへの回答ともいえるPlaywrightを試してみました。1日ほど触ってみた結果、CypressからPlaywrightへ完全に乗り換える決心がつきました。

正直、そう言うのは心苦しいのです。Cypressの小規模でハングリーなチームには愛着があります。Microsoftのような巨大企業への依存を増やすことにはまったく乗り気ではありませんが、Playwrightがあまりにも優れているため、Cypressに留まる理由が見つかりません。

以下は、記憶がまだ新しいうちにまとめておいた、CypressからPlaywrightへ移行する際のメモです。

CypressとPlaywrightに関するこれまでの経験

過去4年間に作ったほぼすべてのWebアプリで、Cypressによるエンドツーエンドテストを書いてきました。自分のCypressスキルは中級程度だと思っています。求められることのほとんどはシンプルで、基本的なAPIしか使いません。カスタムプラグインを自作したことはありませんが、サードパーティ製のものをいくつか使ったことはあります。

Playwrightの使用歴はまだ1日だけです。実際に手を動かすため、あるアプリのテストスイートをCypressからPlaywrightへ移植してみました。選んだのは、エンドツーエンドテストが10件しかないミニマルなファイル共有ツールPicoShareです。PlaywrightのAPIを学ぶ時間も含めて、約5時間ですべてをCypressからPlaywrightへ移植できました。

私はCypressにもPlaywrightにもお金を払ったことがないので、どちらのツールに対しても何かを求める立場にはありません。Cypressには有料のSaaSコンポーネントがありますが、自分のワークフローには合わないため購入したことはありません。普段使っている他のオープンソースプロジェクトのように喜んでスポンサーになりたかったのですが、Cypressにはスポンサー制度自体がありませんでした。

Playwrightの気に入っている点

PlaywrightはCypressより圧倒的に高速

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

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

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

オープンソース製品をどのようにマネタイズするかは企業の自由だと思っていますが、CypressのCI製品は私には魅力的ではありませんでした。CI環境をDockerコンテナでローカルに再現したいのです。Playwrightならそれができますが、CypressのCIサービスではできません。

CircleCIでCypressを動かすために、Docker Composeで少し工夫する必要がありました。そこまで大きなオーバーヘッドではありませんが、テストスタックが理想より少し複雑になってしまいます。

Playwrightを試したとき、ヘッドレスでの実行を前提に設計されたツールを使うのは本当に清々しい体験でした。CIでPlaywrightを動かすのにトリッキーなことをする必要はなく、ヘッドレス環境でそのまま問題なく動作します。

PlaywrightにもCypressと同じタイムトラベル機能がありますが、デスクトップGUIではなくWeb UIで実装されているため、より多くの環境で動作します。

タイムトラベルは本当に便利です!Playwrightのスナップショットは単なる静的なスクリーンショットではありません。テストの各段階でブラウザを操作できるので、まるで魔法のようです。

PlaywrightのWeb UIでは、アプリ実行中の異なる状態へタイムトラベルし、ページ上のあらゆる要素を操作できます。

Playwrightは機能の欠落が少ない

Cypressは基本的なエンドツーエンドテストを手軽に始められますが、アプリが成長するにつれて、テストツール側の機能不足に頻繁にぶつかることに気づきました。

例えば、ファイルアップロード機能を追加した途端、Cypressではその機能をテストできないことに気づきます。作業を中断して、その穴を埋めるサードパーティ製のCypressプラグインを探しに行くことになります。

この記事を書いている最中に、Cypressが今年に入ってファイルアップロードのネイティブサポートを追加したことを知りましたが、極めて一般的なユースケースのサポートに7年もかかったというのは首をかしげざるを得ません。

同様に、ほぼすべてのWeb UIフレームワークに存在するマウスホバーのシミュレーションをしたい場合、Cypressではできません。このバグは8年近く放置されたままです。

Playwrightにももちろん機能の欠落はあるでしょうが、CypressからPlaywrightへテストを移植した1日の中で、私は一度も遭遇しませんでした。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のような値も、本物のPromiseなのでawaitでき、コードがすっきりします。

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を見ますが、ブラウザと同様に自動で空白をトリムし、連続する空白を1つにまとめます。

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は、名前が似ていて一見同じように見える2つのAPIがある点で少し減点です。

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

一方のAPIは要素が「指定されたテキストを持って」存在することをアサートし、もう一方は要素が「指定されたテキストを含んで」存在することをアサートします。「テキストを持っている」ことと「テキストを含んでいる」ことの違いは何なのでしょうか?

ドキュメントをさらに読み込むと、その違いは要素の子要素に対して何を期待するかの微妙な差に帰着するようですが、ドキュメントは確実に改善の余地があります。

Playwrightの方がShadow DOMの操作が簡単

私はHTMLカスタム要素を使ってWebアプリを書くことが多いので、コードにはネストされた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コアにはモダンなWeb開発のペースについていくためのリソースが単純に不足しているように感じられることがよくあります。

1年前にCypressに異論の余地のないPRを送りましたが、まだ何の反応もありません。外部からのプルリクエストをレビューするリソースがないだけなのだろうと思います。

対照的に、Playwrightのオープンなバグは603件だけで、バグ報告の量はほぼ同じであるにもかかわらずです。私がPlaywrightにバグを報告したときは、1営業日以内にトリアージされ、有意義な返答をもらえました。

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も、一連の操作を1つのステートメントにつなげる流暢なスタイルの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はすべてのテスト成果物を閲覧するための静的なWebアプリを生成します。

残念ながら、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」を使用して翻訳されました。

コメント