原因不明のValidation Errorを、地獄の果てまで追い詰める


テスト実行時など、「どこで、何ゆえ、Validationエラーなのか」わからないことがある。
特に、子データなどをnested_attributeで処理していたり、
フォーム項目が大量にあったり、
そもそもどのValidatorがいつ発動したか、すらわからなかったり。

最近でくわした凶悪なものでは、
- test環境で
- 「has_many through」先のモデルで、
- さらにその先の「belongs_to」で存在しないデータに関連がついていた
- Validationメッセージは「has_many through」の部分で「○○は不正です」
fixtureを足して解決できたわけだが…、頭ひねったところでムリゲ。

そんなところで使える強力なTips(独学)3つ。備忘録

どのモデルの、どの種類のValidation Errorでも、NGになったタイミングで捕まえる

問答無用で、デバグします。
ActiveModel::Errors
gems/activemodel-5.2.0/lib/active_model/errors.rb # L295あたり

   def add(attribute, message = :invalid, options = {})
      message = message.call if message.respond_to?(:call)
      detail  = normalize_detail(message, options)
      message = normalize_message(attribute, message, options)
      if exception = options[:strict]
        exception = ActiveModel::StrictValidationFailed if exception == true
        raise exception, full_message(attribute, message)
      end

      details[attribute.to_sym]  << detail
      messages[attribute.to_sym] << message
    end

↑ ここらへんは、「Validation実行後、NGくらってなんらかのメッセージが追加された」時に必ず通る。
なので、ここで byebug挿入。

> @base

で該当のModelの詳細を。

   300:       if exception = options[:strict]
   301:         exception = ActiveModel::StrictValidationFailed if exception == true
(byebug) @base
#<City id: 980190962, code: "1", pref_code: "980190962", name: "新宿区", name_kana: "シンジュク", lon: 1.0, lat: 1.0, specialward: nil, creator_id: nil, updater_id: nil, deleter_id: nil, created_at: "2018-06-08 07:04:41", updated_at: "2018-06-08 07:04:41", deleted_at: nil>
(byebug) 
> where

で、タイミングを把握できる

冒頭のValidationは、↑のbreakpointを見張っていたら、
「ん? 変なModelで変なタイミングで引っかかってるな…」というので検出できた。

全部ログ出力してみる

以下のGemを使用する。
https://rubygems.org/gems/whiny_validation

出力イメージ

  Validation failed  #<Menu id: 77, name: "ヴェルサ", ..., tax_included: false, creator_id: nil, updater_id: nil, deleter_id: nil, created_at: nil, updated_at: nil, deleted_at: nil>
    => 率を入力してください
  Validation failed  #<AllianceProject id: nil, alliance_id: 999, project_id: 2, except_baby: false, kind: "limited", creator_id: 15, updater_id: 15, deleter_id: nil, created_at: nil, updated_at: nil, deleted_at: nil>
    => 具体メニューは不正な値です
  Validation failed  #<Alliance id: 999, name: "基本割引(料金調整用項目)", name_kana: "んんん", ..., creator_id: 1, updater_id: 15, deleter_id: nil, created_at: "2018-11-19 05:10:29", updated_at: "2018-11-19 05:10:29", deleted_at: nil>
    => 具体メニューは不正な値です
    => 単位を入力してください
    => 回収方法を入力してください
    => Alliance projectsは不正な値です
   (1.1ms)  ROLLBACK

2, 3モデル先でも、きっちり受け止めてくれるみたいですね。

Validationメッセージ自体は、i18n後の文字列(errors.full_messages)。
列名と箇所を判明したいので、full_messagesよりかは、
列キーとハッシュがいいな。

ドキュメントがほぼほぼないが、カスタマイズ方法模索中。

Testで、Validationの詳細をチェックする

minitest で controller_testなら、以下のテストを追加しておく

      assert_equal({}, assigns[:customer].errors.messages.select { |_, v| v.present? })
F

Failure:
CustomersControllerTest#test_should_create_customer [/home/ec2-user/environment/base2/test/controllers/customers_controller_test.rb:22]:
Expected: {}
  Actual: {:status=>["を入力してください"]}

どのフィールドが、何のValidationで落ちたか、を、テスト結果で確認できる
テスト結果で即わかるので、対応が楽。

毎回実装方法を忘れるので、
controller_test(今はfunctional_testというのか)のテンプレに格納。
rails g系で自動生成させます

以下、ご参考までに。

{root}/lib/templates/test_unit/scaffold/functional_test.rb

require 'test_helper'

<% module_namespacing do -%>
class <%= controller_class_name %>ControllerTest < ActionDispatch::IntegrationTest
  <%- if mountable_engine? -%>
  include Engine.routes.url_helpers
  <%- end -%>
  include Devise::Test::IntegrationHelpers

  setup do
    @<%= singular_table_name %> = <%= fixture_name %>(:one)
    sign_in users(:one)
  end

  test 'should get index' do
    get <%= index_helper %>_url
    assert_response :success
  end

  test 'should get new' do
    get <%= new_helper %>
    assert_response :success
  end

  test 'should create <%= singular_table_name %>' do
    assert_difference('<%= class_name %>.count') do
      post <%= index_helper %>_url, params: { <%= "#{singular_table_name}: {" %>
<% attributes_hash.each do |field, row| -%>
<% next if field.in? %w[id creator_id created_at updater_id updated_at deleter_id deleted_at] -%>
        <%= "#{field}: #{row}," %>
<% end %>
      <%= '}' %> }

      assert_equal({}, assigns[:<%= singular_table_name %>].errors.messages.select { |_, v| v.present? })
    end

    assert_redirected_to <%= singular_table_name %>_url(<%= class_name %>.last)
  end

  test 'should show <%= singular_table_name %>' do
    get <%= show_helper %>
    assert_response :success
  end

  test 'should get edit' do
    get <%= edit_helper %>
    assert_response :success
  end

  test 'should update <%= singular_table_name %>' do
    patch <%= show_helper %>, params: { <%= "#{singular_table_name}: {" %>
<% attributes_hash.each do |field, row| -%>
<% next if field.in? %w[id creator_id created_at updater_id updated_at deleter_id deleted_at] -%>
        <%= "#{field}: #{row}," %>
<% end %>
    <%= '}' %> }

    assert_equal({}, assigns[:<%= singular_table_name %>].errors.messages.select { |_, v| v.present? })
    assert_redirected_to <%= singular_table_name %>_url(<%= "@#{singular_table_name}" %>)
  end

  test 'should destroy <%= singular_table_name %>' do
    assert_difference('<%= class_name %>.count', -1) do
      delete <%= show_helper %>
    end

    assert_redirected_to <%= singular_table_name %>_url(<%= "@#{singular_table_name}" %>)
  end
end
<% end -%>