On Migrating from Cypress to Playwright

Michael Lynch

從 Cypress 遷移至 Playwright

Cypress 是一套用於端對端測試網頁應用程式的開源工具。我在 2018 年於紐約的一場網頁開發聚會上首次看到 Gleb Bahmutov(葛雷布·巴穆托夫)展示 Cypress,當時令我驚艷不已。

Cypress 現場展示截圖

自從 2018 年在開發者聚會上看到展示後,我就一直使用 Cypress。

在認識 Cypress 之前,我勉強使用著 Selenium。Cypress 是一大躍進,令人耳目一新,它針對讓 Selenium 難以實用的諸多痛點,提供了優雅的解決方案。

最近我嘗試了 Playwright,也就是 Microsoft 對 Cypress 的回應方案。在試用一天後,我已經準備好要完全從 Cypress 轉向 Playwright。

這樣說讓我有點難過,因為我對 Cypress 那個小而拼勁十足的團隊很有好感。我當然也不熱衷於依賴像 Microsoft 這樣的龐大企業,但 Playwright 實在好太多了,讓我無法再堅持使用 Cypress。

接下來是我趁記憶猶新時,記錄下從 Cypress 轉換至 Playwright 的心得筆記。

我先前使用 Cypress 與 Playwright 的經驗

在過去四年中,我為自己打造的幾乎每個網頁應用程式都撰寫了 Cypress 端對端測試。我自評為中階的 Cypress 使用者。我的大多數需求都很單純,只會用到基本的 API。我從未撰寫過自訂外掛,但有使用過幾個第三方外掛。

我只使用 Playwright 一天。為了實際動手體驗,我嘗試將其中一個應用程式的測試套件從 Cypress 移植到 Playwright。我選擇了 PicoShare,這是我開發的極簡檔案分享工具,它只有 10 個端對端測試。包含學習 Playwright API 的時間在內,我在大約五個開發工時內就將它們全數從 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 下載並解壓縮約 1 GB 的映像檔。

cypress/included:10.9.0playwright:v1.26.0-focal-amd64
大小940 MB651 MB

Playwright 提供一致的斷言 API

Cypress 在其工具中綑綁了九個不同的第三方函式庫,形成了一套不一致、混雜的 API。有 shouldexpectassert,你得根據所處的脈絡使用不同的關鍵字。

例如,以下兩段程式碼執行的是完全相同的斷言:

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 不依賴圖形介面環境

Cypress 最為人稱道的功能之一,就是它的桌面圖形介面應用程式:

Cypress 桌面應用程式截圖

Cypress 使用桌面應用程式來顯示測試執行過程

Cypress 桌面應用程式讓你可以對測試進行「時光回溯」,如此便能在測試的每個時間點查看瀏覽器視窗的樣貌。

但如果你是在沒有圖形介面的環境下開發呢?我的所有開發工作都在無頭伺服器虛擬機器上進行。在使用 Cypress 的四年中,我從未使用過它的桌面應用程式。相反地,我都在 Docker 容器內執行 Cypress,而這對於一個預設你在其桌面圖形介面中工作的工具來說,有時反而成了阻礙。

當你試圖在 CI 環境中執行 Cypress 測試時,圖形介面的問題又會再次浮現。那裡通常也沒有桌面圖形介面。Cypress 的解法是使用他們的付費 CI 服務,而這也是該公司主要的資金來源。

我支持企業以任何方式將其開源產品變現,但 Cypress 的 CI 產品從未吸引過我。我希望能夠在本地使用 Docker 容器重現我的 CI 環境。Playwright 讓我能做到這點,但 Cypress 的 CI 服務卻不行。

為了在 CircleCI 上執行 Cypress,我得對 Docker Compose 進行一些額外的處理。雖然額外負擔不算太嚴重,但確實讓測試架構變得比我期望的複雜一些。

當我嘗試 Playwright 時,使用一套為無頭執行而設計的工具,實在讓人耳目一新。我不必為了在 CI 中執行 Playwright 而耍任何花招,因為它在無頭環境中開箱即用。

Playwright 擁有與 Cypress 相同的時光回溯功能,但它是透過網頁介面而非桌面圖形介面來實作,因此能在更多環境中運作。

時光回溯真的很棒!Playwright 的快照甚至不只是應用程式的靜態截圖。你可以在測試的每個階段與瀏覽器互動,感覺有點像魔法。

Playwright 的網頁介面讓你可以回溯至應用程式執行的不同狀態,並與頁面上的任何元素互動。

Playwright 的功能缺口較少

Cypress 讓人能輕鬆上手基本的端對端測試,但我發現隨著應用程式逐漸成長,我經常會在測試工具中遇到功能缺口。

舉例來說,我新增了檔案上傳功能後,才發現 Cypress 無法測試檔案上傳。我得停下手邊工作,去尋找第三方 Cypress 外掛來填補這個缺口。

就在我撰寫本文時,我發現 Cypress 在今年稍早新增了對檔案上傳的原生支援,但令人費解的是,他們竟花了七年才支援這個極為常見的情境。

同樣地,如果你想模擬滑鼠懸停——這個幾乎每個網頁介面框架都有的功能——Cypress 做不到。該錯誤回報已經開了將近八年。

我相信 Playwright 也有它自己的功能缺口,但在我用一天時間將測試從 Cypress 移植到 Playwright 的過程中,一個也沒遇到。我測試套件中為填補 Cypress 缺口所做的所有變通方法,在 Playwright 中都有原生解法。

Playwright 所需的領域特定知識較少

