webpackerでデフォルト以外のenvironmentを使いたい場合

"environment"が指すものがRAILS_ENVとNODE_ENVの2つあり、かつwebpackerはその両方を利用するのでわかりづらい。

config/webpacker.yml

RAILS_ENVに対応したキーの設定を使う

config/webpack/*.js

NODE_ENVに対応したキーのファイルを使う。のだけど注意点がある。

例えばNODE_ENV=stagingのときにはconfig/webpack/staging.jsを使う。その中で次のようにしていたとする。このとき、変数environmentに入るのはwebpackerがproduction用に用意した設定になる。

const environment = require('./environment')

module.exports = environment.toWebpackConfig()

これは、webpackerが

  • test,development,production用のデフォルト設定を用意している
  • NODE_ENVがデフォルト設定のいずれかと同様であればそれを、どれでもなければproduction用のデフォルト設定を利用する

という仕様になっているから。このへんを見ると挙動がつかめるはず。

https://github.com/rails/webpacker/blob/d905149d8a33303a9c24002721bc872ef95a8b6f/package/env.js

NODE_ENVはどのように決まるか

./bin/webpackコマンドでコンパイルするときは、明示的にNODE_ENVを設定しないとdevelopmentになる

https://github.com/rails/webpacker/blob/d905149d8a33303a9c24002721bc872ef95a8b6f/lib/install/bin/webpack#L4

rails assets:precompilerails webpacker:compileを実行したときは、明示的にNODE_ENVを設定しないとproductionになる

https://github.com/rails/webpacker/blob/d905149d8a33303a9c24002721bc872ef95a8b6f/lib/tasks/webpacker/compile.rake#L29

前はRAILS_ENVも見てなかったっけ?

https://github.com/rails/webpacker/pull/1359 で今の挙動に変わったらしい

SentryやRollbarに通知する内容を2つに分けたい

  • Railsプロジェクトでエラーの管理をするとき、SentryかRollbarを使うことが多いはず(以下エラー管理システムと呼ぶ)
  • エラー管理システムには、システムでの想定外の挙動を通知させたい
  • 「システムでの想定外の挙動」を分けるとおおきく次の2つ
    • いわゆるバグ
    • 想定外の振る舞いをしている可能性があるもの
  • 後者は例えば「ActiveRecord::RecordNotFound例外で404ページを表示している箇所で、これ自体は想定内の挙動なのだけどサービス内に該当ページに遷移するリンクがあったときに気付けるように一応エラー管理システムに通知する」というもの*1
  • 後者の通知が多くて、対処が必要な前者が埋もれがちになる、という問題をよく見かける
  • 後者はエラー管理システムとは別で管理したい
    • ログとして情報をためておいて、時々参照したい
      • 単なるログだと流れてしまって見ない
    • 頻度やその時の状況、スタックトレースなどもほしい
    • あれ?でもこれってエラー管理システムの要件では?
  • なんかうまく管理する方法ないですかね…?

追記

@masa_iwasakiさんにアドバイスもらったので追記

*1:例が良くないかもしれないけど、御社のエラー管理システムのを眺めると「バグじゃないんだけど登録されているエラー」がたくさん見つかるはず

循環参照してるときのsaveにおけるActive Recordの挙動

class A < ApplicationRecord
  belongs_to :b
end

class B < ApplicationRecord
  belongs_to :a
end

a = A.new
b = B.new
a.b = b
b.a = a
a.save

みたいなときどのように動くのかを調べた。

↓このへんを参考にした

rails/autosave_association.rb at master · rails/rails

  • belongs_to関連を設定した場合、before_saveでsave_belongs_to_associationが実行される
  • a.saveしたときに、save_belongs_to_association経由で関連先bのsaveを実行する
  • bのbefore_saveでもsave_belongs_to_associationが実行され、先にaが保存される(このときaのsave_belongs_to_associationの二回目が実行されないような仕組みが入っている rails/autosave_association.rb at master · rails/rails )。
  • bにaの外部キーが設定されてbが保存される
  • aにbの外部キーが設定され、aが再度保存(update)される

という流れになるはず。

system specでNet::ReadTimeoutになったら

headless chromeを利用していて、超長い文章をフォームに入力しようとするとNet::ReadTimeoutでこける。そもそも超長い文章をフォームに入れないようにするべきだけど、他に方法がない*1か微妙な場合はtimeoutの間隔を伸ばすことで対応できる。

Capybara.register_driver :selenium_chrome_headless do |app|
  browser_options = Selenium::WebDriver::Chrome::Options.new
  browser_options.args << '--headless'
  browser_options.args << '--disable-gpu'
  browser_options.args << '--no-sandbox'

  Capybara::Selenium::Driver.new(
    app, browser: :chrome, options: browser_options, timeout: 600 # これ
  ).tap do |driver|
    driver.browser.manage.window.size = Selenium::WebDriver::Dimension.new(
      1920, 1080
    )
  end
end

*1:例: バリデーションエラーをE2Eテストしたいが文章の文字数しかバリデーションしていない

時々失敗するE2Eテストの原因を計測して判別する方法について

Railsをお仕事で使っている方はみんなE2Eのテストで時々失敗するやつに苦労しているんじゃないかなと思います。

@mtsmfmさんの発表スライドが詳しいので基本的にはこれで大体のケースには対応できるはず。

スライド読むのがめんどくさい人向けに、一部抜粋かつ要約すると

なんらかの要因(CSSアニメーションや画像のロード)でDOM要素が移動する場合にクリックをミスしてしまってテストが失敗するケースが「時々失敗するテスト」の大多数を占める

という話になります。これを防ぐためにはCSSのアニメーションをオフにしたり、↓のようなメソッドを利用して画像が完全にロードするまで待ってからクリックする、という方法があります。

  def wait_for_image_loading
    Timeout.timeout(Capybara.default_max_wait_time) do
      sleep 0.5 until evaluate_script(<<~JS)
        Array.prototype.every.call(
          document.querySelectorAll('img'),
          (e) => e.complete
        )
      JS
    end
  end

で、僕も同様のやり方を採用していたのですがそれでも時々テストが失敗します。そこでいい加減原因を究明しないとなーと思ったので計測の方法を調べました。

前提

  • Rails
  • rspec
  • capybara
  • selenium
  • headless chrome

です。バージョンはどれも現時点での最新安定版です

どこをクリックしているのか調べる

seleniumやchrome driverはそれぞれログ出力するオプションを持っていますが、僕が調べた限りではあまり有用な情報は取れませんでした(一番最後にログの取得方法を書いておきます)

seleniumには、クリックや画面遷移やjsの実行などの前後にコールバックメソッドを実行する仕組みが用意されています。

Class: Selenium::WebDriver::Support::AbstractEventListener — Documentation for selenium-webdriver (3.142.2)

これを継承した次のようなクラスを作ります。

class NavigationListener < Selenium::WebDriver::Support::AbstractEventListener
  def initialize(logger)
    @logger = logger
  end

  def after_find(by, what, driver)
    logger.info "finded #{by}, #{what}"
  end

  def before_click(element, driver)
    logger.info "clicking location: #{element.location}"

    if element.tag_name == 'a'
      href = element.attribute('href')
      logger.info "before click to: #{href}"
    end
  end

  def after_navigate_to(url, driver)
    logger.info "after navigate to: #{url}"
  end

  private

  attr_reader :logger
end

rails_helper.rbで次のようなcapybara用のdriverを作ります

logger = Logger.new(Rails.root.join('log', 'selenium_hooks.log'))
listener =  NavigationListener.new(logger))

Capybara.register_driver :selenium_chrome_headless do |app|
  browser_options = Selenium::WebDriver::Chrome::Options.new
  browser_options.args << '--headless'
  browser_options.args << '--disable-gpu'
  browser_options.args << '--no-sandbox'

  Capybara::Selenium::Driver.new(
    app, browser: :chrome, options: browser_options, listener: listener
  ).tap do |driver|
    driver.browser.manage.window.size = Selenium::WebDriver::Dimension.new(
      1920, 1080
    )
  end
end

これで、どこをクリックしようとしたのかログに残すことができます。次に、失敗を再現させてみましょう。たまにコケるテストを1000回ほど実行してみます。

# 略
    1000.times do
      it '送信したメッセージが表示されていること' do
        expect(page).to have_content(message.body)
      end
    end

このとき、rails_helper.rbで次のように最初にテストがコケたタイミングでテスト全体が終了するようにしておくと便利です。

config.fail_fast = 1

無事テストが失敗したら、失敗したときのクリック座標と成功したときのクリック座標とを比べてみたり、log/test.logでリクエストが正しく送られてきているかを確認します。僕は↓のような結果になりました。test.logを確認してもリクエストが送られていないので、これはクリックミスしている疑いが濃厚ですね…。

失敗しているとき
clicking location : #<struct Selenium::WebDriver::Point x=413.5, y=381.375>
成功しているとき
clicking location: #<struct Selenium::WebDriver::Point x=413.5, y=551.765625>

最終的に、僕の今回のケースではturbolinksを利用しているのが原因で、wait_for_image_loadingが遷移前のページに対して実行されてしまい有効に動いていなかったのが原因である、という結論になりました。次のページへの遷移を待ってからwait_for_image_loadingを実行することで解決。

seleniumとchrome driverのログを取得する方法

他にハマって時間を取られる人が減るように記録に残しておきます。seleniumのログは次のようにするととれます。

Selenium::WebDriver.logger.level = :debug
Selenium::WebDriver.logger.output = 'selenium.log'

Ruby Bindings · SeleniumHQ/selenium Wiki

chromedriverのログは次のように、新しくdriverを定義する中のdriver_optsオプションを利用します。

Capybara.register_driver :selenium_chrome_headless do |app|
  browser_options = Selenium::WebDriver::Chrome::Options.new
  browser_options.args << '--headless'
  browser_options.args << '--disable-gpu'
  browser_options.args << '--no-sandbox'

  Capybara::Selenium::Driver.new(
    app, browser: :chrome, options: browser_options, driver_opts: '--verbose'
  ).tap do |driver|
    driver.browser.manage.window.size = Selenium::WebDriver::Dimension.new(
      1920, 1080
    )
  end
end

driver_optsオプションで指定した文字列がchromedriverの起動オプションとして渡されます。(driver_optsオプションを利用すると、seleniumにdeprecatedだと怒られるのですがいったんこれでOKとしています)。

Logging - ChromeDriver - WebDriver for Chrome

ここで注意したいのが、chromedriverのログを取得するとき、seleniumのログ設定がないとログを取得することができないということです。どうやら、selenium側でchromedriverの標準エラー出力をseleniumのログ出力と同じにしている模様(かつ、chromedriver側がそれを尊重しているっぽい(未確認))。

selenium/service.rb at abf2219b575bea98e4093fb13843f487da7edb51 · SeleniumHQ/selenium

consoleを見る方法

おまけ。↓のようにするとconsoleの出力が確認できるみたいです(今回は特に活躍することがなかった)

logger.debug(page.driver.browser.manage.logs.get(:browser))

turbolinks環境でCSPを導入しようとしたらテストがコケた

CSPを導入しようとして、まず様子見でcontent_security_policy_report_only = trueとしていろいろ試していたらテストが落ちるようになった。content_security_policy_report_only = trueだとjsやcssは普通に適用されるはずなのにおかしい。

落ちているテストをよく見ると、flashメッセージを表示する箇所がほとんどだった。サーバのログを見るとなぜか同じページに2回アクセスしている。

いろいろ追いかけた結果、次の行が原因なのを突き止めた。

javascript_pack_tag 'application', 'data-turbolinks-track': 'reload', defer: true, nonce: true

CSP用にnonce: trueを追加した。これにより、毎回scriptタグのnonce属性が変更される。turbolinksで'data-turbolinks-track': 'reload'としていると、リソースのURLが変更されたときにページがフルにリロードされる仕様。なのだけど、これがURLだけではなくてどの属性が変更されてもフルでリロードされるようになっていた。

turbolinks/snapshot_renderer.ts at master · turbolinks/turbolinks

'data-turbolinks-track': 'reload'を削除したら落ちたテストが通るようになった。でもこれがよい解決策かというと微妙な気がする…

追記

テストは通るようになり、ページが毎回フルでリロードされることはなくなったけど、nonceが変更されたことでturbolinksが「新しいscriptタグが挿入された」と解釈するのかページアクセスのたびにjsが実行されてしまう><

更に追記

とりあえずIssueを立てておきました。

github.com

resize_to_fitオプションはどこからきたのか

Railsのmasterのコードを読んでいて、コメント行でvariantメソッドにresize_to_fitオプションを渡しているのが目についた。

rails/variant.rb at master · rails/rails

というのは、ImageMagickのオプションにresize_to_fitに相当するものがなかったから。minimagickはImageMagickにそのままオプション渡しているはずなのになんでだろう?

Command-line Options @ ImageMagick

と、調べたところ、Rails6からはminimagickに変わってImageProcessingがデフォルトになり、ImageProcessingがresize_to_fitを提供している、ということだった。

Mogrify unrecognized option resize-to-fit · Issue #39 · janko-m/image_processing