Ruby on Railsにてモデルの機能を拡張する方法は、モジュールをインクルードするのが一般的です。インクルードするモジュールがActiveRecordに依存しない内容であれば、単体テストを書くことは難しくありません。しかし、ActiveRecordに依存している場合はちょっと面倒です。なぜなら、インクルードする側のモデルに対応するテーブルが必要になるからです。今回は、そんな血ヘドを吐くような困難に打ち勝つ方法をご紹介します。
目次
環境
- Ruby on Rails 4.0.0
- rspec-rails 2.14.0
サンプルモデル・モジュール
まずは、インクルードする側のArticleモデルです。
📄db/migrate/001_create_articles.rb
class CreateArticles < ActiveRecord::Migration def change create_table :articles do |t| t.string :title t.text :content t.integer :updated_count, default: 0 t.timestamps end end end
📄app/models/article.rb
class Article < ActiveRecord::Base include UpdatedCountable end
タイトル(title)・コンテント(content)・更新回数(updated_count)を持つモデルです。このモデルが UpdatedCountableモジュールをインクルードしています。
そして、こちらが UpdatedCountableモジュールです。
📄app/models/concerns/updated_countable.rb
module UpdatedCountable def self.included(base) base.class_eval do before_save :__updated_countable_up end end def __updated_countable_up self.updated_count += 1 end end
このモジュールをインクルードすると、自動的に before_saveコールバックにて updated_count属性がインクリメントされるという仕様になっています。トリガー的なモジュールというわけです。
それでは、この機能をrspecにてテストしましょう。(本当はテストが先なんですが!)
Articleモデルのspec
さっそく、spec/models/article_spec.rbに次のようなテストを書いてみました。
📄spec/models/article_spec.rb
require 'spec_helper' describe Article do describe "updated_countable" do before { @article = Article.create! } it { expect { @article.save! }.to change(@article, :updated_count).by(1) } end end
specを実行してみると・・・・・・見事に成功ですね! しかし、両手放しで喜んではいけません。もちろん、このままでも悪くはないのですが、少なくとも以下の点において非常に気持ちが悪いです。
- UpdatedCountableモジュールの仕様なのに、なんでArticleモデルのspecでテストしているんだ!
- 複数のモデルにインクルードされている場合、全部のモデルに同じspecを書くのか? 書かないのなら、どのモデルに書いたか忘れちゃいそう・・・
- 今後Articleモデルのテストコードが増えたときに、余計なコードがあると可読性が落ちる
といわけで、このテストをUpdatedCountableモジュール用に切り出してみましょう。
UpdatedCountableのspec
spec/models/concerns/updated_countable_spec.rbというファイルを作成し、そこにspecを書きます。
📄spec/models/concerns/updated_countable_spec.rb
require 'spec_helper' describe UpdatedCountable do before :all do m = ActiveRecord::Migration.new m.verbose = false m.create_table :ars do |t| t.integer :updated_count, default: 0 end end after :all do m = ActiveRecord::Migration.new m.verbose = false m.drop_table :ars end class Ar < ActiveRecord::Base include UpdatedCountable end describe "countup" do before { @ar = Ar.create! } it { expect { @ar.save! }.to change(@ar, :updated_count).by(1) } end end
ポイントは before :all / after :all でダミーのテーブルを生成/破棄していることです。このテーブルが無いとArクラスはActiveRecordとして動いてくれません。specを実行してみると・・・・・・・・・成功です! う〜む、感慨深い。
しかし、まだちょっと気持ちが悪いですね。このままだと、この手のモジュールのspecを書く度に、テーブル生成・破棄の処理を書かなくてはいけません。明らかにリファクタリングの臭いを発するコードです。
こういったコードはspec_helperに持って行って、共通化してしましましょう。spec/spec_helper.rbの末尾に以下のコードを追加します。
📄spec/spec_helper.rb
... ... def create_temp_table(name, &block) raise "no block given!" unless block_given? before :all do m = ActiveRecord::Migration.new m.verbose = false m.create_table name, &block end after :all do m = ActiveRecord::Migration.new m.verbose = false m.drop_table name end end
呼び出す側の updated_countable_spec.rbも修正します。
📄spec/models/concerns/updated_countable_spec.rb
require 'spec_helper' describe UpdatedCountable do create_temp_table :ars do |t| t.integer :updated_count, default: 0 end class Ar < ActiveRecord::Base include UpdatedCountable end describe "countup" do before { @ar = Ar.create! } it { expect { @ar.save! }.to change(@ar, :updated_count).by(1) } end end
非常にすっきりしました。再度specを実行すると・・・・・・・・・やっぱり成功! 見渡すかぎりの緑色。
これで、今後いくらモジュールが増えても怖くありません! なんだか、必要以上にモジュールを作ってしまいそうです。
関連する記事
- 【Rails 5】ヘルパーを使用しているactive_decoratorをrspecでテストする
- 自分はこんな感じでRailsアプリを作っております
- RailsでActiveRecordモデルのコレクションに対してメソッドを追加する
- simple_formとTwitter bootstrapで作る俺流鉄板Railsアプリ(その1)
- 【実践】RailsでExcelレポート出力(その1)
I got this very strange question.
Should it be
Updatedcountable <– lowercase 'c' in countable
instead of
UpdatedCountable
?
I’m sorry to be not good at writing English, so it can be a strange module name for you.
You can choose more appropriate name as you think.
but how come you think using lowercase is better…?
Oh, I’m terribly sorry.
I read it one more time.
It is definitely my mistake.
Your file name is app/models/concerns/updated_countable.rb (with the char _ that should work perfectly.