日記帳

プログラミングのことをつぶやく日記です。

【後編】「プロを目指す人のためのRuby入門」(チェリー本)のMinitest部分をRSpecに書き換える

プロを目指す人のためのRuby入門 言語仕様からテスト駆動開発・デバッグ技法まで (Software Design plusシリーズ)

この記事は、下記の記事「【前編】「プロを目指す人のためのRuby入門」(チェリー本)のMinitest部分をRSpecに書き換える」の続きです。前編と同じようにテストをRSpecで書いていきます。前編との変更点としてピュアなRubyでRSecのテストを書くのはなく、RSpecらしく書くことを目標にしました。理由は、DRYしやすいためです。

leokun0210.hatenablog.com

第7章 改札機プログラムの作成

前編と同じようにテスト対象のプログラムとRSpecのコードを記述します。**なおlib/フォルダのプログラムは全てチェリー本の引用です

lib/gate.rb

# 改札機を表すクラス
class Gate
  STATIONS = [:umeda, :juso, :mikuni] #:nodoc:
  FARES = [150, 190] #:nodoc:

  # Gateオブジェクトの作成
  # ==== 引数
  # *+name+ - 駅名
  def initialize(name)
    @name = name
  end

  # 改札機に通って駅に入場する
  # ==== 引数
  # *+ticket+ - 切符
  def enter(ticket)
    ticket.stamp(@name)
  end

  # 改札機を通って駅から出場する
  # ==== 引数
  # *+ticket+ - 切符
  # ==== 戻り値
  # *+boolean+ - 運賃が足りて入れば+true+、不足して入れば+false+
  def exit(ticket)
    fare = calc_fare(ticket)
    fare <= ticket.fare
  end

  # 運賃を計算する
  # ==== 引数
  # *+ticket+ - 切符
  def calc_fare(ticket)
    from = STATIONS.index(ticket.stamped_at)
    to = STATIONS.index(@name)
    distance = to - from
    FARES[distance - 1]
  end
end

lib/ticket.rb

class Ticket
  attr_reader :fare, :stamped_at

  def initialize(fare)
    @fare = fare
  end

  def stamp(name)
    @stamped_at = name
  end
end

spec/gate_spec.rb

require_relative '../lib/gate'
require_relative '../lib/ticket'

describe 'gate' do
  let(:umeda) { Gate.new(:umeda) }
  let(:juso) { Gate.new(:juso) }
  let(:mikuni) { Gate.new(:mikuni) }
  let(:ticket) { Ticket.new(fare) }

  describe '梅田から' do
    before do
      umeda.enter(ticket)
    end

    context '十三まで' do
      let(:fare) { 150 }

      it { expect(juso.exit(ticket)).to be_truthy }
    end

    context '三国まで' do
      context '運賃が足りている' do
        let(:fare) { 190 }

        it { expect(mikuni.exit(ticket)).to be_truthy }

      end

      context '運賃が足りていない' do
        let(:fare) { 150 }

        it { expect(mikuni.exit(ticket)).to be_falsey }
      end
    end
  end

  context '十三から三国まで' do
    let(:fare) { 150 }

    before do
      juso.enter(ticket)
    end

    it { expect(mikuni.exit(ticket)).to be_truthy }
  end
end

前編と比べて、RSpecのコードが複雑になってきています。梅田から他の駅で降車する処理は、3つあるのでdescribeでblockにしてあります。梅田駅に入場する処理はbeforeblockの中で処理できるよう共通化しています。letを使って共通部分は変数にしています。letの遅延展開については下記のページが参考になります。booleanの判定は、be_truthy,be_falseyでmatchさせるようにしています。。

qiita.com

実行結果は、後編は省略します。

第8章 deep_freezeメソッドの作成

lib/bank.rb

require_relative '../lib/deep_freezable'

class Bank
  extend DeepFreezable

  CURRENCIES = deep_freeze(
    {
      'Japan' => 'yen', 'US' => 'dollar',
      'India' => 'rupee'
    }
  )
end

lib/team.rb

require_relative '../lib/deep_freezable'

class Team
  extend DeepFreezable

  COUNTRIES =
    deep_freeze(['Japan', 'US', 'India'])
end

lib/deep_freezable.rb

module DeepFreezable
  def deep_freeze(array_or_hash)
    case array_or_hash
    when Array
      array_or_hash.each do |element|
        element.freeze
      end
    when Hash
      array_or_hash.each do |key, value|
        key.freeze
        value.freeze
      end
    end
    array_or_hash.freeze
  end
end

spec/deep_freezable_spec.rb

require_relative '../lib/bank'
require_relative '../lib/team'

describe 'deep_freeze_to_array' do
  context '配列の値は正しいか?' do
    it '正しい' do
      expect(Team::COUNTRIES).to eq(['Japan', 'US', 'India'])
    end
  end

  context '配列自身がfreezeされているか?' do
    it 'freezeされている' do
      expect(Team::COUNTRIES.frozen?).to be_truthy
    end
  end

  context '配列の要素が全てfreezeされているか?' do
    it 'freezeされている' do
      expect(Team::COUNTRIES.all? { |country| country.frozen? }).to be_truthy
    end
  end
