End-to-End Testing Web Apps: The Painless Way

Michael Lynch

Web App 端對端測試:無痛指南

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

好吧,我知道你半信半疑。其他指南也都宣稱能讓你無痛測試 Web App,結果最後才發現,解法得綁定某種超特定的技術堆疊,或是要付費使用第三方服務。我不會這樣對你。

這份指南提供一個簡單又有彈性的端對端測試範本,幾乎可以套用到任何 Web App。 唯一的要求,就是你的 App 能在 Docker 上執行。

真的就只有這個要求!不管是 Ruby App、React App、Enterprise Java Beans App,甚至是你自己拼湊出來的古怪 Web 技術堆疊,都能測試。不管你是在 Windows、Linux 還是 Mac 上開發都沒差。最棒的是,你不需要做繁瑣的設定,除了 Docker 之外也不用再安裝任何軟體。

這篇教學使用的是免費的開源工具,你不需要在任何地方註冊帳號就能使用。之後要在 Circle 或 Travis 這類持續整合環境中執行測試時,也不需要做任何特殊設定——只要用跟你在開發機器上一樣的一行指令就能跑測試。

Cypress,重頭戲登場

更新(2022-10-25):我不再推薦使用 Cypress 來做 Web 應用程式的端對端測試。新專案我會推薦改用 Playwright

讓這種測試得以實現的工具就是 Cypress,它是瀏覽器自動化領域的後起之秀。這是一套開源的端對端測試框架,背後有一支全職團隊持續開發。它們的商業模式跟 Docker 很像,都是發布免費的開源工具,再透過販售託管服務來支撐開發。

Cypress 標誌

Cypress 是一套用於 Web App 自動化測試的開源工具。

我去年在一場區域性的軟體研討會上,看到 Gleb Bahmutov 展示 Cypress,才第一次認識它。當他提到 Cypress 完全不依賴 Selenium 時,我立刻被吸引住了。我過去所有端對端測試的經驗都糟透了,而痛苦的根源永遠都是 Selenium。

Selenium 標誌

Selenium 是歷史最悠久、也最普及的瀏覽器自動化工具,但它笨重又過時。

Selenium 至今仍是目前最熱門的瀏覽器自動化框架,但它也具備你能想像到、一個 15 年前設計的 Java 工具會有的所有問題。安裝麻煩、語法彆扭,測試失敗時提供的資訊也少得可憐。而在 Gleb 展示的那些流暢的 Cypress Demo 中,它承諾能解決所有這些頭痛問題。

Cypress 標誌

Cypress 的一個厲害功能是,它會在測試的每一個步驟錄下瀏覽器畫面,幫你診斷失敗原因。

我興沖沖地去讀了 Cypress 文件,卻有點失望地發現,幾乎所有的文件都假設使用者是 Node.js 技術堆疊,而且是在圖形化環境下開發,而不是無頭(headless)的終端機環境。

不過,Cypress 看起來還是很有前景。一年後,我回去關心它的進展,發現了一個結合 Cypress 與 Docker Compose 的新範例應用。瞬間一切都豁然開朗。一旦看到 Cypress 在 Docker Compose 下運作,就很清楚該如何把這個模式套用到任何 Web App 上。今天,我就要把這個模式以及如何在你的 App 中使用它分享給你。

可重複使用的端對端測試模式

把 Cypress 和 Docker Compose 結合起來,就能產生一個夠靈活、幾乎可套用到任何 Web App 的測試模式。跟其他會對你的 App 實作方式有所假設的測試工具不同,這個解法將測試框架與受測的 App 完全解耦。

Docker 容器架構圖

Docker Compose、Cypress 與 Web App 如何協同運作

Docker Compose 讓你可以在一個容器中執行 Cypress,在另一個容器中執行你的 App。你的 App 完全不需要知道 Cypress 的存在,而 Cypress 唯一需要知道關於你的 App 的事,就是用來發送 HTTP 請求的網路連接埠。

一個用來測試的簡單 Web App

作為測試用的 Web App 範例,我要介紹 Sentimentalyzer:全世界最笨的文字情緒分析器。它會試著從使用者輸入的一段文字來猜測對方的心情。

如果你輸入文字 It's a nice day today,Sentimentalyzer 會判斷你很開心:

在 Sentimentalyzer 中輸入文字Sentimentalyzer 產生的結果

Sentimentalyzer 分析開心語句

如果你輸入文字 Who ate ALL MY WAFFLES?,Sentimentalyzer 就會認為你在生氣:

在 Sentimentalyzer 中輸入文字Sentimentalyzer 產生的結果

Sentimentalyzer 分析生氣語句

演算法很簡單:如果超過 50% 的字元是大寫,就代表使用者在吼叫,所以一定是在生氣。否則,Sentimentalyzer 就會假設使用者心情還不錯。

專案結構

以下是我的範例專案的檔案結構:

main.go               <- source for my web app, Sentimentalyzer
Dockerfile            <- defines how to run Sentimentalyzer in a Docker container
e2e/                  <- folder that contains all the files for my end-to-end tests
  cypress.json        <- Cypress configuration
  docker-compose.yml  <- glue that binds together my app container with the Cypress container
  integration/
    spec.js           <- defines the end-to-end test for Sentimentalyzer

所有的正式環境邏輯都在根目錄下,而所有端對端測試的程式碼則都在 e2e 資料夾中。

在本地端執行 Sentimentalyzer

我在這裡刻意不展示 App 的原始碼,是為了強調一件事:你完全不需要看過 App 本身的實作,就能寫出 Cypress 測試。Sentimentalyzer 剛好是用 Go 寫的,但就算我改用 Python 或 Angular 來實作,測試寫起來也是一樣的。如果你好奇的話,原始碼就在 GitHub 上

想在你的電腦上試玩 Sentimentalyzer,請執行以下指令:

git clone https://github.com/mtlynch/hello-world-cypress.git
cd hello-world-cypress
docker build --tag sentimentalyzer .
docker run \
  --interactive \
  --tty \
  --env PORT=8123 \
  --publish 8123:8123 \
  sentimentalyzer

上述指令會在你的本機上,於 http://localhost:8123 啟動一個 Sentimentalyzer 伺服器。

既然我的 App 已經能在 Docker 容器中執行,接下來就可以用 Cypress 來為它建立端對端測試了。

建立端對端測試

要寫出你的第一個 Cypress 端對端測試,你只需要三個檔案:

  • cypress.json
  • docker-compose.yml
  • integration/spec.js

cypress.json

這個檔案用來指定 Cypress 的設定選項

{
  "pluginsFile": false,
  "supportFile": false
}

下載 cypress.json

這些設定沒什麼特別的,我把它們設為 false,只是為了防止 Cypress 自動產生不必要的輔助檔案。

docker-compose.yml

這個檔案定義了 Sentimentalyzer 的 Docker 容器與 Cypress 的 Docker 容器,並讓它們能夠彼此溝通:

version: "3.2"
services:
  sentimentalyzer:
    build: ../
    environment:
      - PORT=8123
  cypress:
    image: "cypress/included:4.4.0"
    depends_on:
      - sentimentalyzer
    environment:
      - CYPRESS_baseUrl=http://sentimentalyzer:8123
    working_dir: /e2e
    volumes:
      - ./:/e2e

下載 docker-compose.yml

有幾行值得特別說明:

image: "cypress/included:4.4.0"

cypress/includedCypress Docker 映像檔的其中一個系列,映像檔本身就已預先安裝好 Cypress。其他像是 cypress/basecypress/browsers 等系列,則是假設由使用者在執行時才安裝 Cypress。透過使用 cypress/included 映像檔,我就能確保容器一啟動,Cypress 就會立刻執行測試。

depends_on:
  - sentimentalyzer

depends_on 這個區段能確保在 Cypress 開始執行測試之前,Sentimentalyzer 已經啟動並處於可運行狀態。

environment:
  - CYPRESS_baseUrl=http://sentimentalyzer:8123

CYPRESS_baseUrl 這個環境變數告訴 Cypress 要在哪個 URL 存取 Sentimentalyzer。因為 Cypress 和 Sentimentalyzer 執行在同一個 Docker Compose 設定中,Cypress 可以直接用容器名稱(sentimentalyzer)作為主機名稱,向 Sentimentalyzer 發送網路請求。

working_dir: /e2e
volumes:
  - ./:/e2e

最後,我使用 Docker 的 volume 掛載功能,讓 Cypress 的 Docker 容器可以共享主機的部分檔案系統。

主機上 ./e2e 目錄中的所有內容,都會在 Docker 容器中以 /e2e 的路徑呈現。這能確保 Cypress 在執行過程中產生的 log、截圖或影片,能立即在主機上取得,而不需要手動從容器複製到主機。用這種方式綁定主機的 volume,也讓你能輕鬆地編輯並重新執行測試,而不必重新建置整個 Docker 映像檔。

working_dir 這一行則確保 Cypress 會把 /e2e 目錄當成檔案系統中的目前工作目錄。

integration/spec.js

設定都處理完後,接下來就是有趣的部分了:撰寫測試。

it("detects angry sentiment", () => {
  cy.visit("/analyze");

  cy.get("#feelings").type("I REALLY need some COFFEE");
  cy.get("form").submit();

  cy.get(".results p").should("contain", "You are feeling: Angry");
});

it("detects content sentiment", () => {
  cy.visit("/analyze");

  cy.get("#feelings").type("I think coffee in the morning is just swell!");
  cy.get("form").submit();

  cy.get(".results p").should("contain", "You are feeling: Content");
});

下載 spec.js

