Railsアプリのテスト実行時に特定の警告だけを例外に変換する

今後役立つのかわからないがRuby 2.7→3.xアップデートのときに使った方法のメモ。

Warning[:deprecated] = true すると表示される非推奨警告を発生させるコードがRailsアプリに含まれないように、テスト実行時だけこのような警告をあらかじめエラーに変換できると検出しやすくて便利。また、特定の警告(たとえばキーワード引数分離など)だけエラーにしたいという状況がありうる。jeremyevans/ruby-warningを使うことで、このような警告のハンドリングを実現できる。

RSpecのテストがある場合、spec/rails_helper.rbの最初のほうに次のようなコードを書いておく。

# spec/rails_helper.rb
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'

require 'warning'

# 1. 非推奨警告を出力する
Warning[:deprecated] = true

# 2. Railsアプリにキーワード引数分離に関する警告が含まれるとき例外を上げる
Warning.process(File.expand_path("..", __dir__), keyword_separation: :raise)

# 3. Warningの設定後にアプリと依存しているgemをロードする
require_relative '../config/environment'

まず、1のように非推奨警告を表示するようにRubyのレベルで設定しておく。その後、ruby-warning gemによって追加されるWarning#processを通じて、2のように親ディレクトリ(アプリのルートディレクトリ)配下のコードでキーワード引数分離(:keyword_separation)の警告が発生したら例外を発生(:raise)させる。ここで指定できる警告とハンドリングの種類についてはruby-warning gemのREADMEに説明がある。

https://github.com/jeremyevans/ruby-warning#label-Usage+

以上の設定は、3のようにアプリ自体のコードをロードする前に実行しておく必要がある。

参考

約8年開発されている Rails 製プロダクトを Ruby 3 にバージョンアップするために keyword parameters is deprecated を「網羅的に」検知する方法 - Money Forward Developers Blog

できるだけコントローラではなくモデルで例外処理する

問題: コントローラで例外処理している

アプリケーションが扱うドメイン特有のエラーを例外として表現する場合に、その例外をコントローラで処理するコードを書くと、ほとんどの場合でコードが読みにくくなったり、コードを変更しづらくなったりする結果となる。そして、開発の効率が落ちたり不具合を作りやすくなったりする。

具体的な問題は次のとおり。

コントローラのアクションの凝集度が下がる

コントローラのアクションに、たとえば複数の決済方法のエラーのような微妙に異なる種類の例外に対する処理を書くと、コントローラが肥大化し、凝集度が下がる。結果として、エラーが関わる変更を入れるときに、本来ビジネスロジックだけの変更であっても、コントローラとモデルの両方を必ず変更しなければならなくなる。また、関係の薄いさまざまな例外が1箇所で処理されることになり、コードが読みにくくなる。

class ChargesController < ApplicationController
  def create
    # ...

  # いろいろな例外処理
  rescue FooPay::Error
    # ...

  rescue BarPay::Error
    # ...

  rescue BazPay::Error
    # ...

  # 以降同じような節が続く
  end
end

さらに、こういうコードは「ここで例外処理しているから今回のも追加しておくか」という心理を誘発しやすい(割れ窓)。持続的なWebアプリケーション開発は割れ窓との戦いである。

テストの負担が増える

コントローラ層を対象としてテストする場合、いちいち結合テスト(近年のRailsだとintegration testまたはrequest spec)を書く必要がある。プレーンなクラスやいわゆるモデルなどと比較するとテストの準備が大変になるし、テストの実行速度も落ちる。また、異常系はとくにだが、しばしばリクエストやコンテキストを含めた複雑な事前状態を作る必要があり、読みにくいテストになる。

テストがしづらいところにがんばってテストを書くことはできるが、それは問題解決になっていない。

原因

そもそもコントローラはHTTPのことをやるところだが、それ以上の責務を負わせているのが上述した問題の主な原因といえる。

コントローラは、外から来るリクエストを受け取って、レスポンスを作って返すというのが本来の責務。HTTPに基づいてパラメータやヘッダのパース、Cookieの管理、レスポンスの生成などを実行する。

つまり、コントローラはレイヤードアーキテクチャのUI層やプレゼンテーション層にあたる。これらの責務やレイヤーには、アプリケーションの中核となるビジネスロジック(アプリケーションが解決する問題領域に固有のロジック)は含まれない。

一方、ビジネスロジックはモデルで扱う。これはレイヤードアーキテクチャで言うとドメイン層やモデル層と呼ばれるところ。ビジネスロジックを実行中に処理を継続できなくなったら発生させたいエラーは本来ビジネスロジックとして処理するべきだが、このエラーがモデルを外から利用しているコントローラにまで侵食してくると、上述したような問題が発生する。

解決策

本当に例外を使うべきか考える

ビジネスロジックにおけるエラーは「例外」ではないはず、とまず考えたい。たとえば、ユーザーが変な値を送信してきたときにそれを不正としてエラーを返すのは、例外的ではなく想定内のはず。別の側面だと、例外は制御構造から大域脱出するので、多用するとコードを読むときの負荷を高めうる。

