日記帳

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

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

きっかけ

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

「プロを目指す人のためのRuby入門」を買いました。評判に違わず素晴らしい本です。しかし、業務ではRSpecを使うのにMinitestのコードを書いても自分のためにならないと思いRSpecで書き換えることにしました。これをみてRSpecの書き方がわからない人が少しでも慣れてくれれば嬉しいです。

そもそも、プロを目指すというタイトルなのに、テストコードがテスティングフレームワークデファクトスタンダートであるRSpecではないのは何故なのだろうと疑問に思いました。その疑問に答えるヒントが、著者の伊藤惇一さんのスライドに書かれていました。

21ページ、xUnit形式(Minitest)とSpec形式(RSpec)の対比にて、

  • xUnit形式はRubyの言語がフルに利用できる
  • Spec形式はDSLに制約されやすい

と書かれています。つまり独自言語色の強いRSpecは、チェリー本のテーマであるピュアなRubyの入門からは多少外れた位置あると考えられます。


追記(2018.1.27)

コメント欄にて、著者の伊藤惇一さんよりMinitestを選んだ理由を教えていただきました。以下引用です。

  • RSpecはgemインストールの手順が必要になるので、初心者のハードルやトラブル発生のリスクが上がってしまう
  • RSpecDSLを使うので、DSLの説明が必要になる。これも初心者のハードルを上げる要因になる
  • RSpecは現場レベルのそこそこ複雑なテストを書くときには便利だが、本書ぐらいシンプルなテストであればMinitestでも十分
  • Railsチュートリアルも最近はMinitestを使っているので、Railsチュートリアル経験者にも馴染みやすい
  • xUnit形式はJUnitのような他のテスティングフレームワークでも考え方が共通しているので、他言語経験者も理解しやすい(他言語でもSpec形式のフレームワークは増えてきていますが)

前準備

RSpecを入れましょう。そのあとで、MinitestとRSpecのテストを分けるために、specフォルダを作りましょう。

gem install rspec
mkdir spec

実践

第3章 FizzBuzzブログラム

このコードをテストします。lib/のコードはチェリー本からの引用です。引用をなるべく減らそうと思ったのですが、説明する上でテスト対象のコードは残した方が理解しやすいと思い、そのまま引用しています。

lib/fizz_buzz.rb

def fizz_buzz(n)
  if n % 15 == 0
    "Fizz Buzz"
  elsif n % 3 == 0
    "Fizz"
  elsif n % 5 == 0
    "Buzz"
  else
    n.to_s
  end
end

これをRSpecで書き直しましょう。ファイル名はfizz_buzz_spec.rbにしましょう。

touch spec/fizz_buzz_spec.rb

これがRSpecのコードはこちらです。describeブロックでテストをグループ化しましょう。ここではtest_fizz_buzzというfizz_buzzメソッドをテストすることを宣言しています。さらにcontextブロックでテストで確認したい項目ごとに分けています。このテストでは、以下の4つに分けています。

  • 3の倍数を引数にしたら、Fizzが返却される
  • 5の倍数を引数にしたら、Buzzが返却される
  • 3と5の倍数を引数にしたら、Fizz Buzzが返却される
  • 上記以外だと引数が返却される

itブロックで期待している動作を明文化します。itブロックの中のexpectは、Minitestでいうassert_equalとほぼ同様の動きをします。このテストのみMinitestで書かれたコードも引用します。

test/fizz_buzz_test.rb(チェリー本より引用)

require 'minitest/autorun'
require_relative '../lib/fizz_buzz'

class FizzBuzzTest < Minitest::Test
  def test_fizz_buzz
    assert_equal '1', fizz_buzz(1)
    assert_equal '2', fizz_buzz(2)
    assert_equal 'Fizz', fizz_buzz(3)
    assert_equal '4', fizz_buzz(4)
    assert_equal 'Buzz', fizz_buzz(5)
    assert_equal 'Fizz', fizz_buzz(6)
    assert_equal 'Fizz Buzz', fizz_buzz(15)
  end
end

spec/fizz_buzz_spec.rb

require_relative '../lib/fizz_buzz'

describe 'fizz_buzz' do
  context '3または5の倍数ではない数字が引数の場合' do
    it '引数が返却される' do
      expect(fizz_buzz(1)).to eq('1')
      expect(fizz_buzz(2)).to eq('2')
      expect(fizz_buzz(4)).to eq('4')
    end
  end

  context '3の倍数が引数の場合' do
    it 'Fizzが返却される' do
      expect(fizz_buzz(3)).to eq('Fizz')
      expect(fizz_buzz(6)).to eq('Fizz')
    end
  end

  context '5の倍数が引数の場合' do
    it 'Buzzが返却される' do
      expect(fizz_buzz(5)).to eq('Buzz')
    end
  end

  context '3及び5の倍数が引数の場合' do
    it 'Fizz Buzzが返却される' do
      expect(fizz_buzz(15)).to eq('Fizz Buzz')
    end
  end
end

実行します。

$ rspec spec/fizz_buzz_spec.rb
....

Finished in 0.00374 seconds (files took 0.10134 seconds to load)
4 examples, 0 failures

