..

Ja Conversation By Rspec

13. 僕とお喋りしませんか?

とても基本的な話かもしれないですが、RSpecを記述する際に意識すべきことを備忘録として残します。

rspec-coreのREADMEの一文

先日チームのメンバーとrspec-coreのリポジトリを読んでいたのですが、READMEに興味深い一文がりました。

その文章がこちら。

Basic Structure RSpec uses the words “describe” and “it” so we can express concepts like a conversation:

参考:GitHub - rspec/rspec-core: RSpec runner and formatters

訳すとこんな感じ?

基本構造
RSpecはDescribeとItという単語を使用することで会話のように概念を表現することができるようになります。

この一文の「会話のように概念を表現」の部分が特に印象に残りました。

どんな会話?

会話の具体例が書いてありました。

“Describe an order.” “It sums the prices of its line items.”

訳すとこんな感じ?

orderがどう振る舞うか説明して!
いいよ!これはアイテムの金額を合計するんだ!

ちょっと訳が変かもしれませんが、そこにはプログラムがどう振る舞うかという説明が会話形式で記されています。

上記は describeit だけのパターンですが、これに context を加えると状況(条件)がわかるようになります。

ちなみに contextdescribe のaliasなので中身は一緒で用途が違うだけです。 同じ振る舞いをするaliasを用意している点からもRSpecがこの形式を重視しているように思います。

誰と誰の会話?

会話とはいったものの、これは誰と誰の会話なのでしょうか。

私は 現在の実装者と未来の実装者(同一人物の場合もある)がRSpecを介して会話して、現在の実装者がプログラムの振る舞いを説明していると解釈すると平和なのかなと考えてます。

もちろんRSpecはあくまでプログラムが期待通りに動作するとこを担保するためのものなので、説明形式でテストケースを作れば良いというわけではありません。

ただこの説明の形式を意識することでより大きな効果を得ることができると考えています。

RSpecはただ書くだけだと勿体ない

時々引数で文字列を渡していないエグザンプルや適当な文字列を渡しているコンテクストを見かけることがあります。 RSpecは、適切な構造・適切な説明を意識して記述することでプログラムがどのように振る舞うかを教えてくれるので、これらの点を手を抜いてしまうのは勿体ないと思います。

更にRSpec実行時に --format documentation のオプションを与えるとドキュメント形式で簡単な仕様がわかるような出力がされます。

私は最近忘れっぽく、先週書いたコードが全く見に覚えのないものだったりすることがあり、、、 このような細かい部分を意識することでRSpecの効果を最大限発揮してバグを減らし質の高いコーディングを心がけようと思いました。

具体例

具体例として自分でコードを書いてみました。

~/repos $ mkdir rspec-sample
~/repos $ cd rspec-sample/
~/repos/rspec-sample $ bundle init
Writing new Gemfile to /home/ezquerro/repos/rspec-sample/Gemfile
~/repos/rspec-sample $ vi Gemfile 
~/repos/rspec-sample $ cat Gemfile 
# frozen_string_literal: true

source "https://rubygems.org"

# gem "rails"
gem 'rspec-core'

~/repos/rspec-sample $ bundle install --path vendor/bundle
~/repos/rspec-sample $ ls -al
total 51
drwxrwxr-x 4 ezquerro ezquerro   7 Sep  9 14:17 .
drwxrwxr-x 9 ezquerro ezquerro   9 Sep  7 18:41 ..
drwxrwxr-x 2 ezquerro ezquerro   3 Sep  7 18:43 .bundle
-rw-rw-r-- 1 ezquerro ezquerro  94 Sep  7 19:01 Gemfile
-rw-rw-r-- 1 ezquerro ezquerro 205 Sep  7 19:01 Gemfile.lock
drwxrwxr-x 3 ezquerro ezquerro   3 Sep  7 18:43 vendor


~/repos/rspec-sample $ vi tax_calculator.rb 
~/repos/rspec-sample $ cat -n tax_calculator.rb
     1  class TaxCalculator
     2    class PriceMustBeIntegerError < StandardError; end
     3    class TaxRateMustBeFloatError < StandardError; end
     4  
     5    def initialize(original_price, tax_rate)
     6      raise PriceMustBeIntegerError unless original_price.kind_of?(Integer)
     7      raise TaxRateMustBeFloatError unless tax_rate.kind_of?(Float)
     8  
     9      @original_price = Integer(original_price)
    10      @tax_rate       = Float(tax_rate)
    11    end
    12  
    13    def calculate
    14      Integer @original_price * (1 + @tax_rate)
    15    end
    16  end
~/repos/rspec-sample $ irb
irb(main):001:0> require '~/repos/rspec-sample/tax_calculator'
=> true
irb(main):002:0> tc=TaxCalculator.new(1000, 0.13)
=> #<TaxCalculator:0x00007faea664fcd8 @original_price=1000, @tax_rate=0.13>
irb(main):003:0> tc.calculate
=> 1130

