2020 時代的 Rails 系統測試 (翻譯)

恒温發表於2020-07-23

zz from https://ruby-china.org/topics/40177apexy,已經得到轉發授權。,感謝翻譯同學:

本文已獲得原作者(Vladimir Dementyev)和 Evil Martians 授權許可進行翻譯。原文介紹了在新的 2020 時代,摒棄了基於 Java 的笨重 Selenium 之後,如何在 Rails 下構建基於瀏覽器的高效系統測試。作者對於系統測試概念進行了詳細闡述,演示了具體配置的範例和執行效果,對 Docker 開發環境也有專業級別的涵蓋。非常推薦。我的翻譯 Blog 連結在這裡:https://xfyuan.github.io/2020/07/proper-browser-testing-in-rails/

【下面是正文】

發現 Ruby on Rails 應用中端到端瀏覽器測試的最佳實踐集合,並在你的專案上採用它們。瞭解如何摒棄基於 Java 的 Selenium,轉而使用更加精簡的 Ferrum-Cuprite 組合,它們能直接通過純 Ruby 方式來使用 Chrome DevTools 的協議。如果你在使用 Docker 開發環境——本文也有涵蓋。

Ruby 社群對於測試飽含激情。我們有數不勝數的測試庫,有成千上萬篇關於測試主題的部落格文章,甚至為此有一個專門的播客。更可怕的是,下載量排在前三名的 Gem 也都是有關 RSpec 測試框架的!

我相信,Rails,是 Ruby 測試興盛背後的原因之一。這個框架讓測試的編寫儘可能地成為一種享受了。多數情況下,跟隨著 Rails 測試指南的教導就已足夠(起碼在剛開始的時候)。但事情總有例外,而在我們這裡,就是系統測試

對 Rail 應用而言,編寫和維護系統測試很難被稱為 “愜意的”。我在處理這個問題上,自 2013 年的第一次 Cucumber 驅動測試算起,已經逐步改進了太多。而今天,到了 2020 年,我終於可以來跟大家分享一下自己當前(關於測試)的設定了。本文中,我將會討論以下幾點:

  • 系統測試概述
  • 使用 Cuprite 進行現代系統測試
  • 配置範例
  • Docker 化的系統測試

系統測試概述

“系統測試” 是 Rails 世界中對於自動化端到端測試的一種通常稱謂。在 Rails 採用這個名稱之前,我們使用諸如功能測試瀏覽器測試、甚至驗收測試等各種叫法(儘管後者在意思上有所不同)。

如果我們回憶下測試金字塔(或者就金字塔),系統測試是處於非常靠上的位置:它們把整個程式視為一個黑盒,通常模擬終端使用者的行為和預期。而這就是在 Web 應用程式情況下,為什麼我們需要瀏覽器來執行這些測試的原因(或者至少是類似 Rack Test 的模擬器)。

我們來看下典型的系統測試架構:

img

我們需要管理至少三個 “程式”(它們有些可以是 Ruby 執行緒):一個 執行我們應用的 Web 服務端,一個瀏覽器,和一個測試執行器。這是最低要求。實際情況下,我們通常還需要另一個工具提供 API 來控制瀏覽器(比如,ChromeDriver)。一些工具嘗試通過構建特定瀏覽器(例如 capybara-webkit 和 PhantomJS)來簡化此設定,並提供開箱即用的此類 API,但它們在對真正瀏覽器的相容性 “競爭” 中都敗下陣來,沒能倖免於難。

當然,我們需要新增少量 Ruby Gem 測試依賴庫——來把所有碎片都粘合起來。更多的依賴庫會帶來更多的問題。例如,Database Cleaner 在很長時間內都是一個必備的庫:我們不能使用事務來自動回滾資料庫狀態,因為每個執行緒都是用自己獨立的連線。我們不得不針對每張表使用TRUNCATE ...DELETE FROM ...來代替,這會相當慢。我們通過在所有執行緒中使用一個共享連線解決了這個問題(使用 TestProf extension)。Rails 5.1 也釋出了一個現成的類似功能。

