On Migrating from Cypress to Playwright

Michael Lynch

談從 Cypress 遷移到 Playwright

原文由 Michael Lynch 發布,訂閱此部落格

Cypress 是一個用於端對端測試網頁應用程式的開源工具。我在 2018 年紐約的一場網頁開發聚會上第一次看到 Gleb Bahmutov 展示 Cypress,當下驚為天人。

Cypress 即時展示截圖

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

在認識 Cypress 之前,我是心不甘情不願地用著 Selenium。Cypress 帶來讓人耳目一新的躍進,它用優雅的方式解決了大量讓 Selenium 難以實用的痛點。

最近我嘗試了 Playwright,也就是微軟對 Cypress 的回應。試用一天後,我已經準備好要完全從 Cypress 轉換到 Playwright。

這麼說讓我有點難過,因為我對 Cypress 那個小而拚勁十足的團隊一直很有好感。我當然一點也不想多依賴微軟這種龐大的巨頭企業,但 Playwright 實在好太多了,讓我找不到繼續留守 Cypress 的理由。

接下來是我趁記憶猶新,記下從 Cypress 轉換到 Playwright 的筆記。

我與 Cypress 和 Playwright 的過往經驗

過去四年來,我為自己開發的幾乎每個網頁應用程式都寫過 Cypress 端對端測試。我會把自己評為中階的 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%

效能差異有一部分來自 Playwright 的 Docker 容器比 Cypress 的小得多。對本地開發來說這不是什麼大問題,因為只要下載一次就好。但當我在 CI 上跑 Cypress 時,每次都得等 CircleCI 下載並解壓縮將近 1 GB 的映像檔。

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

Playwright 提供一致的斷言語法

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 最廣為宣傳的功能之一就是它的桌面 GUI 應用程式:

Cypress 桌面應用程式截圖

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

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

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

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

我支持公司用任何方式將開源產品變現,但 Cypress 的 CI 產品從來沒有吸引過我。我希望能用 Docker 容器在本地重現 CI 環境。Playwright 讓我做得到,但 Cypress 的 CI 服務不行。

為了在 CircleCI 上執行 Cypress,我得用 Docker Compose 做一些額外的調整。雖然不是什麼過於繁重的負擔,但確實讓測試架構變得比我希望的複雜了一點。

當我嘗試 Playwright 時,能用到一個為無頭執行而設計的工具,真是讓人耳目一新。我不需要做任何取巧的設定就能在 CI 中執行 Playwright,因為它在無頭環境中開箱即用。

Playwright 也有和 Cypress 一樣的時光回溯功能,但它是用網頁介面而非桌面 GUI 來實作,所以能在更多環境中運作。

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

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

Playwright 的功能缺口更少

Cypress 讓人很容易就能上手基本的端對端測試,但我發現隨著應用程式越來越大,我經常會遇到測試工具的功能缺口。

例如,我加入了檔案上傳功能,才發現 Cypress 無法測試檔案上傳。我得停下手邊的工作,去找第三方的 Cypress 外掛來補這個洞。

在寫這篇文章時,我才發現 Cypress 在今年稍早終於加入了對檔案上傳的原生支援,但讓人費解的是,他們竟然花了七年才支援這個極為常見的情境。

同樣地,如果你想模擬滑鼠懸停這個幾乎每個網頁 UI 框架都有的功能,Cypress 也做不到。這個問題已經掛了將近八年。

我相信 Playwright 也有自己的功能缺口,但在我花一天從 Cypress 移植測試到 Playwright 的過程中,一個也沒遇到。原本為了填補 Cypress 缺口而在測試套件中做的各種變通方法,在 Playwright 中都有原生解法。

Playwright 需要的特定領域知識更少

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

對於基本的測試來說,Cypress 的語意對懂 JavaScript 的人而言自然又熟悉。但一旦你偏離了常見路徑,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

在 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 核心團隊根本沒有足夠的資源跟上現代網頁開發的步伐。

一年前我向 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 工具的主打功能之一,把免費版做得更好用反而會讓他們少賺錢。

微軟的口袋比 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 這家開源公司,尤其是他們的工程副總 Gleb Bahmutov。Gleb 發表了許多高品質的部落格文章,也是非常出色的研討會講者。

當我寫了一篇關於 Cypress 的部落格文章時,Gleb 很熱心地提供了回饋來幫我改進文章。文章發布後,Cypress 還在他們的部落格上幫我推廣。

另一方面,微軟在歷史上曾對開源抱持敵意。他們現在正處於友善的時期,但如果風向一變,發現打壓開源能賺更多錢,他們大概還是會那麼做。

如果這是一部電影,Cypress 會是那個讓人忍不住為他加油的拚搏小蝦米,而微軟則是那個改過自新的反派,很可能在第三幕背叛主角。

Cypress 的測試產物在 CI 中可用

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

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

Cypress 產生的測試產物很容易以 CI 產物的形式檢視

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 一樣大。

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

留言