上記は税抜価格と税率から、税金適用後の価格を算出するクラスのコードです。

そして以下はそのRSpecのコードです。

~/repos/rspec-sample $ cat -n tax_calculator_spec.rb
     1  require 'rspec/core'
     2  require '~/repos/rspec-sample/tax_calculator'
     3                   
     4  RSpec.describe TaxCalculator do
     5    let(:price) { rand 1000..9999 } 
     6    let(:tax_rate) { rand 0.01..0.99 }
     7  
     8    describe '#initialize' do
     9      subject { TaxCalculator.new(price, tax_rate) }
    10  
    11      context 'with an Integer object as price and a Float object as tax rate' do
    12        it 'does not raise exception' do
    13          expect{ subject }.not_to raise_error
    14        end
    15  
    16        it 'returns an object of TaxCalculator class' do
    17          is_expected.to be_a_kind_of TaxCalculator
    18        end
    19      end
    20  
    21  
    22      context 'with nil as price' do
    23        let(:price) { nil }
    24  
    25        it 'raises TaxCalculator::PriceMustBeIntegerError' do
    26          expect{ subject }.to raise_error TaxCalculator::PriceMustBeIntegerError
    27        end
    28      end
    29  
    30      context 'with a String object as price' do
    31        let(:price) { '1000' }
    32  
    33        it 'raises TaxCalculator::PriceMustBeIntegerError' do
    34          expect{ subject }.to raise_error TaxCalculator::PriceMustBeIntegerError
    35        end
    36      end
    37  
    38      context 'with a Float object as price' do
    39        let(:price) { 1000.0 }
    40  
    41        it 'raises TaxCalculator::PriceMustBeIntegerError' do
    42          expect{ subject }.to raise_error TaxCalculator::PriceMustBeIntegerError
    43        end
    44      end
    45      
    46      context 'with nil as tax rate' do
    47        let(:tax_rate) { nil }
    48  
    49        it 'raises TaxCalculator::TaxRateMustBeFloatError' do
    50          expect{ subject }.to raise_error TaxCalculator::TaxRateMustBeFloatError
    51        end
    52      end
    53  
    54      context 'with a String object as tax rate' do
    55        let(:tax_rate) { '0.13' }
    56  
    57        it 'raises TaxCalculator::TaxRateMustBeFloatError' do 
    58          expect{ subject }.to raise_error TaxCalculator::TaxRateMustBeFloatError
    59        end
    60      end
    61  
    62      context 'with an Integer object as tax rate' do
    63        let(:tax_rate) { 1 }
    64  
    65        it 'raises TaxCalculator::TaxRateMustBeFloatError' do 
    66          expect{ subject }.to raise_error TaxCalculator::TaxRateMustBeFloatError
    67        end
    68      end
    69    end
    70  
    71    describe '#calculate' do
    72      let(:tax_calculator) { TaxCalculator.new(price, tax_rate) }
    73      subject { tax_calculator.calculate }
    74  
    75      it 'returns the amount after taking taxes into account' do
    76        is_expected.to eq Integer price * (1 + tax_rate)
    77      end
    78    end
    79  end
    80

これを会話形式で表現すると以下のようになります。(超訳)

太郎「 TaxCalculator#initialize の振る舞いを説明して!」
花子「いいわよ!」
花子「価格として Integer クラスのオブジェクトを、税率として Float クラスのオブジェクトを引数に渡すと例外を発生させず TaxCalculator クラスのオブジェクトを返すよ」
花子「それ以外のクラスのオブジェクトを渡すとそれぞれ TaxCalculator::PriceMustBeIntegerError と TaxCalculator::TaxRateMustBeFloatError が発生するよ」
太郎「ありがとう! TaxCalculator#calculate も教えて!」
花子「このメソッドを呼び出すと税率が適用された金額を Integer クラスのオブジェクトで返すよ」
太郎「ありがとう!」

こんな感じでしょうか?

また、オプションを渡して実行すると仕様が理解できるドキュメント形式で出力されます。

~/repos/rspec-sample $ bundle exec rspec tax_calculator_spec.rb --format documentation

TaxCalculator
  #initialize
    with an Integer object as price and a Float object as tax rate
      does not raise exception
      returns an object of TaxCalculator class
    with nil as price
      raises TaxCalculator::PriceMustBeIntegerError
    with a String object as price
      raises TaxCalculator::PriceMustBeIntegerError
    with a Float object as price
      raises TaxCalculator::PriceMustBeIntegerError
    with nil as tax rate
      raises TaxCalculator::TaxRateMustBeFloatError
    with a String object as tax rate
      raises TaxCalculator::TaxRateMustBeFloatError
    with an Integer object as tax rate
      raises TaxCalculator::TaxRateMustBeFloatError
  #calculate
    returns the amount after taking taxes into account

Finished in 0.00506 seconds (files took 0.0943 seconds to load)
9 examples, 0 failures