這樣,通過新增系統測試,我們增加了開發環境和 CI 環境的維護成本,以及引入了潛在的故障或不穩定因素:由於複雜的設定,flakiness 是端到端測試中最常見的問題。而大多數這些 flakiness 來自於跟瀏覽器的通訊。

儘管通過在 Rails 5.1 中引入系統測試簡化了瀏覽器測試,它們仍然需要一些配置才能平滑執行:

  • 你需要處理 Web drivers(Rails 假設你使用 Selenium)。
  • 你可以自己在容器化環境中配置系統測試(例如,象這篇文章中的做法)。
  • 配置不夠靈活(例如,螢幕快照的路徑)

讓我們來轉到程式碼層面吧,看看在 2020 時代如何讓你的系統測試更有樂趣!

使用 Cuprite 進行現代系統測試

預設情況下,Rails 假設你會使用 Selenium 來跑系統測試。Selenium 是一個實戰驗證過的 Web 瀏覽器自動化軟體。它旨在為所有瀏覽器提供一個通用 API 以及最真實的體驗。在模擬使用者瀏覽器互動方面,唯有有血有肉的真人才比它做得更好些。

不過,這種能力不是沒有代價的:你需要安裝特定瀏覽器的驅動,現實互動的開銷在規模上是顯而易見的(例如,Selenium 的測試通常都相當慢)。

Selenium 已經是很久以前釋出的了,那時瀏覽器還不提供任何內建自動化測試相容性。這麼多年過去了,現在的情況已經大不相同,Chrome 引入了 CDP 協議。使用 CDP,你可以直接操作瀏覽器的 Session,不再需要中間的抽象層和工具。

從那以後,湧現了好多利用 CDP 的專案,包括最著名的——Puppeteer,一個 Node.js 的瀏覽器自動化庫。那麼 Ruby 世界呢?是 Ferrum,一個 Ruby 的 CDP 庫,儘管還相當年輕,也提供了不遜色於 Puppeteer 的體驗。而對我們更重要的是,它帶來了一個稱為 Cuprite 的夥伴專案——使用 CDP 的純 Ruby Capybara 驅動。

我從 2020 年初才開始積極使用 Cuprite 的(一年前我嘗試過,但在 Docker 環境下有些問題),從未讓我後悔過。設定系統測試變得異常簡單(全部所需僅 Chrome 而已),而且執行是如此之快,以至於在從 Selenium 遷移過來之後我的一些測試都失敗了:它們缺乏合適的非同步期望斷言,在 Selenium 中能通過僅僅是因為 Selenium 太慢。

讓我們來看下我最近工作上所用到 Cuprite 的系統測試配置。

註釋過的配置範例

這個範例來自於我最近的開源 Ruby on Rails 專案——AnyCable Rails Demo。它旨在演示如何跟 Rails 應用一起使用剛剛釋出的 AnyCable 1.0,但我們也可用於本文——它有很好的系統測試覆蓋著。

這個專案使用 RSpec 及其系統測試封裝器。其大部分也是可以用在 Minitest 上的。

讓我們從一個足以在本地機器上執行的最小示例開始。其程式碼放在 AnyCable Rails Demo 的 demo/dockerless 分支上。

首先來快速看一眼 Gemfile:

group :test do
gem 'capybara'
gem 'selenium-webdriver'
gem 'cuprite'
end

什麼?為什麼需要selenium-webdriver,如果我們根本不用 Selenium 的話?事實證明,Rails 要求這個 gem 獨立於你所使用的驅動而存在。好訊息是,這已經被修復了,我們有望在 Rails 6.1 中移除這個 gem。

我把系統測試的配置放在多個檔案內,位於spec/system/support目錄,使用專門的system_helper.rb來載入它們:

spec/
system/
support/
better_rails_system_tests.rb
capybara_setup.rb
cuprite_setup.rb
precompile_assets.rb
...
system_helper.rb

我們來看下上面這個列表中的每個檔案都是幹嘛的。

system_helper.rb

system_helper.rb檔案包含一些針對系統測試的通用 RSpec 配置,不過,通常而言,都如下面這樣簡單:

# Load general RSpec Rails configuration
require "rails_helper.rb"

