読者です 読者をやめる 読者になる 読者になる

Going Rails-way

Ruby on Rails 道を突き詰めたい

共通の振る舞いのspec

継承やmixinで振る舞いを共有するクラスがあるとき、共有する振る舞いのテストを個別に書いてたり、特定のクラスでだけ書くようでは、不十分だと思いました。

振る舞いが他の実装との兼ね合いで変わってしまったり、オーバーライドによって挙動が変わるなどしては困ります。

また、抽象クラスやモジュールでインターフェースのみ定義しておき、具象クラスでそれを実装するという仕様のとき、それが守られているかどうか確認したいことがありますが、すべてのクラスのテストでいちいち書くのも面倒だったりします。

このような共通の振る舞いのspecについて、最近はこんな感じで書いています。

共通する振る舞いのテストを使う

こうすることで、ClassAincludeしているモジュールの想定している振る舞いが実装されていることをテストでき、また理解しやすくなります。

describe ClassA do
  # ~のように振る舞うことをテスト
  it_behaves_like 'HogeExtension', described_class.new
  it_behaves_like 'FugaExtension', described_class.new
  it_behaves_like 'HelloClass'

  # ClassA独自の振る舞いのテスト
  describe '#hello' do
    subject { described_class.new.hello }
    it { is_expected.to eq('ClassA world') }
  end
end

テスト対象

module HogeExtension
  def hoge
    'hoge'
  end
end

module FugaExtension
  def fuga
    'fuga'
  end
end

class HelloClass
  def hello
    raise NotImplementedError # このメソッドはオーバーライドする想定(インターフェースのみ定義するなど)
  end
end

class ClassA < HelloClass
  include HogeExtension
  include FugaExtension

  def hello
    'ClassA world'
  end
end

共通する振る舞いをまとめる

include HogeExtensionしたクラスのインスタンスの振る舞いのテストをこんな感じでshared_examples_forでまとめてみます。

ブロック引数objとしてインスタンスを受け取るようにしておきました。

# spec/shared/examples/hoge_extension.rb
shared_examples_for 'HogeExtension' do |obj|
  describe '#hoge' do
    subject { obj.hoge }
    it "returns 'hoge'" do
      is_expected.to eq('hoge')
    end
  end
end

同様にFugaExtension

# spec/shared/examples/fuga_extension.rb
shared_examples_for 'FugaExtension' do |obj|
  describe '#fuga' do
    subject { obj.fuga }
    it "returns 'fuga'" do
      is_expected.to eq('fuga')
    end
  end
end

HelloClassクラスについては、インスタンスの生成方法がわかっているということにして、described_classを使いつつ、#helloが実装されていること、Stringを返すことをテストしてみました。

# spec/shared/examples/hello_class.rb
shared_examples_for 'HelloClass' do
  it { expect(described_class.ancestors).to be_include(HelloClass) }
  describe '#hello' do
    subject { described_class.new.hello }
    it { expect { is_expected }.to_not raise_error(NotImplementedError) }
    it { is_expected.to be_an_instance_of(String) }
  end
end

クラスメソッド increment_counter と インスタンスメソッドincrement!

実際の動作

increment_counter

irb(main):001:0> Post.increment_counter(:comments_count, 1)
  SQL (4.1ms)  UPDATE "posts" SET "comments_count" = COALESCE("comments_count", 0) + 1 WHERE "posts"."id" = ?  [["id", 1]]

increment!

irb(main):001:0> post = Post.create
   (0.1ms)  begin transaction
  SQL (0.3ms)  INSERT INTO "posts" ("created_at", "updated_at") VALUES (?, ?)  [["created_at", 2017-03-02 02:37:16 UTC], ["updated_at", 2017-03-02 02:37:16 UTC]]
   (3.9ms)  commit transaction
=> #<Post id: 1, title: nil, comments_count: nil, created_at: "2017-03-02 02:37:16", updated_at: "2017-03-02 02:37:16">
irb(main):002:0> post.title = 'Hoge'
=> "Hoge"
irb(main):003:0> post.increment!(:comments_count, 1)
  SQL (4.1ms)  UPDATE "posts" SET "comments_count" = COALESCE("comments_count", 0) + 1 WHERE "posts"."id" = ?  [["id", 1]]
=> #<Post id: 1, title: "Hoge", comments_count: 1, created_at: "2017-03-02 02:37:16", updated_at: "2017-03-02 02:37:16">
irb(main):004:0> post.reload
  Post Load (0.3ms)  SELECT  "posts".* FROM "posts" WHERE "posts"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<Post id: 1, title: nil, comments_count: 1, created_at: "2017-03-02 02:37:16", updated_at: "2017-03-02 02:37:16">

Rails4との違い

  • Rails4ではincrement!で他の属性と同時に更新できた
  • Rails4ではincrement!COALESCE関数が使われず、comments_count = 1のようなクエリだった

まとめ

  • increment!の場合タイムスタンプが更新されるが、increment_counterは更新されない
  • どちらも他の属性と同時に(1つのUPDATEで)更新することは、できない
    • Rails4ではできるけど、incrementという名前からして正しくない使い方と考えておこう

特定のspecで外部キー制約を無効にする

外部キー制約を使用すると、コード上のミスによりデータベースに不整合が発生するのを未然に防ぐことができてよい。

Active Record マイグレーション | Rails ガイド

Rails:外部キー制約をマイグレーションで表現する方法

第四章 キーレスエントリ(外部キー嫌い) - Qiita

stub_modelなどを使っていると、制約の影響でsaveでエラーが発生したりして煩わしいこともある。今の主題ではないのでここでは制約のことは置いておきたいのである。

仕方がないので

# spec/support/foreign_key_checks.rb

RSpec.configure do |config|
  config.before(:each, foreign_key_checks: false) do
    ActiveRecord::Base.connection.execute('SET FOREIGN_KEY_CHECKS=0')
  end

  config.after(:each, foreign_key_checks: false) do
    ActiveRecord::Base.connection.execute('SET FOREIGN_KEY_CHECKS=1')
  end
end

こういうのを作っておけば、こうやって特定のspecでだけ外部キー制約を無効にできる。

describe '#hoge', foreign_key_checks: false do
  # この中のexampleは外部キー制約を無視する
  it '...' do 
    # 
  end

  it '...' do 
    # ...
  end
end

いいやり方かはしらない。