例外の定義の1つを引いてくると『オブジェクト指向入門 第2版 設計・コンセプト』の12章に「例外とはルーチンコールの失敗を引き起こす可能性のある実行時イベントである」とある。つまり、例外とは、ルーチン(メソッド)の実行時に最後まで期待どおり実行できない可能性がある事態の発生を表す*1

どのようなエラーを例外として表現すべきか。技術的な例外としては外部サービスとの通信の失敗や、NoMethodErrorなどの実行時エラーがある。また、ビジネスロジックの例外としては、データとビジネスルールのあいだの不整合などで一度開始したトランザクションを続けられない状態になったとき発生させる例外が考えられる。これらのケースを除くと、本当に例外が必要ではないケースもそれなりにある。

そこで、エラーをできるだけ例外ではない方法で表現できないかを考える。たとえば、ビジネスロジック実行前のデータの検証失敗時に例外を起こすのではなく、検証の実行結果を取得できるような設計にする。これは、RailsだとActiveModel::Validationをうまく使い、入力値に関するエラーは戻り値、またモデルやフォームオブジェクトの属性として取得する事になる。

モデル層で適切にビジネスロジックを書く

ビジネスロジックに関するエラーをコントローラで処理するようになることが問題の原因だと書いた。ビジネスロジックに関するエラー処理をモデル層でやるためには、RailsのActive Recordのモデル(つまり、アクティブレコードパターンに基づいたDBのテーブルに対応しているクラス)だけではなく、必要に応じてふつうのクラスを作るようにする。そして、そのなかでエラー処理をやる。Railsならプレーンなクラスでもいいし、コントローラやビューで使えるようにするならActive Model (ActiveModel::Model)を使ってもよい。

このようなクラスをうまく作るためには、リソースだけではなくアプリケーションにおけるイベントもモデルとして抽出したり、ユースケースを見出してクラスにする必要がある。この話題については、非常にわかりやすくまとまっている次の文献を参照されたい。

トランザクションを張る場合は、例外を発生させることでトランザクションをロールバックする形になる。このケースにも対応するための案としては、次のようにビジネスロジックの一部としてロールバックと例外処理を実行し、このモデルを使う側からは実行結果だけが取得できるよう方法がある。これができると、コントローラは薄くなり、scaffoldしたときの構造に近くなる。

class Foo < ApplicationRecord
  # ...

  def execute_some_business_logic
    transaction do
      foobar!
      save!
    end

    true

  rescue ActiveRecord::RecordInvalid
    false

  rescue Foo::SomeError
    # 特有のビジネス例外としてなにか処理する

    false
  end
end

class FoosController < ApplicationController
  def update
    @foo = Foo.find(params[:id])

    if @foo.execute_some_business_logic
      # ...
    else
      # ...
    end
  end
end

DBと通信できないなどの技術的な例外はそもそもプログラムの実行を続けられないはずなので、ここで処理せず呼び出し元まで例外を突き抜けさせるのがよい。

FAQ: 404、500などのエラーにしたいときはコントローラより上の層で例外処理する必要があるのでは?

利用しているフレームワークにもよるが、404や500などの一般的、技術的エラーはアプリケーション共通の処理を定義していることが多いので、そういうケースで例外を発生させるのは問題なさそうに思う。たとえば、PUT /foos/:foo_id/bars/:bar_idのようにパスパラメータとしてIDが入るリソース設計にすると、

  • foobarは最初にコントローラでDBから探す
  • それらのレコードが見つかったら、そこからビジネスロジックに処理を依頼する

という流れになる。このときは、foobarがなければ、コントローラで例外処理をすることになる。

class BarsController < ApplicationController
  rescue_from ActiveRecord::RecordNotFound do
    # ...
  end

  def update
    foo = Foo.find(params[:foo_id])
    @bar = foo.bars.find(params[:bar_id])

    if @bar.update(bar_params)
      # ...
    end
  end
end

*1:契約によるプログラミングの言葉を使うと、呼び出されたルーチンが事後条件を満足できないときに発生するといえる cf. https://www.slideshare.net/t_wada/exception-design-by-contract

AOPに基づいてconcernモジュールを作る

発端: concernモジュールの命名をどうするか

ここではActiveSupport::Concernをextendしたモジュールのことをconcernモジュールやconcernと呼ぶ。

「Concernに何を実装すべきかは…非常に曖昧」1である。さらに、Railsアプリのメインであるルーティングからモデルまでは命名規約がかっちり決まっているが、concernに明示的な規約はない。concernも基本的にはRubyのモジュールなので、EnumerableComparableのような組み込みモジュールと似たような命名にするべきだろうか。実際、concernの名前に動詞+ableを使うのはよくやると思う。