# Load configuration files and helpers
Dir[File.join(__dir__, "system/support/**/*.rb")].sort.each { |file| require file }

然後,在你的系統測試中,使用require "system_helper"來啟用該配置。

我們為系統測試使用了一個單獨的 helper 檔案和一個 support 資料夾,以避免在我們只需執行一個單元測試時載入所有多餘的配置。

capybara_setup.rb

這個檔案包含針對 Capybara 框架的配置:

# spec/system/support/capybara_setup.rb

# Usually, especially when using Selenium, developers tend to increase the max wait time.
# With Cuprite, there is no need for that.
# We use a Capybara default value here explicitly.
Capybara.default_max_wait_time = 2

# Normalize whitespaces when using `has_text?` and similar matchers,
# i.e., ignore newlines, trailing spaces, etc.
# That makes tests less dependent on slightly UI changes.
Capybara.default_normalize_ws = true

# Where to store system tests artifacts (e.g. screenshots, downloaded files, etc.).
# It could be useful to be able to configure this path from the outside (e.g., on CI).
Capybara.save_path = ENV.fetch("CAPYBARA_ARTIFACTS", "./tmp/capybara")

該檔案也包含一個對於 Capybara 很有用的補丁,其目的我們稍後揭示:

# spec/system/support/capybara_setup.rb

Capybara.singleton_class.prepend(Module.new do
attr_accessor :last_used_session

def using_session(name, &block)
self.last_used_session = name
super
ensure
self.last_used_session = nil
end
end)

Capybara.using_session讓你能夠操作不同的瀏覽器 session,從而在單個測試場景內操作多個獨立 session。這在測試實時功能時尤其有用,例如使用 WebSocket 的功能。

該補丁跟蹤上次使用的 session 名稱。我們會用這個資訊來支援在多 session 測試中獲取失敗情況下的螢幕快照。

cuprite_setup.rb

這個檔案負責配置 Cuprite:

# spec/system/support/cuprite_setup.rb

# First, load Cuprite Capybara integration
require "capybara/cuprite"

# Then, we need to register our driver to be able to use it later
# with #driven_by method.
Capybara.register_driver(:cuprite) do |app|
Capybara::Cuprite::Driver.new(
app,
**{
window_size: [1200, 800],
# See additional options for Dockerized environment in the respective section of this article
browser_options: {},
# Increase Chrome startup wait time (required for stable CI builds)
process_timeout: 10,
# Enable debugging capabilities
inspector: true,
# Allow running Chrome in a headful mode by setting HEADLESS env
# var to a falsey value
headless: !ENV["HEADLESS"].in?(%w[n 0 no false])
}
)
end

# Configure Capybara to use :cuprite driver by default
Capybara.default_driver = Capybara.javascript_driver = :cuprite

我們也為常用 Cuprite API 方法定義了一些快捷方式:

module CupriteHelpers
# Drop #pause anywhere in a test to stop the execution.
# Useful when you want to checkout the contents of a web page in the middle of a test
# running in a headful mode.
def pause
page.driver.pause
end

# Drop #debug anywhere in a test to open a Chrome inspector and pause the execution
def debug(*args)
page.driver.debug(*args)
end
end

RSpec.configure do |config|
config.include CupriteHelpers, type: :system
end

下面你可以看到一個#debug幫助方法如何工作的演示:

Debugging system tests

better_rails_system_tests.rb

這個檔案包含一些有關 Rails 系統測試內部的補丁以及通用配置(程式碼註釋有詳細解釋):

# spec/system/support/better_rails_system_tests.rb

module BetterRailsSystemTests
# Use our `Capybara.save_path` to store screenshots with other capybara artifacts
# (Rails screenshots path is not configurable https://github.com/rails/rails/blob/49baf092439fc74fc3377b12e3334c3dd9d0752f/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb#L79)
def absolute_image_path
Rails.root.join("#{Capybara.save_path}/screenshots/#{image_name}.png")
end

# Make failure screenshots compatible with multi-session setup.
# That's where we use Capybara.last_used_session introduced before.
def take_screenshot
return super unless Capybara.last_used_session

