Rails 6時代のCredentialsをどうやって書くのが一番スマートなのか

Rails6ではcredentials.yml.encが複数の環境で作れるようになりました*1。staging環境などでもCredentialsが使えてべんり!となったのだけどdevelopment環境やtest環境の秘密の文字列はどのようにするべきなのでしょうか。たぶんRails公式ではこういう書き方を想定しているんじゃないかな、と想像します。

secret_token = if Rails.env.development? || Rails.env.test?
                 '開発用の秘密の文字列'
               else
                 Rails.application.credentials.secret_token
               end

もちろんこれでも書けなくはないのだけど、こういうのが色んな場所にあったりするともっといい感じにできないもんかな…、という気持ちになりますね。

stagingと同様にconfig/credentials/development.yml.enc とconfig/credentials/test.yml.enc を作るとRails.application.credentials.secret_tokenだけですべての環境をカバーできるけど、たいていdevelopmentとtestは同じ秘密の文字列を指すのでこれはこれでなんだかな、となる。

そこでググってみたところRails 6 adds support for multi environment credentials – Saeloun Blog という記事では次のようにしているのを見つけました。

  • credentials.yml.enc を開発用(development, test)にする
  • config/credentials/production.yml.enc を別途作る

これだと前述の問題を解決できそう。Rails 5.2時代を経験していると「credentials.yml.encはproduction用途である」という思い込みがあるので初見で面食らう、という点を除けばこれが最適解なような気もします。

みなさんはどんな感じでCredentialsを管理しているか教えてほしいです(\( ⁰⊖⁰)/)。

*1:詳しく知りたい人はこちらを読んでみると良さそう https://gihyo.jp/book/2019/978-4-297-10869-4

実行時間が長いコマンドが終了したら教えてほしい

macの話。

実行時間が長いコマンドを走らせて待っている間に他のことを始めてしまい、いつのまにかコマンドを実行していることそのものを忘れてしまう、という事が多かったので、終わったら通知が来るといいな、と思ったのでした。

調べたらapple script経由で通知を出せるらしいという情報を得たので、とりあえずbundle installとyarn install用のエイリアスに仕込んでみました。

alias b="bundle install && osascript -e 'display notification \"finish!\" with Title \"bundle install\"'"
alias y="yarn install && osascript -e 'display notification \"finish!\" with Title \"yarn install\"'"

f:id:willnet:20191222165404p:plain

なかなかよさそう。

参考: terminal - How can I trigger a Notification Center notification from an AppleScript or shell script? - Ask Different

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

(2023/08/29追記)今はdriver_optsオプションは使えません。次のようにserviceオプションを利用します。

Capybara.register_driver :selenium_chrome_headless do |app|
  options = Selenium::WebDriver::Chrome::Options.new
  options.args << '--headless'
  options.args << '--disable-gpu'
  options.args << '--no-sandbox'
  service = Selenium::WebDriver::Service.chrome
  service.args << '--verbose'
  Capybara::Selenium::Driver.new(
    app, browser: :chrome, timeout: 600, options:, service:
  )
end

(追記ここまで)

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

selenium/service.rb at abf2219b575bea98e4093fb13843f487da7edb51 · SeleniumHQ/selenium

consoleを見る方法

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

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