當初發現 Cypress 時,吸引我的其中一點是它是為 JavaScript 而設計,而 Selenium 則是以 Java 為主。

對於基本測試而言,熟悉 JavaScript 的人會覺得 Cypress 的語意自然且熟悉。但當你偏離常見路徑時,Cypress 突然就不那麼像 JavaScript,而更像一套自有的領域特定框架。

舉例來說,我的應用程式 PicoShare 有一項功能,可為你想與未驗證使用者分享的檔案產生網址。為了測試這項功能,我需要操作 PicoShare 的分享流程、登出使用者工作階段,然後驗證瀏覽器仍能存取稍早幾個步驟所產生的網址。

以下是當初我在 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,所以可能會以為 invoke 回傳了一個 Promise。但如果你試圖 await 這個 promise,它會回傳 undefined,因為 Cypress 實際上回傳的只是假裝成 Promise 的東西。

這看似不是什麼大問題,但如果你需要動態參照應用程式中的某個值,Cypress 會迫使你為每個需要的值新增一層巢狀閉包。有一個廣受支持、要求支援 await 的功能請求,但在四年內毫無進展,而 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 的值,真的就是可以 awaitPromise,因此程式碼更為簡潔。

在 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 簡單得多的語法,強制 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:「確保 Locator 指向具有給定文字的元素。你也可以對該值使用規則運算式。」
  • toContainText:「確保 Locator 指向包含給定文字的元素。你也可以對該值使用規則運算式。」

其中一個 API 用於斷言元素「具有給定文字」而存在,另一個則斷言元素「包含給定文字」而存在?「擁有」文字和「包含」文字究竟有什麼差別?

進一步閱讀文件後,差異似乎在於你對元素子元素的期望有細微不同,但文件確實還有改進空間。

Playwright 讓操作 shadow DOM 變得更簡單

我經常使用 HTML 自訂元素來撰寫網頁應用程式,因此我的程式碼中常常有巢狀的 shadow DOM(陰影 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 中的設定樣貌,如 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 桌面圖形介面或 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 核心團隊根本沒有足夠資源跟上現代網頁開發的步伐。

一年前我向 Cypress 提交了一個毫無爭議的 PR,他們至今仍未回應。我懷疑他們只是沒有資源來審查外部的 pull request。

相較之下,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 都提供了流暢風格的 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 作為一家開源公司的表現,特別是他們的工程副總裁葛雷布·巴穆托夫。葛雷布·巴穆托夫發表了高品質的部落格文章,也是一位出色的研討會講者。

當我撰寫一篇關於 Cypress 的部落格文章時,葛雷布·巴穆托夫慷慨地分享了改善文章的回饋。在我發布後,Cypress 還在他們的部落格上推廣了我的文章。

另一方面,Microsoft 歷史上曾對開源抱持敵意。他們現在處於友善的時期,但如果風向改變,而他們發現透過打壓開源能賺更多錢,他們很可能就會這麼做。

如果這是一部電影,Cypress 會是那個讓人忍不住想為其加油、奮力拼搏的弱勢一方,而 Microsoft 則是那個改過自新的反派,很可能在第三幕背叛主角。

Cypress 的測試產物在 CI 中運作良好

當 Cypress 測試失敗時,它會在失敗當下為你的應用程式截圖並將圖片儲存至磁碟。很容易就能設定你的 CI 平台將這些圖片保留為測試產物,以便除錯。同樣地,Cypress 讓你能儲存每個測試的影片,也可以將其作為 CI 測試產物發布。

在 CircleCI 儀表板的產物分頁中顯示的 Cypress 影片檔案截圖

Cypress 產生的測試產物易於作為 CI 產物檢視

Playwright 產生的測試產物則較為複雜。Playwright 並非產生簡單的圖片和影片,而是產生一個用於檢視所有測試產物的靜態網頁應用程式。

不幸的是,Playwright 的報告檢視器在 CircleCI 上無法運作,因此我必須下載資源並在本地執行 Playwright 伺服器,而無法直接從 CircleCI 儀表板檢視。

Cypress 的 Docker 映像檔真的包含了軟體本身

在一種我只在端對端測試工具中見過的模式裡,Cypress 與 Playwright 的官方 Docker 映像檔實際上並未包含工具本身。也就是說,Cypress 的 Docker 映像檔並未包含 Cypress,而 Playwright 的 Docker 映像檔也未包含 Playwright。

相反地,這些 Docker 映像檔包含的是你分別安裝 Cypress 或 Playwright 所需的相依套件。因此,當你執行 Playwright 的 Docker 映像檔時,仍必須在環境設定中安裝 Playwright。

這背後一定有什麼好理由,但我從未理解。當我向 Cypress 團隊抱怨這點時,他們新增了一個特殊的 cypress/included 映像檔,其中包含了 Cypress 工具本身。Playwright 似乎沒有對等的 Docker 映像檔。

總結

儘管我使用 Playwright 僅數小時,我發現它的體驗比 Cypress 好上許多。憑藉更清晰的 API、更簡單的測試設定以及速度,我在 Playwright 上的生產力可能比在 Cypress 時高出 50 至 100%。

往後,我所有的新應用程式都將使用 Playwright 進行測試。對於測試時間已超過五分鐘的應用程式,我甚至可能會將一些舊的 Cypress 測試移植到 Playwright。

如果你是 Cypress 的使用者,我強烈建議你試試 Playwright。對我而言,從 Cypress 躍升至 Playwright 的幅度,就如同當初從 Selenium 躍升至 Cypress 一樣大。

原文由 Michael Lynch 發布

本文章由 muse-spark-1.2-contributor 進行翻譯