Capybara.using_session(Capybara.last_used_session) { super }
end
end

RSpec.configure do |config|
config.include BetterRailsSystemTests, type: :system

# Make urls in mailers contain the correct server host.
# It's required for testing links in emails (e.g., via capybara-email).
config.around(:each, type: :system) do |ex|
was_host = Rails.application.default_url_options[:host]
Rails.application.default_url_options[:host] = Capybara.server_host
ex.run
Rails.application.default_url_options[:host] = was_host
end

# Make sure this hook runs before others
config.prepend_before(:each, type: :system) do
# Use JS driver always
driven_by Capybara.javascript_driver
end
end

precompile_assets.rb

這個檔案負責在執行系統測試之前預編譯 assets(我不在這兒貼上它的完整程式碼,只給出最有趣的部分):

RSpec.configure do |config|
# Skip assets precompilcation if we exclude system specs.
# For example, you can run all non-system tests via the following command:
#
# rspec --tag ~type:system
#
# In this case, we don't need to precompile assets.
next if config.filter.opposite.rules[:type] == "system" || config.exclude_pattern.match?(%r{spec/system})

config.before(:suite) do
# We can use webpack-dev-server for tests, too!
# Useful if you working on a frontend code fixes and want to verify them via system tests.
if Webpacker.dev_server.running?
$stdout.puts "\n⚙️ Webpack dev server is running! Skip assets compilation.\n"
next
else
$stdout.puts "\n? Precompiling assets.\n"

# The code to run webpacker:compile Rake task
# ...
end
end
end

為什麼要手動預編譯 assets 呢,如果 Rails 能夠自動為你做這事的話?問題在於 Rails 預編譯 assets 是惰性的(比如,在你首次請求一個 asset 的時候),這會使你的第一個測試用例非常非常慢,甚至碰到隨機超時的異常。

另一個我想提請注意的是使用 Webpack dev server 進行系統測試的能力。這在當你進行艱苦的前端程式碼重構時相當有用:你可以暫停一個測試,開啟瀏覽器,編輯前端程式碼並看到它被熱載入了!

Docker 化的系統測試

讓我們把自己的配置提升到更高的層次,使其相容我們的 Docker 開發環境。Docker 化版本的測試設定在 AnyCable Rails Demo 程式碼庫的預設分支上,可隨意檢視,不過下面我們打算涵蓋那些有意思的內容。

Docker 下設定的主要區別是我們在一個單獨的容器中執行瀏覽器例項。可以把 Chrome 新增到你的基礎 Rails 映象上,或者可能的話,甚至從容器內使用主機的瀏覽器(這可以用 Selenium and ChromeDriver 做到)。但是,在我看來,為docker-compose.yml定義一個專用的瀏覽器 service 是一種更正確的 Docker 式方式來幹這個。

目前,我用的是來自 browserless.io 的 Chrome Docker 映象。它帶有一個好用的 Debug 檢視器,讓你能夠除錯 headless 的瀏覽器 session(本文最後有一個簡短的視訊演示):

services:
# ...
chrome:
image: browserless/chrome:1.31-chrome-stable
ports:
- "3333:3333"
environment:
# By default, it uses 3000, which is typically used by Rails.
PORT: 3333
# Set connection timeout to avoid timeout exception during debugging
# https://docs.browserless.io/docs/docker.html#connection-timeout
CONNECTION_TIMEOUT: 600000

CHROME_URL: http://chrome:3333新增到你的 Rails service 環境變數,以後臺方式執行 Chrome:

docker-compose up -d chrome

現在,如果提供了 URL,我們就需要配置 Cuprite 以和遠端瀏覽器一起工作:

# cuprite_setup.rb

# Parse URL
# NOTE: REMOTE_CHROME_HOST should be added to Webmock/VCR allowlist if you use any of those.
REMOTE_CHROME_URL = ENV["CHROME_URL"]
REMOTE_CHROME_HOST, REMOTE_CHROME_PORT =
if REMOTE_CHROME_URL
URI.parse(REMOTE_CHROME_URL).yield_self do |uri|
[uri.host, uri.port]
end
end