オールグリーンですね!RSpecの詳しい構文はチェリー本の著者が書いた記事がわかりやすいです。

qiita.com

第4章 RGBカラー変換プログラム

同じように「第4章 RGBカラー変換プログラム」もRSpecに書き換えましょう。最初にテスト対象のコードです。

lib/rgb.rb

def to_hex(r, g, b)
  [r, g, b].inject('#') do |hex, n|
    hex + n.to_s(16).rjust(2, '0')
  end
end

def to_ints(hex)
  hex.scan(/\w\w/).map(&:hex)
end

次にRSpecです。ファイル作成は省略します。

spec/rgb_spec.rb

require_relative '../lib/rgb'

describe 'to_hex' do
  context 'r,g,bそれぞれの値が同じ値' do
    it '16進数に変換されたカラーコードが返却される' do
      expect(to_hex(0, 0, 0)).to eq('#000000')
      expect(to_hex(255, 255, 255)).to eq('#ffffff')
    end
  end
  context 'r,g,bそれぞれの値が違う値' do
    it '16進数に変換されたカラーコードが返却される' do
      expect(to_hex(4, 60, 120)).to eq('#043c78')
    end
  end
end

describe 'to_ints' do
  context 'r,g,bそれぞれの値が同じ値' do
    it '10進数に変換されたカラーコードが返却される' do
      expect(to_ints('#000000')).to eq([0, 0, 0])
      expect(to_ints('#ffffff')).to eq([255, 255, 255])
    end
  end
  context 'r,g,bそれぞれの値が同じ値' do
    it '10進数に変換されたカラーコードが返却される' do
      expect(to_ints('#043c78')).to eq([4, 60, 120])
    end
  end
end

ここでは、to_hexto_intsがそれぞれtrueになることが確認できるようにメソッドごとにdescribeブロックを分けています。早速実行してみましょう。

$ rspec spec/rgb_spec.rb
....

Finished in 0.00517 seconds (files took 0.10886 seconds to load)
4 examples, 0 failures

第5章 長さの単位変換プログラム

lib/convert_length.rb

UNITS = {m: 1.0, ft: 3.28, in: 39.37}

def convert_length(length, from: :m, to: :m)
  (length / UNITS[from] * UNITS[to]).round(2)
end

spec/convert_length_spec.rb

require_relative '../lib/convert_length'

describe 'convert_length' do
  context 'fromがメートル、toがインチの場合' do
    it 'メートルからインチに変換できる' do
      expect(convert_length(1, from: :m, to: :in)).to eq(39.37)
    end
  end

  context 'fromがインチ、toがメートルの場合' do
    it 'インチからメートルに変換できる' do
      expect(convert_length(15, from: :in, to: :m)).to eq(0.38)
    end
  end

  context 'fromがメートル、toがインチの場合' do
    it 'フィートからメートルに変換できる' do
      expect(convert_length(35000, from: :ft, to: :m)).to eq(10670.73)
    end
  end
end

どれ単位からどの単位の変換をテストしたいかをcontextブロックを分けています。実行してみましょう。

$ rspec spec/convert_length_spec.rb
...

Finished in 0.00338 seconds (files took 0.13897 seconds to load)
3 examples, 0 failures

第6章 ハッシュ記法変換プログラム

lib/convert_hash_syntax.rb

def convert_hash_syntax(old_syntax)
  old_syntax.gsub(/:(\w+) *=> */, '\1: ')
end

spec/convert_hash_syntax_spec.rb

require_relative '../lib/convert_hash_syntax'

describe 'convert_hash_syntax' do
  old_syntax = <<~TEXT
    {
      :name => 'Alice',
      :age => 20,
      :gender => :female
    }
  TEXT
  expected = <<~TEXT
    {
      name: 'Alice',
      age: 20,
      gender: :female
    }
  TEXT

  it '=>が:に置き換わる' do
    expect(convert_hash_syntax(old_syntax)).to eq(expected)
  end
end

追記(2018.1.27)

チェリー本著者である伊藤惇一さんよりコメントで指摘があり、describe直下にローカル変数を書くとスコープ範囲が広くなりテストが複雑化した時に可読性が落ちます。したがって、上記のコードはよくない例としてください。itブロックの中でローカル変数を書いてスコープ範囲を狭めましょう。

require_relative '../lib/convert_hash_syntax'

# example(it)内でローカル変数を宣言する
describe 'convert_hash_syntax' do
  it '=>が:に置き換わる' do
    old_syntax = <<~TEXT
      {
        :name => 'Alice',
        :age => 20,
        :gender => :female
      }
    TEXT
    expected = <<~TEXT
      {
        name: 'Alice',
        age: 20,
        gender: :female
      }
    TEXT
    expect(convert_hash_syntax(old_syntax)).to eq(expected)
  end
end

実行しましょう。

$ rspec spec/convert_hash_syntax_spec.rb
.

Finished in 0.00273 seconds (files took 0.1079 seconds to load)
1 example, 0 failures

最後に

RSpecDSLなので、最初は読み書きしにくいです。この続きではもっと慣れて書いてる記事をお見せしたいです。それでは。