DockerのChromeでCupriteの動作を検証してみる

はじめに

Seleniumの調査だったり開発環境がApple Siliconだったりで考慮しないといけない事が多いのでSystemTestの設定を後回しにしていたRailsアプリがあったのだが、CupriteというSeleniumではなくCDP(Chrome DevTools Protocol)を使用したGemの存在を知ったのがきっかけでやっぱりきちんとやろうという事になり、まずは勉強がてら一旦Railsから離れてCupriteとDockerのChormeで環境を作って試してみた。

詳細

リポジトリ

Gemなど

Ferrum

RubyでCDPを使うためのGem。Ferrumとはラテン語で鉄の意味らしい。
名前がラテン語である理由やGItHubのトップにあるイラストがMUCのエンドゲームでサノスが使っていたインフェニティティガントレットっぽく見えるのが気になる。

Cuprite

CapybaraでFerrumを使うためのGem。

CDP(Chrome DevTools Protocol)

Cupriteを知るまでその存在を知らなかったのだがプログラムから呼び出せるChromeに実装されたAPI。E2ETestなどではSeleniumを代替出来るのでCuprite & Ferrumはそのアプローチを取っている。

やったこと

疎通のテストなので単純なリクエストを返してくれるRackアプリをテスト用に作成。 最終的にCapybaraを通してJavaScriptの実行を試したいので検証用のボタンも追加しておく。

config.ru

class App
  def call(env)
    html = "<html><head><script>function changeText() { document.querySelector('#text').textContent = 'Change Text'; }</script></head><body><h1 id='text'>Hello World!</h1><button onclick=changeText()>Click</button></body></html>"
    [200,{ "Content-Type" => "text/html" },[html]]
  end
end
run App.new

DockerはWebアプリを起動するためのRubyChromeを別コンテナで立ち上げている。

version: "3.9"
services:
  web:
    build:
      context: .
    command: ["bundle", "exec", "rackup", "-p", "80", "-o", "0.0.0.0"]
    ports:
      - "80:80"
    volumes:
      - ./app:/app
    depends_on:
      - chrome
  chrome:
    image: browserless/chrome
    ports:
      - "3333:3333"
    volumes:
      - .:/app:cached
    environment:
      PORT: 3333
      CONNECTION_TIMEOUT: 600000

Rubyのコンテナでは必要なGemをbuild時にインストールしておりRuby3からWEBRickが標準ライブラリでなくなったのでPumaを追加している。

# frozen_string_literal: true

source "https://rubygems.org"

gem "rack"
gem "puma"
gem "cuprite"
gem "test-unit"
gem "debug"
FROM ruby:3.1.1
RUN mkdir /app
WORKDIR /app
ADD ./app /app
RUN gem install bundler
RUN bundle install
$ docker compose build
$ docker compose up -d

Dockerのビルドとコンテナの起動が済んだら動作を一つずつ確認していく。

Webアプリコンテナ <-> Chromeコンテナ

Rackアプリの起動の確認とWebコンテナからChormeのコンテナにアクセス出来るかを確認しておく

$ docker compose exec web curl 'http://localhost:80'
<html><head><script>function changeText() { document.querySelector('#text').textContent = 'Change Text'; }</script></head><body><h1 id='text'>Hello World!</h1><button onclick=changeText()>Click</button></body></html>%
$ docker compose exec web curl 'http://chrome:3333'
<!doctype html><html><head><meta charset="utf-8"/><title>browserless debugger</title>...

Chrome経由でWebアプリに接続するのでChromeのコンテナからホストネームでアクセス出来るかも確認しておく

$ docker compose exec chrome curl 'http://web:80'
<html><head><script>function changeText() { document.querySelector('#text').textContent = 'Change Text'; }</script></head><body><h1 id='text'>Hello World!</h1><button onclick=changeText()>Click</button></body></html>%

Ferrum -> Chromeコンテナ

WebコンテナからFerrum経由でChromeにアクセス出来るか確認

options = {
    url: 'http://chrome:3333',
    browser_options: { 'no-sandbox': nil }
}
browser = Ferrum::Browser.new(options)
browser.go_to("https://google.com")
puts browser.current_title
browser.quit
$ docker compose exec web ruby ferrum.rb
Google

Capybra -> Chromeコンテナ -> Webアプリ

Capybara経由でChromeを通してRackアプリに接続出来るか確認

require "capybara/cuprite"
require 'capybara/dsl'

options = {
    url: 'http://chrome:3333',
    browser_options: { 'no-sandbox': nil },
    base_url: "http://web"
}

Capybara.register_driver(:cuprite) do |app|
    Capybara::Cuprite::Driver.new(app,options)
end

Capybara.javascript_driver = :cuprite
Capybara.server_host = "0.0.0.0"
Capybara.app_host = "http://web:80"

session = Capybara::Session.new(:cuprite)
driver = session.driver
browser = driver.browser
page = browser.page
browser.visit "/"
puts page.body

今回ChromeもDockerにした上に似た名前の項目が多いので設定が紛らわしいが Capybara::Cuprite::Driver.newのurlにはChromeの宛先をCapybara.app_hostにはWebアプリケーションの宛先を設定する。
その他はCapybaraからvisit "/"のようにホスト名を省略してページを選択したければ Capybara::Cuprite::Driver.newbasu_url を渡す。
browser_options: { 'no-sandbox': nil }Docker経由の場合必要とのこと。 Capybara.server_hostもDocker経由の場合 "0.0.0.0" を指定して外部からのアクセスを許可してやる必要がある。
この内容を保存して実行してやるとCapybara経由でRackアプリに接続出来る事が確認できる。

$ docker compose exec web bundle exec ruby capybara.rb
<html><head><script>function changeText() { document.querySelector('#text').textContent = 'Change Text'; }</script></head><body><h1 id="text">Hello World!</h1><button onclick="changeText()">Click</button></body></html>

TestCase

最後にテストケースを作成してSystemTestでの実行を試す。

# 必要な設定は上のCapybaraのテストとほぼ同じだが、そのままだとRackサーバーを立ち上げようとするので以下の設定を追記
Capybara.default_driver = :cuprite

require 'test/unit'
require 'capybara/dsl'

class SystemTestCase < Test::Unit::TestCase
  include Capybara::DSL
  def test_foo
    visit '/'
    assert_equal(page.find('h1').text, 'Hello World!')
    page.click_on 'Click'
    assert_equal(page.find('h1').text, 'Change Text')
  end
end
$ docker compose exec web ruby test_case.rb
Loaded suite test_case
Started
.
Finished in 0.319190042 seconds.
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
1 tests, 2 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
3.13 tests/s, 6.27 assertions/s

JavaScriptの実行も確認出来た

つまずいたところ

途中上記の設定で実行しても以下のような空のHTMLで返ってきて困った。

<html><head></head><body></body></html>

原因は意外なところでDockerComposeで指定していたサービス名が理由だった。 こちらを参考にしてapp -> webに変えたらうまく動いた。エラー内容も違うし詳細な理由はわからないのが、とりあえずはDockerComposeのService名はappにしないほうが無難なのだろうか

まとめ

SystemTestはブラウザが必要だったりUnitTestよりも複雑でそこにDockerや新しく使うGemが絡んでくると確信のないまま設定をあれこれいじくる事になりがちなので一旦小さな構成で試してみた。 とりあえず迷った時に立ちかえられる構成が出来たので次はRailsアプリに組み込んでいきたい。

参照