RSpecのexampleや失敗時のメッセージをわかりやすくするコツ(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

RSpecのexampleや失敗時のメッセージをわかりやすくするコツ(翻訳)

RSpecは、テストを書くための多方面に渡る柔軟なDSLを提供します。しかしマッチャーが実に多岐に渡っているため、Minitestのようなテストフレームワークに比べるとマッチャーを正しく使うために頑張りが必要になります。

次のspecをご覧ください。

RSpec.describe ShoppingCart, type: :model do
  let(:shopping_cart) { subject }

  describe '#to_order' do
    let(:product1) { create(:product, price: 10) }
    let(:product2) { create(:product, price: 3) }
    let(:user) { create(:user) }

    before do
      shopping_cart.insert(product1, quantity: 2)
      shopping_cart.insert(product2)
    end

    it '正しい属性を持つ' do
      order = shopping_cart.to_order(user)

      expect(order.price).to eq(23)
      expect(order.user).to eq(user)
    end

    it 'そのorderにproductsを追加する' do
      line_items = shopping_cart.to_order(user).line_items

      expect(line_items[0].product).to eq(product1)
      expect(line_items[0].quantity).to eq(2)
      expect(line_items[1].product).to eq(product2)
      expect(line_items[1].quantity).to eq(1)
    end
  end
end

これは使えると言えば使えますが、とても読みやすいとまでは言えません。特にローカル変数の代入のあたりが、どうもRSpecらしい書き方には見えません。最後に、末尾のexampleで行のitem順を特定の方法でソートしています。不要な詳細実装を副作用として強いている点がまったく好きになれません。

組み込みマッチャー

RSpecには、expectationで使える多数組み込みマッチャーがひととおり用意されています。RSpecのドキュメントで組み込みマッチャーargument matchersについて詳しく解説されています。

これらのマッチャーはさまざまな状況で利用できます。たとえばhave_attributesやそのエイリアスであるan_object_having_attributesもそうしたマッチャーのひとつです。

RSpec.describe ShoppingCart, type: :model do
  let(:shopping_cart) { subject }

  describe '#to_order' do
    let(:product1) { create(:product, price: 10) }
    let(:product2) { create(:product, price: 3) }
    let(:user) { create(:user) }

    before do
      shopping_cart.insert(product1, quantity: 2)
      shopping_cart.insert(product2)
    end

    it '正しい属性を持つ' do
      expect(shopping_cart.to_order(user))
        .to have_attributes(price: 23, user: user)
    end

    it 'そのorderにproductsを追加する' do
      expect(shopping_cart.to_order(user).line_items).to contain_exactly(
        an_object_having_attributes(product: product1, quantity: 2),
        an_object_having_attributes(product: product2, quantity: 1)
      )
    end
  end
end

contain_exactlyのおかげで、行itemのソート順に影響されなくなりました。

適切なマッチャーを使えば、specがまるで自然言語であるかのように読み取れます。exampleの内容を把握するために理解が必要となるロジックはそれほどありません。

RSpec 3で利用できるマッチャーとエイリアス一覧も便利なリソースです。

失敗時のメッセージをわかりやすくする

have_attributesのようなマッチャーで非常に残念なのは、ActiveRecordオブジェクトと組み合わせたときに、1件の失敗メッセージに数個分のオブジェクトでもたちまち画面がいっぱいになってしまう点です。

Failures:

1) ShoppingCart#to_order adds the products to the order
   Failure/Error: expect(shopping_cart.to_order(user).line_items).to contain_exactly(
     expected collection contained:  [(an object having attributes {:product => #<Product id: 1, name: "Product #3", price: 10, created_at: "2015-12-14 22:42:05", updated_at: "2015-12-14 22:42:05">, :quantity => 2}), (an object having attributes {:product => #<Product id: 2, name: "Product #4", price: 3, created_at: "2015-12-14 22:42:05", updated_at: "2015-12-14 22:42:05">, :quantity => 1})]
     actual collection contained:    [#<LineItem id: 1, product_id: 1, order_id: 1, quantity: 1, created_at: "2015-12-14 22:42:05", updated_at: "2015-12-14 22:42:05">, #<LineItem id: 2, product_id: 2, order_id: 1, quantity: 1, created_at: "2015-12-14 22:42:05", updated_at: "2015-12-14 22:42:05">]
     the missing elements were:      [(an object having attributes {:product => #<Product id: 1, name: "Product #3", price: 10, created_at: "2015-12-14 22:42:05", updated_at: "2015-12-14 22:42:05">, :quantity => 2})]
     the extra elements were:        [#<LineItem id: 1, product_id: 1, order_id: 1, quantity: 1, created_at: "2015-12-14 22:42:05", updated_at: "2015-12-14 22:42:05">]
   # ./spec/models/shopping_cart_spec.rb:22:in `block (3 levels) in <top (required)>'

こんなスパムまがいの大漁節メッセージを出力されても、どうでもよい情報がほとんどで肝心な部分が埋もれるだけなので馬鹿馬鹿しい限りです。

ひとつの方法は、失敗時に出力されるメッセージがもっとシンプルになるよう、次のような感じでオブジェクトに手を加えることです。

it 'adds the products to the order' do
  line_items = shopping_cart.to_order(user).line_items.map do |item|
    item.slice(:product, :quantity)
  end

  expect(line_items).to contain_exactly(
    { 'product' => product1, 'quantity' => 2 },
    { 'product' => product2, 'quantity' => 1 }
  )
end

私はこのアプローチはまったく好きになれません。失敗メッセージはシンプルになるものの、specにロジックが追加されて複雑になってしまっています。このspecに目を通すたびに、ロジックをがっつり読み解いてexampleを理解する必要があります。

specの簡潔さを損なわずにオブジェクトの出力をカスタマイズするのは、実は割と簡単です。単にinspectメソッドをオーバーライドするだけで、失敗メッセージ内のオブジェクトからの出力を変更できます。

class LineItem < ActiveRecord::Base
  belongs_to :product
  belongs_to :order

  def inspect
    "#{quantity} x #{product.inspect}"
  end
end

class Product < ActiveRecord::Base
  def inspect
    name
  end
end

無関係なノイズを除去したことで、失敗メッセージが読みやすくなりました。

Failures:

1) ShoppingCart#to_order adds the products to the order
 Failure/Error: expect(shopping_cart.to_order(user).line_items).to contain_exactly(
   expected collection contained:  [(an object having attributes {:product => Product #3, :quantity => 2}), (an object having attributes {:product => Product #4, :quantity => 1})]
   actual collection contained:    [1 x Product #3, 1 x Product #4]
   the missing elements were:      [(an object having attributes {:product => Product #3, :quantity => 2})]
   the extra elements were:        [1 x Product #3]
 # ./spec/models/shopping_cart_spec.rb:22:in `block (3 levels) in <top (required)>'

inspectの出力をカスタマイズするとppの出力がオーバーライドされるので、ppによるオブジェクトのinspectを普段から多用しているのであれば、このアプローチは不便かもしれません。

class SomeObject
  def inspect
    'Test'
  end

  def pretty_print(pp)
    pp.pp_object(self)
  end
end

しかし、ActiveRecordオブジェクトのからの出力はあまりイケていません。これについてうまい方法がまだありませんが、私が見つけた中でベストなのは、privateメソッドをオーバーライドしてActiveRecordの出力を先ほどのような見やすい出力に変える方法です。

class SomeObject < ActiveRecord::Base
  def inspect
    'Test'
  end

  private

  def custom_inspect_method_defined?
    false
  end
end

まとめ

RSpecが提供するマッチャーについて詳しく知っておけば、クリーンで簡潔でエレガントなexampleをかけるようになります。たまにはRSpecで使えるマッチャーのリストをざっと眺めてみる価値はあります。exampleを書いたら、一歩下がってもっとよい書き方はないかと考えてみることです。これを繰り返すうちに、クリーンなspecを書くノウハウを貯めることができ、ひいては複雑さを軽減できるようになります。

本記事で用いた完全なコードはGitHubのivedigit/rspec_matchersでご覧いただけます。

皆さんの好きなRSpecマッチャーがありましたら、ぜひ(原文)末尾のコメントにてお知らせください。

関連記事

Rails tips: RSpecテストの高速化/リファクタリングに役立つ4つの手法(翻訳)

Rails tips: RSpecテストを遅くする悪い書き方3種(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