Docker環境でHeadless Chromeを使ったSystemt Specを実行する

# この記事なんやねん
みなさん、Rspecで統合テスト(System Spec)書いてますか？

すっかりSeleniumを使ってヘッドレスブラウザを使った統合テストが主流になりましたが、`Docker`と`Headless Chrome`を使ったやり方が意外とヒットしなかったので投稿します。

# 手順
ここから早速始めていきましょう。因みに、以下の環境は揃っているという前提で進めます。

- Dockerとdocker-composeをインストール済み
- docker-composeを使ってRails環境を構築済み
- Rspec実行環境を構築済み

もし、 *環境構築まだやねん* って状態だったら、[こちらの記事](https://qiita.com/belion_freee/items/f0ef60a08299c9febbdf)を参考に開発環境を作ってみてください。

## イメージの準備
Dockerfileにheadless chromeドライバをインストールしてもいいのですが、**正直めんどくさい**です。
`brew`で簡単にインストールできるのにDocker使った方が環境構築めんどくさいのも本末転倒な感じするので、Dockerhubにイメージ上がってないか検索したら**案の定ありました**。

https://hub.docker.com/r/selenium/standalone-chrome

seleniumがイメージを提供してくれてるようですね。因みに、googleはイメージ提供していないようなので、seleniumさんのイメージをありがたく使いましょう。

## サービス追加
docker-composeに以下のように既存サービスの一番下にサービスを追加してください。

```yml:docker-copompose.yml
version: '3'
services:
  ### 省略 ###
  chrome:
    image: selenium/standalone-chrome
    ports:
      - "4444:4444"
```

これだけです。やっぱりイメージ使えば楽ですなあ。

## Rspecの設定
次に、Rspecの設定をしていきます。Capybaraとrails_helperを設定するだけで済むので簡単です。

### Gemfileの修正
もしまだライブラリをインストールしていなければ以下を追記してから、`bundle install`してください。

```text:Gemfile
group :test do
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  gem "database_cleaner"
  gem "factory_bot_rails"
  gem "rspec-rails"
　# 下記を追加
  gem 'capybara' 
  gem 'selenium-webdriver'
  gem 'rspec-retry'
end
```

### Capibaraの設定
下記のファイルを作成してください。ポート番号は他でもいいですよ。

```ruby:spec/support/capybara.rb
Capybara.default_driver    = :selenium_chrome
Capybara.javascript_driver = :selenium_chrome
Capybara.server_host = Socket.ip_address_list.detect(&:ipv4_private?).ip_address
Capybara.server_port = 3001
Capybara.default_max_wait_time = 5
Capybara.ignore_hidden_elements = true

Capybara.register_driver :selenium_chrome do |app|
  opts = {
    desired_capabilities: :chrome,
    browser:              :remote,
    url:                  "http://chrome:4444/wd/hub",
  }
  Capybara::Selenium::Driver.new(app, opts)
end
```

### rails_helperの修正
下記のようにrails_helperを修正してください。
なお、`spec/support/system_support.rb`というSystemスペック用のヘルパーを
作成しなければ任意ってコメントしてる設定は不要です。

```ruby:spec/rails_helper.rb
  ### 省略 ###

# コメントアウトされてるので、コメントアウトを外してください
Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f }

RSpec.configure do |config|
  ### 省略 ###

  # Systemスペック用のヘルパー。任意です。
  config.include SystemSupport, type: :system

  # 名称を一意にするために設定。任意かな。
  config.before(:all, type: :system) do
    timestamp!
  end

  # Systemスペックは不安定なのでリトライ用の設定。
  config.verbose_retry = true
  config.display_try_failure_messages = true
  config.default_retry_count = 3

  # 下記を追加
  config.before(:each, type: :system) do
    driven_by Capybara.default_driver
  end

  config.before(:each, type: :system, js: true) do
    driven_by Capybara.javascript_driver
    host! "http://#{Capybara.server_host}:#{Capybara.server_port}"
  end
end
```

### ヘルパーの作成(任意)
Systemスペックは共通の処理(ログイン処理とか)が多いので、ヘルパーを用意しておくと便利です。
任意と書いていますが、作成しておいて損はないと思います。

```ruby:spec/support/system_support.rb
module SystemSupport
  # 一意の名称を作成するために実行時のtimestampを、
  # 数字でインスタンス変数に格納するsetterです。地味に便利。
  def timestamp!(timestamp = Time.now.to_i)
    @timestamp = timestamp
  end

  # getterです
  def timestamp
    @timestamp
  end

  # ブロックの結果がtrueになるまでループするメソッド。すげえ使う。
  def wait_until(wait_time = Capybara.default_max_wait_time)
    Timeout.timeout(wait_time) do
      loop until yield
    end
  end

  # 特定のcssが登場する、もしくは、なくなるまでループするメソッド
  def wait_for_css(selector, wait_time = Capybara.default_max_wait_time, non_display: false)
    Timeout.timeout(wait_time) do
      loop until send((non_display ? :has_no_css? : :has_css?), selector)
    end
    yield if block_given?
  end

  # 非同期通信が終わるまでループするメソッド
  def wait_for_ajax(wait_time = Capybara.default_max_wait_time)
    Timeout.timeout(wait_time) do
      loop until page.evaluate_script("jQuery.active").zero?
    end
    yield if block_given?
  end
end
```

## System Specの作成
ここまでで設定は完了してるので実際にSystem Specを書いていきましょう。
私のサンプルソースではこんな感じで書いてます。自分のソースコードに合わせて適宜書き直してください。

```ruby
require "rails_helper"

# typeはsystemを設定、Javascriptも使うのでjsもtrueにしておく。
RSpec.describe "HelloWorlds", type: :system, js: true do
  # 最初にテストデータ作成
  before(:all) {
    create(:hello_world, country: "JP", hello: "こんにちわ世界", priority: 1, file_name: "jp.jpeg")
    create(:hello_world, country: "US", hello: "Hello World", priority: 2)
    create(:hello_world, country: "CN", hello: "你好 世界", priority: 3)
  }

  before(:each) {
    visit root_path

    # コンテンツが全て表示されるまで待つ
    wait_until { (page.all("div.portfolio-item").count == 3) }
  }

  context "when go to index page" do
    it "show contents" do
      expect(page).to have_css("h1", text: "Hello World")

      content = page.first("div.portfolio-item")
      expect(content.first("p.card-text").text).to eq("こんにちわ世界")
      expect(content.first("h4.card-title")).to have_link("日本")
      expect(content.first("img.card-img-top")[:src]).to match(/jp\.jpeg/)
    end
  end

  context "when go to Create page" do
    it "create content" do
      page.first("#new_hello_world").click

      # タイトル出るまで待つ
      wait_until { page.has_css?("h3", text: "New Helloworld") }

      select "ドイツ", from: "hello_world_country"
      # 一意の名称で検索してテストデータを作成
      fill_in "hello_world_hello", with: "Hallo Welt #{timestamp}"
      fill_in "hello_world_priority", with: 4

      click_on "Submit"

      # メッセージ出るまで待つ
      wait_until { page.has_content?("Hello world was successfully created.") }

      content = page.first("div.form").all("label.form-control")
      expect(content[0].text).to eq("ドイツ")
      expect(content[1].text).to eq("Hallo Welt #{timestamp}")
      expect(content[2].text).to eq("4")
    end
  end
end
```

### テスト実行
早速テストを実行してみましょう。
下記の`web`はサービス名なので適宜自分の設定してるサービス名に変更して実行してください。

```shell
% docker-compose run --rm web rspec spec/system/hello_worlds_spec.rb
Starting hr_chrome_1 ... done
Starting hr_db_1     ... done
Capybara starting Puma...
* Version 4.1.0 , codename: Fourth and One
* Min threads: 0, max threads: 4
* Listening on tcp://192.168.176.4:3001
..

Finished in 1 minute 1.58 seconds (files took 3.71 seconds to load)
2 examples, 0 failures
```

**通りましたね、やったぜ**

## まとめ
Dockerでも簡単にHeadless Chromeを使った統合テスト環境を実現できました。
テストだけじゃなくてスプレイピングにも利用できるので試してみてください。

因みに、今回使ったサンプルソースは[こちらのリポジトリ](https://github.com/belion-freee/hello-world-rails)になるので参考までにどうぞ。