CapybaraなしでRailsアプリのE2Eテスト(feature spec)を実行する
Capybaraはわりと万能だが、「別にそこまでやってくれなくていい」というところが多い。
Capybaraを最小限使ってラクする方法は↑の記事で書いたが、完全にCapybaraなしでRailsアプリをブラウザと対向させて試験する方法も調べたのでメモ。
Capybaraなしとはどういう状態か
基本的には、以下の3点以外は変わらない。
- テストサーバーは起動しない
 - Capybara::DSLがincludeされない
- visitなどは未実装の例外(NotImplementedError)ではなく、未定義(NameErrorあるいはNoMethodError)になる。
 
 - system specはSystemTestCaseがCapybaraに強く依存しているので、使えない
 
たとえばrspec-railsは以下のようなセットアップをしてくれるが、これはCapybaraの有無に関係なく行われる。
- spec/features配下を 
type: :featurespec/system 配下をtype: :systemとメタデータ付ける - feature specではdescribe, before, it の代わりに feature, background, scenario を使う
 
テスト開始時にサーバーを起動する(実験)
ようするにサーバーさえ起動すればいいので、Capybaraの以下の部分でやっていることをマネする。
- https://github.com/teamcapybara/capybara/blob/master/lib/capybara/server.rb
 - https://github.com/teamcapybara/capybara/blob/master/lib/capybara/registrations/servers.rb
 
Capybaraは親切なのでいろいろやっているが、なるべくシンプルにいきたいので以下のように妥協する。
- Webrickは使わないので、Puma起動ロジックのみにする
 - ポートは3000番に固定する
 - ホストも127.0.0.1に固定する
 
そうすれば、なんと以下のような設定だけでサーバーが起動してE2Eテストできる。
RSpec.configure do |config|
  config.before(:suite) do
    # Railsサーバー起動
    require 'rack/builder'
    testapp = Rack::Builder.app(Rails.application) do
      map '/__ping' do # サーバー起動したかどうかの確認用エンドポイント
        run ->(env) { [200, { 'Content-Type' => 'text/plain' }, ['OK']] }
      end
    end
    require 'rack/handler/puma'
    server_thread = Thread.new do
      Rack::Handler::Puma.run(testapp,
        Port: 3000,
        Threads: '0:4',
        workers: 0,
        daemonize: false,
      )
    end
    # Railsサーバーが起動完了してHTTPアクセスできるようになるまで待つ
    require 'net/http'
    require 'timeout'
    Timeout.timeout(3) do
      loop do
        puts Net::HTTP.get(URI("http://127.0.0.1:3000/__ping"))
        break
      rescue Errno::EADDRNOTAVAIL
        sleep 1
      rescue Errno::ECONNREFUSED
        sleep 0.1
      end
    end
  end
  # 自動操作をpuppeteer-rubyで行う
  config.around(type: :feature) do |example|
    Puppeteer.launch(channel: :chrome, headless: false) do |browser|
      @server_base_url = 'http://127.0.0.1:3000'
      @puppeteer_page = browser.new_page
      example.run
    end
  end
end
specはこの記事のときと同様、以下のようなもの。
冒頭で書いたように、Railsが提供するシステムテスト(system spec)は使えないので、rspec-railsが提供するfeature specの方を使う。
require 'rails_helper'
describe 'example' do
  let(:base_url) { @server_base_url }
  let(:page) { @puppeteer_page }
  let(:user) { FactoryBot.create(:user) }
  it 'can browse' do
    page.goto("#{base_url}/tests/#{user.id}")
    page.wait_for_selector('input', visible: true)
    page.type_text('input', 'hoge')
    page.keyboard.press('Enter')
    text = page.eval_on_selector('#content', 'el => el.textContent')
    expect(text).to include('hoge')
    expect(text).to include(user.name)
  end
end
実行すると、Capybaraのときと遜色なく実行できるし、Ctrl+Cでテスト中断も問題なく(サーバープロセスが変に残ったりせず)できた。

テスト開始時にサーバーを起動(ちょっとリファクタリング)
いくらなんでも前章のintegration_test_helper内容を「全部こぴぺして自分のアプリケーションに導入するぞ!」というのは気がひける。(書いてる私自身もやりたくないw)
そんなわけで、サクッと使えるようにGemにしました。
Rack::Handler::Puma を直接利用していた部分を、 Rack::Server#start という rackup コマンドの実装でも使われている汎用的なものに変更したほかは同じ処理をしています。
RSpec.configure do |config|
  config.before(:suite) do
    # Railsサーバー起動(rackup相当の動作)
    server = Rack::TestServer.new(
      # Rack::Serverのオプション
      # https://github.com/rack/rack/blob/2.2.3/lib/rack/server.rb#L173
      app: Rails.application,
      server: :puma,
      Port: 3000,
      daemonize: false,
      
      # Rack::Handler::Pumaのオプション
      # https://github.com/puma/puma/blob/v5.4.0/lib/rack/handler/puma.rb#L84
      Threads: '0:4',
      workers: 0,
    )
    
    server.start_async
    server.wait_for_ready
  end
だいぶ再利用性が向上してますね。(自画自賛w)
まとめ
「Capybaraじゃなくて、素でSelenium使いたい」 「Capybaraじゃなくて、素でPlaywright使いたい」 なんてことは意外と簡単にできる。
Discussion