# Check whether the remote chrome is running.
remote_chrome =
begin
if REMOTE_CHROME_URL.nil?
false
else
Socket.tcp(REMOTE_CHROME_HOST, REMOTE_CHROME_PORT, connect_timeout: 1).close
true
end
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError
false
end

remote_options = remote_chrome ? { url: REMOTE_CHROME_URL } : {}

上面的配置假設當CHROME_URL未被設定或者瀏覽器未響應時,使用者想使用本地安裝的 Chrome。

我們這樣做以便讓配置向下相容本地的配置(我們一般不強迫每個人都使用 Docker 作為開發環境;讓拒絕使用 Docker 者為其獨特的本地設定而受苦吧?)。

我們的驅動註冊現在看起來是這樣的:

# spec/system/support/cuprite_setup.rb

Capybara.register_driver(:cuprite) do |app|
Capybara::Cuprite::Driver.new(
app,
**{
window_size: [1200, 800],
browser_options: remote_chrome ? { "no-sandbox" => nil } : {},
inspector: true
}.merge(remote_options)
)
end

我們也需要更新自己的#debug幫助方法以列印 Debug 檢視器的 URL,而不是嘗試去開啟瀏覽器:

module CupriteHelpers
# ...

def debug(binding = nil)
$stdout.puts "? Open Chrome inspector at http://localhost:3333"
return binding.pry if binding

page.driver.pause
end
end

由於瀏覽器是執行在一個不同的 “機器” 上,因此它應該知道如何到達測試服務端(其不再是localhost)。

為此,我們需要配置 Capybara 服務端的 host:

# spec/system/support/capybara_setup.rb

# Make server accessible from the outside world
Capybara.server_host = "0.0.0.0"
# Use a hostname that could be resolved in the internal Docker network
# NOTE: Rails overrides Capybara.app_host in Rails <6.1, so we have
# to store it differently
CAPYBARA_APP_HOST = `hostname`.strip&.downcase || "0.0.0.0"
# In Rails 6.1+ the following line should be enough
# Capybara.app_host = "http://#{`hostname`.strip&.downcase || "0.0.0.0"}"

最後,讓我們對better_rails_system_tests.rb做一些調整。

首先,我們來讓 VS Code 中的螢幕快照通知變得可點選?(Docker 絕對路徑與主機系統是不同的):

# spec/system/support/better_rails_system_tests.rb

module BetterRailsSystemTests
# ...

# Use relative path in screenshot message
def image_path
absolute_image_path.relative_path_from(Rails.root).to_s
end
end

其次,確保測試都使用了正確的服務端 host(這在 Rails 6.1 中已經被修復了):

# spec/system/support/better_rails_system_tests.rb

config.prepend_before(:each, type: :system) do
# Rails sets host to `127.0.0.1` for every test by default.
# That won't work with a remote browser.
host! CAPYBARA_APP_HOST
# Use JS driver always
driven_by Capybara.javascript_driver
end

In too Dip

如果你使用 Dip 來管理 Docker 開發環境(我強烈建議你這麼做,它使你獲得容器的強大能力,而無需記憶所有 Docker CLI 命令的成本付出),那麼你可以通過在dip.yml中新增自定義命令和在docker-compose.yml中新增一個額外 service 定義,來避免手動載入chrome service:

# docker-compose.yml

# Separate definition for system tests to add Chrome as a dependency
rspec_system:
<<: *backend
depends_on:
<<: *backend_depends_on
chrome:
condition: service_started

# dip.yml
rspec:
description: Run Rails unit tests
service: rails
environment:
RAILS_ENV: test
command: bundle exec rspec --exclude-pattern spec/system/**/*_spec.rb
subcommands:
system:
description: Run Rails system tests
service: rspec_system
command: bundle exec rspec --pattern spec/system/**/*_spec.rb

現在,我使用如下命令來執行系統測試:

dip rspec system

就是這樣了!

最後,讓我向你展示一下如何使用 Browserless.io 的 Docker 映象的 Debug 檢視器進行除錯:

Debugging system tests running in Docker

相關文章