Rubyのプレーンなモジュールとの違いとして、ActiveSupport::Concernにはincludedclass_methodsが存在する。これらの機能があることで、concernはコールバックの定義やクラスマクロの定義に使われるようなフォースが働いているように見える。そこで、そのような特徴を踏まえたうえでconcernならではの命名があるか考えてみる。

横断的関心事

concernとはいったいなんだったのかというのをふりかえってみると、アスペクト指向プログラミング(AOP)における横断的関心事(cross-cutting concern)が起源の1つといえそう2

AOPにおける横断的関心事についてはこちらが詳しい。

横断的関心事 - アスペクト指向なWiki

横断的関心事の分類の1つとして静的か動的かというものがある。静的な横断的関心事はインタータイプ宣言の機能を担う。AspectJなどではクラスに新たなメソッドやプロパティを追加する仕組みのことをインタータイプ宣言と呼んでいるようだ。一方、動的な横断的関心事はポイントカット+アドバイスの機能を担う。こちらは、コードを実行すると発生する特定のイベント(メソッド呼び出しなど)にあわせてアドバイスと呼ばれるコードを実行する。

それぞれ、concernだと次のようなものにあたるといえそう。

  • 静的な横断的関心事は、スコープやクラスマクロを追加するconcern
  • 動的な横断的関心事は、コールバックを追加するconcern

concernの事例

Basecampの事例

concernといえば、DHHがかつて示していたBasecampのコード例が有名。

これ自体の賛否は置いておくとして、includeしているモジュールがすべてcocnernモジュールだと仮定すると、その名前には次のようなバリエーションがある。

  • 名詞
    • 単数形、複数形
  • 動詞
    • 接尾辞がable
      • スコープやメソッドを追加するために使われているように見える
    • 現在形(原型と三単現両方ある?)
    • 過去分詞

想定しうるバリエーションがあらかた存在する状況で、これだけだと明確なルールが見出しにくい。

api.rubyonrails.orgの事例

ActiveSupport::ConcernFooモジュールのような単純な例だけだったので、concerningのほうを見ると、EventTrackingという名詞のconcernが紹介されている。このconcernはメソッドを追加しつつコールバックも定義している。

AOPの観点でconcernを作る

実事例を見つつ、さきほど書いたAOPの静的・動的横断的関心事によるconcernの分類に基づくと、ある程度納得感のある名前を持つconcernが作れそうに見える。

モジュールやクラスが静的な横断的関心事にあたるconcernをincludeすると、その時点でconcernが定義しているクラスマクロ、スコープ、またインスタンスメソッドが使えるようになる。つまり、それらを利用するか否かにかかわらず使えるようにはなる。とすると、Rubyのプレーンなモジュールと同じく動詞+ableにするのがよさそうに見える。

たとえば、パーフェクトRuby on Railsに載っているようなTaggableなら付与したタグのためにtagged_withスコープやtagsなどの関連が追加されるだろうし、UserSessionIssueableならログインセッションをセットアップするメソッドlogin_as_userが追加されるだろう。

module Taggable
  extend ActiveSupport::Concern

  included do
    has_many :tags #, ...
  end

  class_methods do
    def tagged_with
      # ...
    end
  end
end

module UserSessionIssueable
  extend ActiveSupport::Concern

  def login_as_user
    # ...
  end
end

一方、モジュールやクラスが動的な横断的関心事にあたるconcernをincludeすると、その時点でconcernが持つコールバックを必要に応じて自動で実行するようになる。「できるようになる」よりは「する」なので、concernのモジュール名は動詞の現在形にしておくのがよさそう。また、アトリビュートも追加されて、なんらかの状態を持たされるなら過去分詞にするといいかもしれない。

たとえば、NotifyImportantOperationなら重要な操作をどこかに自動で通知するコールバックを定義するだろうし、Obfuscatedなら特定のアトリビュートを自動で難読化したものを新たにアトリビュートとして持つようにできるだろう。

module NotifyImportantOperation
  extend ActiveSupport::Concern

  included do
    before_action :notify, only: [:create, :destroy]
  end

  def notify
    # ...
  end
end

module Obfuscated
  extend ActiveSupport::Concern

  included do
    attr_reader :obfuscated
    after_validation :set_obfuscated
  end

  def set_obfuscated
    # @obfuscated = ...
  end
end

うまく命名できるconcernになっているかどうか考えることは、大きすぎたりモジュールとしての意味のまとまりが希薄なconcernを作らないようにするのにも役立ちそうだ。

今回のAOPの考えかただと、concernに名詞で命名するとしっくりくる状況があまり思いつかなかった。一方で、EventTrackingの例のように関連とコールバック両方を定義するようなときは、利用可能なメソッドの定義と自動で実行されるコールバックの両方が手に入るので、上記の命名方法よりは適切に名詞で命名したほうがわかりやすいというのはあるかもしれない。


  1. 『パーフェクトRuby on Rails【増補改訂版】』13-1-3

  2. https://texta.pixta.jp/entry/2022/01/12/150000 など参照