end

describe 'deep_freeze_to_hash' do
  context 'ハッシュの値は正しいか?' do
    let(:currencies) { {
        'Japan' => 'yen',
        'US' => 'dollar',
        'India' => 'rupee'
    } }

    it '正しい' do
      expect(Bank::CURRENCIES).to eq(currencies)
    end
  end

  context 'ハッシュ自身かfreezeされているか?' do
    it 'freezeされている' do
      expect(Bank::CURRENCIES.frozen?).to be_truthy
    end
  end

  context 'ハッシュの要素(キーと値)が全てfreezeされているか?' do
    it '全てfreezeされている' do
      expect(Bank::CURRENCIES.all? { |key, value| key.frozen? && value.frozen? }).to be_truthy
    end
  end
end

ここは今まで通り書いています。特筆する箇所もないでしょう。

第10章 ワードシンセサイザーの作成

Effectクラス

この章は、テストが2つあるのでそれぞれ見ていきましょう。

lib/effects.rb

module Effects
  def self.reverse
    ->(words) do
      words.split(' ').map(&:reverse).join(' ')
    end
  end

  def self.echo(rate)
    ->(words) do
      # スペースならそのまま返す
      # スペース以外ならその文字を指定された回数だけ繰り返す
      words.chars.map { |c| c == ' ' ? c : c * rate }.join
    end
  end

  def self.loud(level)
    ->(words) do
      # スペースで分割 > 大文字変換と"!"の付与 > スペースで連結
      words.split(' ').map { |word| word.upcase + '!' * level}.join(' ')
    end
  end
end

spec/effects_spec.rb

require_relative '../lib/effects'

describe 'reverse' do
  let(:effect) { Effects.reverse }
  subject(:effect_reverse) { effect.call('Ruby is fun!') }

  it '文字列が反転する' do
    expect(effect_reverse).to eq('ybuR si !nuf')
  end
end

describe 'echo' do
  let(:effect) { Effects.echo(arg) }
  subject(:effect_echo) { effect.call('Ruby is fun!') }

  context '引数が2の場合' do
    let(:arg) { 2 }

    it '各文字が2回ずつ繰り返される' do
      expect(effect_echo).to eq('RRuubbyy iiss ffuunn!!')
    end
  end

  context '引数が3の場合' do
    let(:arg) { 3 }

    it '各文字が3回ずつ繰り返される' do
      expect(effect_echo).to eq('RRRuuubbbyyy iiisss fffuuunnn!!!')
    end
  end
end

describe 'loud' do
  let(:effect) { Effects.loud(arg) }
  subject(:effect_loud) { effect.call('Ruby is fun!') }

  context '引数が2の場合' do
    let(:arg) { 2 }

    it '文字列が大文字になり、単語毎の末尾に`!!`がつく' do
      expect(effect_loud).to eq('RUBY!! IS!! FUN!!!')
    end
  end

  context '引数が3の場合' do
    let(:arg) { 3 }

    it '文字列が大文字になり、単語毎の末尾に`!!!`がつく' do
      expect(effect_loud).to eq('RUBY!!! IS!!! FUN!!!!')
    end
  end
end

describeごとにexpectで実行するメソッドと引数は同じなので、新しくsubjectを使用しています。subjectexpectの引数として使用するのが一般的です。

WordSynthクラス

lib/word_synth.rb

class WordSynth
  def initialize
    @effects = []
  end

  def add_effect(effect)
    @effects << effect
  end

  def play(original_words)
    @effects.inject(original_words) do |words, effect|
      effect.call(words)
    end
  end
end

spec/word_synth_spec.rb

require_relative '../lib/word_synth'
require_relative '../lib/effects'

describe 'play' do
  let(:synth) { WordSynth.new }
  subject(:play) { synth.play('Ruby is fun!') }

  context 'effectなし' do
    it 'エフェクトも付与される' do
      expect(play).to eq('Ruby is fun!')
    end
  end

  context 'reverseエフェクトを加える' do
    let!(:add_effect) { synth.add_effect(Effects.reverse) }

    it '文字列が反転する' do
      expect(play).to eq('ybuR si !nuf')
    end
  end

  context '多くのエフェクトを加える' do
    let!(:add_effect1) { synth.add_effect(Effects.echo(2)) }
    let!(:add_effect2) { synth.add_effect(Effects.loud(3)) }
    let!(:add_effect3) { synth.add_effect(Effects.reverse) }

    it '文字列が反転する' do
      expect(play).to eq('!!!YYBBUURR !!!SSII !!!!!NNUUFF')
    end
  end
end

ここではlet!を新しく使用しています。let!itblockに入る直前に実行されます。let!の後にletが評価されます。

最後に

今回はピュアRuby風に書くのではなく、RSpecに則ってDRYな書き方にしました。subjectletの違い、beforelet!の実行タイミングなど、実際に書き直してみると新しい発見がありました。本当はshared_contextなど使用して、共通化したかったのですが、技量が足りず実現には至らなかったです。今後もRSpecのコードをネタにできる素材があったら、記事を書きます。