Rails+M1 Mac+Docker環境にSystem Specを導入する

[Rails アプリに E2E テストを導入する（技術選定編）](https://qiita.com/bython/items/4d79ade2e8da5378e8b6)で E2E テストをする上での選択肢や各スタックについて理解しましたが、
今回は実際に E2E テストを実行する環境を構築して、簡単なテストの実行まで行いたいと思います。

## 環境

- OS: Ventura 13.5（22G74）
- CPU: Apple M1
- Ruby: 3.2.2
- Rails: 7.0.8
- Docker Desktop: 4.21.1

## Gem のインストール

Gemfile に必要な gem を追加します。webrdrivers が存在すると、Webdrivers::BrowserNotFound のエラーが発生するため削除します。

```ruby:Gemfile
group :test do
  gem 'capybara', '>= 2.15'
  gem 'selenium-webdriver'
  # gem 'webdrivers' ← これは削除する
end
```

その後コンテナ内で bundle install を実行します。

```bash:terminal
bundle install -j4
```

## Selenium コンテナ定義の追加

selenium を起動するための`selenium_chrome`コンテナを追加します。
注意点として、Apple シリコン 環境で動かす場合のコンテナイメージは`selenium/standalone-chrome`ではなく`seleniarm/standalone-chromium`という arm 環境に対応したイメージを指定してください。

```docker-compose.yml
  rails: &rails
    container_name: rails
    build:
      context: ./
      dockerfile: Dockerfile
    command: /bin/sh -c "rm -f tmp/pids/server.pid && bundle exec rails s -b '0.0.0.0'"
    stdin_open: true
    tty: true
    volumes:
      - .:/app:delegated
      - node_modules:/app/node_modules:delegated
      - bundle:/usr/local/bundle:delegated
    depends_on:
      - mysql
      - redis
    environment:
      DATABASE_HOST: mysql
      REDIS_HOST: redis
      WEBPACKER_DEV_SERVER_HOST: webpacker
      NODE_OPTIONS: --openssl-legacy-provider
      SELENIUM_DRIVER_URL: http://selenium_chrome:4444/wd/hub # <- 追加
      CAPYBARA_SERVER_HOST: rails # <- 追加
      CAPYBARA_APP_HOST: http://rails # <- 追加
    ports:
      - 3000:3000
  webpacker:
    <<: *rails
    container_name: webpacker
    command: bundle exec bin/webpack-dev-server
    environment:
      WEBPACKER_DEV_SERVER_HOST: 0.0.0.0
      NODE_OPTIONS: --openssl-legacy-provider
    ports:
      - 3035:3035
  # 追加
  selenium_chrome:
    # seleniumだとApple Siliconで動かないので、代わりにseleniarmを使う
    image: seleniarm/standalone-chromium:4.17.0
    ports:
      - '4444:4444'
```

## Capybara / System Spec の設定

まずコンテナ内で rails generate rspec:install コマンドを実行します。

```bash:terminal
rails generate rspec:install
```

すると`spec_helper.rb`と`rails_helper.rb`が生成されますが、これらには以下のような役割の違いがあります。

- `spec_helper.rb`
  - Rails に限定しない Rspec の基本設定を行うファイル
- `rails_helper.rb`
  - Rails のテスト環境を設定するためのファイル。spec_helper.rb を require することで、RSpec の基本設定を継承する

今回は Rails の PJ なので基本的に`rails_helper.rb`を編集することになります。
早速、rails_helper.rb を編集して、のちに作成する capybara 用の設定ファイルを読み込めるようにします。

```spec/rails_helper.rb
# spec/supportにcapybara用の設定ファイルを配置するのでコメントアウトを外す
Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f }
```

次に`spec/support/capybara.rb`を作成し、以下内容とします。
大まかな流れとして、`Capybara.register_driver :remote_chrome`で`remote_chrome`という名前のドライバーを定義して、`config.before(:each, type: :system, js: true)`で System Spec 実行の際に`remote_chrome`ドライバーを読み込むように定義しています。

```spec/support/capybara.rb
require 'capybara/rspec'
require 'selenium-webdriver'

# カスタムドライバーの定義
Capybara.register_driver :remote_chrome do |app|
  url = ENV["SELENIUM_DRIVER_URL"]
  options = Selenium::WebDriver::Chrome::Options.new
  options.add_argument('no-sandbox')
  options.add_argument('headless')
  options.add_argument('disable-gpu')
  options.add_argument('disable-dev-shm-usage')
  options.add_argument('remote-debugging-port=9222')
  options.add_argument('window-size=950, 800')

  Capybara::Selenium::Driver.new(app,
                                 browser: :remote,
                                 url: url,
                                 capabilities: options)
end

# System Specの設定
RSpec.configure do |config|
  # System Specのテスト実行前に実行される共通定義（jsフラグが未指定の場合）
  config.before(:each, type: :system) do
    driven_by :rack_test
  end

  # System Specのテスト実行前に実行される共通定義（jsフラグをtrueとする場合）
  config.before(:each, type: :system, js: true) do
    # 定義したカスタムドライバーを使用する
    driven_by :remote_chrome
    Capybara.server_host = IPSocket.getaddress(Socket.gethostname)
    Capybara.server_port = 4444
    Capybara.app_host = "http://#{Capybara.server_host}:#{Capybara.server_port}"
  end
end
```

## System Spec 定義の作成

テスト自体の定義を作成します。今回はテストが正常に実行するかを確認したいので、「ルートパスに遷移し、トップページの h2 タグ内の文字列が想定通り表示されているか」というシンプルなテストケースを行うことにします。
`spec/system/static_pages_spec.rb`を作成し、以下定義とします。

```spec/system/static_pages_spec.rb
require 'rails_helper'

RSpec.describe 'Static Pages', type: :system, js: true do
  it 'can render top page' do
    visit root_path

    expect(page).to have_selector('h2', text: '現役エンジニアから、')
    expect(page).to have_selector('h2', text: 'キャリアをつくるスキルを学ぶ')
  end
end

```

## テストの実行

webpacker/webpack を利用している場合、通常の静的ファイルのコンパイルのパス`public/packs`ではなく`public/packs-test`に静的ファイルをあらかじめコンパイルする必要があるので、コンテナ上で以下コマンドを実行し、静的ファイルの手動コンパイルを行います。

```bash:terminal
RAILS_ENV=test bundle exec rake assets:precompile
```

以上で準備は全て整ったので、最後に System Spec を実行します。コンテナ上で以下コマンドを実行します。

```bash:terminal
bundle exec rspec spec/system/static_pages_spec.rb
```

以下のように`1 example, 0 failures`となれば OK です。

```bash:terminal
root@2392cecb2a2e:/app# bundle exec rspec spec/system/static_pages_spec.rb
`Redis.current=` is deprecated and will be removed in 5.0. (called from: /app/config/initializers/redis.rb:2:in `<main>')
.

Finished in 11.81 seconds (files took 4.41 seconds to load)
1 example, 0 failures

root@2392cecb2a2e:/app#
```

以上で System Spec を導入し、実際にテストを実行することができました 🎉

## ハマりどころと対応

作業する中でハマった点を 2 点ご紹介します
切り分けの際は`save_and_open_page`というメソッドを使い、特定時点の画面の状態を HTML 形式で保存すると捗るかもしれません

```spec/system/static_pages_spec.rb
RSpec.describe 'Static Pages', type: :system, js: true do
  it 'トップページ表示' do
    visit root_path

    save_and_open_page # <-この時点のHTMLが保存される
    expect(page).to have_selector('h2', text: '現役エンジニアから、')
    expect(page).to have_selector('h2', text: 'キャリアをつくるスキルを学ぶ')
  end
end
```

### 「Selenium::WebDriver::Error::UnknownError: unknown error: session deleted because of page crash」エラーが発生する

これについては以下記事を参考にして、設定のチューニングを行って解消しました。

[メモリ省力化設定を入れて「Selenium::WebDriver::Error::UnknownError: unknown error: session deleted because of page crash」が出ないようにする](https://zaitaku-tushin.com/2023/06/07/%e3%83%a1%e3%83%a2%e3%83%aa%e7%9c%81%e5%8a%9b%e5%8c%96%e8%a8%ad%e5%ae%9a%e3%82%92%e5%85%a5%e3%82%8c%e3%81%a6%e3%80%8cseleniumwebdrivererrorunknownerror-unknown-error-session-deleted-because-of/)

### 「Webpack が JavaScript ファイルを見つけられない」エラーが発生する

以下のようなエラーが発生するケースです。

```
ActionView::Template::Error:
       Webpacker can't find products.js in /app/public/packs-test/manifest.json. Possible causes:
       1. You want to set webpacker.yml value of compile to true for your environment
          unless you are using the `webpack -w` or the webpack-dev-server.
       2. webpack has not yet re-run to reflect updates.
       3. You have misconfigured Webpacker's config/webpacker.yml file.
       4. Your webpack configuration is not creating a manifest.
       Your manifest contains:
       {
       }
```

これについては[テストの実行](#テストの実行)で触れた通り、public/packs-test に静的ファイルをあらかじめコンパイルする必要があるので、`bin/webpack`コマンドで手動コンパイルを行い解消しました。

## 参考になった記事

いつも皆様のアウトプットに助けられております。ありがとうございます

- [Everyday Rails - RSpec による Rails テスト入門](https://leanpub.com/everydayrailsrspec-jp/)
  - いかにして RSpec を使うかが実践的かつ体系的にまとまっており、Rails でテストを扱う人は読んでおいて損はないと思います。かの有名な [Junichi Ito](https://twitter.com/jnchito) さん翻訳
- [Docker for Mac M1 で System Spec 沼にハマった話](https://zenn.dev/kenkenlysh/articles/78c1797b830053)
  - M1 の Docker 上で system spec を動かそうと思ったら動かなかった話
- [Apple silicon 搭載 Mac で SystemSpec を動かす](https://zenn.dev/lincwell_inc/articles/09a2b0b393f911)
  - Apple silicon 搭載 Mac の Docker 上で SystemSpec を動かす際のハマりどころと設定方法
- [M1 Mac の Docker で Chromium を使った Feature Spec を動かす](https://tech.gunosy.io/entry/docker_chromium_with_m1)
  - M1 の Docker 上で Feature Spec が動かなくなった際の対応方法
- [M1 Mac で Rails + Docker + RSpec + Capybara での System テストの環境構築](https://qiita.com/ryouzi/items/fe0f583dcc93e06b6e64)
  - System Spec を実行するための設定について
- [Docker + Rails 7 + RSpec で System Spec を実行するための設定](https://peno022.hatenablog.com/entry/docker-rails7-rspec-configuration-for-system-spec)
  - System Spec の環境構築と CI（GitHub Actions）への対応方法
- [【RSpec】before(:suite)/before(:all)/before(:each)それぞれの違いについてまとめてみた](https://techtechmedia.com/before-suite-all-each-rspec/)
  - RSpec の before(:suite)/before(:all)/before(:each)の違いについての説明
- [メモリ省力化設定を入れて「Selenium::WebDriver::Error::UnknownError: unknown error: session deleted because of page crash」が出ないようにする](https://zaitaku-tushin.com/2023/06/07/%e3%83%a1%e3%83%a2%e3%83%aa%e7%9c%81%e5%8a%9b%e5%8c%96%e8%a8%ad%e5%ae%9a%e3%82%92%e5%85%a5%e3%82%8c%e3%81%a6%e3%80%8cseleniumwebdrivererrorunknownerror-unknown-error-session-deleted-because-of/)
  - Selenium が crash してしまう時の対応方法