就算你不熟悉 Cypress API,它的語意也夠直觀,你大概就能看懂這些測試在做什麼。用白話來說,兩個測試都遵循相同的流程:

  1. 在瀏覽器中,前往 Sentimentalyzer Web App 的 /analyze 路徑。
  2. 找到文字輸入欄位。
  3. 輸入一些文字。
  4. 提交表單。
  5. 讀取結果。

我來逐行講解第一個測試:

cy.visit("/analyze");

這一行是告訴 Cypress 在瀏覽器中載入 Sentimentalyzer 的 /analyze 路徑。Cypress 會把它跟我在上面 docker-compose.yml 中定義的 CYPRESS_baseUrl 環境變數組合起來,所以完整的 URL 就是 http://sentimentalyzer:8123/analyze。你無法從自己的開發機器存取這個 URL,但在 Cypress 容器內部,這是一個有效的位址。

cy.get("#feelings").type("I REALLY need some COFFEE");

接著,我讓 Cypress 去找到文字欄位。這很簡單,因為這個文字欄位有一個唯一的 ID,feelings,所以我用 CSS 選擇器語法 #feelings 來指定這個元素。

尋找 feelings 元素的 HTML id

type() 函式會告訴 Cypress 在我指定的欄位中輸入文字。

接下來,Cypress 需要提交表單。Cypress 為這個常見任務提供了submit() 函式。頁面上只有一個 <form> 元素,所以用 CSS 選擇器 form 就能輕鬆取得它,然後提交表單:

cy.get("form").submit();

提交表單後,應該會讓 Cypress 來到 Sentimentalyzer 的結果頁面。Cypress 需要檢查文字 "You are feeling: Angry" 是否存在,但這有點棘手,因為包含這段文字的 <p> 標籤沒有 ID 屬性:

尋找結果 <p> 標籤的 CSS 選擇器

我再次使用 CSS 選擇器語法,透過指定在 class 為 "results" 的 DOM 節點底下的 <p> 元素來定位相關文字:

cy.get(".results p").should("contain", "You are feeling: Angry");

contain 斷言會驗證 <p> 標籤中是否包含我預期的文字。

執行我的測試

一切就緒後,就是見證 Cypress 實際運作的時候了。我用一個簡單的指令來執行測試:

cd e2e
docker-compose up --exit-code-from cypress

--exit-code-from cypress 這個旗標會告訴 Docker Compose,將 Cypress 容器的結束代碼作為 docker-compose 指令的結束代碼。這表示當測試通過時,指令的結束代碼為 0,測試失敗時則為非 0。這個行為對於會依據指令結束代碼來判斷是否成功的建置腳本或持續整合設定來說非常方便。

從終端機來看,整個過程是這樣的:

Cypress 會為每一次測試執行建立影片錄製。這是我最喜歡的功能,因為它在診斷測試失敗時幫了大忙:

Cypress 端對端測試的錄影(以 1/4 速度慢放)

測試失敗時的截圖

上面我展示的是通過的測試。那當 Cypress 測試失敗時會發生什麼事呢?它依然會產生測試執行的影片,但同時也會輸出一張截圖,顯示是哪個斷言失敗了:

Cypress 在失敗時輸出的截圖

當我的測試失敗時,Cypress 產生的截圖(Cypress 預期看到「Furious」一詞,但實際找到的是「Angry」)

這解決了我在使用其他工具時遇到的一大痛點。Selenium 雖然也支援截圖,但只能在斷言之前或之後截圖。這個限制導致了令人沮喪的情況:Selenium 宣稱測試失敗,但截圖卻顯示行為是正確的,因為瀏覽器狀態在測試失敗後又改變了。

Cypress 避免了這個問題,因為它的截圖是與斷言同時發生的。如果測試失敗,截圖會精確呈現 Cypress 在失敗當下所看到的畫面。

將此方法套用到你的 Web App

只要這三個檔案,你就能開始對你的 Web App 進行端對端測試。步驟如下:

  1. e2e 資料夾複製到你的專案中。
  2. docker-compose.ymlsentimentalyzer 的區段替換成你的 App 的 Docker 容器設定。
  3. 根據你的 App 的 UI 流程,重寫 integration/spec.js

原始碼與其他範例

這個 Demo 的完整原始碼可在 GitHub 上取得:

我也建立了幾個分支來展示其他常見的 Cypress 情境:

延伸閱讀

這份指南對 Cypress 做了基本的介紹。若想了解更進階的功能,請參考官方的 Cypress 文件:

更新(2019-05-02):回應這篇文章,Cypress 團隊發布了一個已預先安裝 Cypress 的官方 Docker 映像檔。我已更新這篇教學以整合他們的新映像檔。想了解關於這些映像檔的更多細節,以及更多同時使用 Cypress 與 Docker 的技巧,請參考 Cypress 部落格文章


插圖由 Loraine Yow 繪製。感謝來自 Cypress 團隊的 Gleb Bahmutov 為本文提供早期回饋。

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

留言