[Ruby] fizzbuzz 問題を切り分けて考えるとテストが書きやすくなる

いままではこれを書いて満足していた。

(1..30).each do |i|
  if (i % 15).zero?
    puts 'FizzBuzz'
  elsif (i % 3).zero?
    puts 'Fizz'
  elsif (i % 5).zero?
    puts 'Buzz'
  else
    puts i
  end
end

バージョンは以下の通り。

% ruby -v
ruby 2.3.3p222 (2016-11-21 revision 56859) [x86_64-darwin16]
% gem list |grep test-unit
test-unit (3.1.5)

テストや改変に強い形式に書き直す

なるほど。

僕はFizzBuzz問題は次のような、3つの小さな問題に切り分けられると思うんだ。

  1. 1つの数を取ってFizzBuzzの結果を返す関数を作る問題
  2. 1からxまでの数をその関数に適用する関数を作る問題
  3. スクリプト引数xを2の関数に与えて結果をターミナルに出力する関数を作る問題

経験あるプログラマはこれらを瞬時に頭の中でやってしまうから、ぼくらの気持ちがわからないんだね。次のようなコードをよく見るけど、個人的には問題の切り分けができてないから、良いコードとは思えないんだよ。
テストしづらいし改変にも弱いからね。

1つの数を取ってFizzBuzzの結果を返す関数を作る問題

まずクラスに書き直してみた。

return がないとうまく動かない。

fizzbuzz.rb
#
# Class FizzBuzz
#
class FizzBuzz
  # 1つの数を取ってFizzBuzzの結果を返す関数
  def fizzbuzz(num)
    return 'FizzBuzz' if (num % 15).zero?
    return 'Fizz' if (num % 3).zero?
    return 'Buzz' if (num % 5).zero?
    num
  end
  # 1からxまでの数をその関数に適用する関数
  # スクリプト引数xを2の関数に与えて結果をターミナルに出力する関数
end
@fizz_buzz = FizzBuzz.new
(1..30).each do |i|
  puts @fizz_buzz.fizzbuzz(i)
end

使ったことがない Test::Unit でテストを書きます。

test/test_fizzbuzz.rb
require 'test/unit'
require './fizzbuzz'
class TestFizzBuzz < Test::Unit::TestCase
  def setup
    @fizz_buzz = FizzBuzz.new
  end
  def test_fizzbuzz
    assert_equal 1, @fizz_buzz.fizzbuzz(1)
    assert_equal 'Fizz', @fizz_buzz.fizzbuzz(3)
    assert_equal 'Buzz', @fizz_buzz.fizzbuzz(5)
    assert_equal 'FizzBuzz', @fizz_buzz.fizzbuzz(15)
  end
end

文中mod_zero = ->base{ n%base == 0 } 部分が理解できないけれどもあきらめて進める(後述)。

少し良くなったと思うけど、個人的にはwhenの順位を考慮しなきゃいけないってのが好きじゃないんだ。これはどうかな?

なるほど。文字列を作ってしまうのか。

fizzbuzz.rb
  def fizzbuzz(num)
    str = ''
    str << 'Fizz' if (num % 3).zero?
    str << 'Buzz' if (num % 5).zero?
    str.empty? ? num : str
  end

テストを確認すると違う書き方をしていた。
setup に答えを用意して、これを利用する。

test/test_fizzbuzz.rb
class TestFizzBuzz < Test::Unit::TestCase
  def setup
    @fizz_buzz = FizzBuzz.new
    @ans = [1, 2, 'Fizz', 4, 'Buzz', 'Fizz', 7, 8, 'Fizz', 'Buzz', 11, 'Fizz', 13, 14, 'FizzBuzz']
  end
  def test_fizzbuzz
    (1..15).each { |n| assert_equal(@ans[n - 1], @fizz_buzz.fizzbuzz(n)) }
  end

1からxまでの数をその関数に適用する関数を作る問題

RubyにはEnumeratorがあるから、これはばかみたいに簡単だよね。関数名をmap_uptoにしよう。

Enumerator で map が思い浮かばない人生。。。

さらにテストの書き方が分からずズルをした。
こちらも setup の答えを利用する。

test/test_fuzzbuzz.rb
  def test_map_upto
    assert_equal(@ans, @fizz_buzz.map_upto(15, @fizz_buzz.method(:fizzbuzz)))
  end

ここまでをまとめるとこうなる。

fizzbuzz.rb
class FizzBuzz
  # 1つの数を取ってFizzBuzzの結果を返す関数
  def fizzbuzz(num)
    str = ''
    str << 'Fizz' if (num % 3).zero?
    str << 'Buzz' if (num % 5).zero?
    str.empty? ? num : str
  end
  # 1からxまでの数をその関数に適用する関数
  def map_upto(max_num, fnc)
    (1..max_num).map { |n| fnc[n] }
  end
  # スクリプト引数xを2の関数に与えて結果をターミナルに出力する関数
end
@fizz_buzz = FizzBuzz.new
puts @fizz_buzz.map_upto(30, @fizz_buzz.method(:fizzbuzz))

スクリプト引数xを2の関数に与えて結果をターミナルに出力する関数を作る問題

ターミナルで $ ruby fizzbuzz.rb 15 こういう事をしたいと言うことだった。
受け取った関数にスクリプト引数を与えて each でまわしている。

fizzbuzz.rb
class FizzBuzz
  # スクリプト引数xを2の関数に与えて結果をターミナルに出力する関数
  def console(fnc)
    raise 'need an argument of integer' if ARGV[0].nil?
    max_num = ARGV[0].to_i
    fnc[max_num].each { |e| puts e }
  end
end
# require 時には呼ばれない
if __FILE__ == $PROGRAM_NAME
  @fizz_buzz = FizzBuzz.new
  result = ->(max) { @fizz_buzz.map_upto(max, @fizz_buzz.method(:fizzbuzz)) }
  @fizz_buzz.console(result)
end

if __FILE__ == $0 の部分は、require 時には呼ばれないとのこと。

ライブラリ中にこのように記載した箇所は、直接実行した場合には呼ばれるが、requireした時にはよばれません。

さらにテストコードを見ると -> が再出。調べると lambda の省略記法だった。

STDOUT をテストするコードが全く分からず。
動かすのにはまって、もっとも時間をとられた。

スクリプト引数の渡し方もはまった。
下記を読んで setup に ARGV のべた書きをしたら動いた。

test/test_fizzbuzz.rb
class TestFizzBuzz < Test::Unit::TestCase
  def setup
    ARGV[0] = 15
    @fizz_buzz = FizzBuzz.new
    @ans = [1, 2, 'Fizz', 4, 'Buzz', 'Fizz', 7, 8, 'Fizz', 'Buzz', 11, 'Fizz', 13, 14, 'FizzBuzz']
  end
  def test_console
    $stdout = op = StringIO.new('', 'w')
    result = ->(max) { @fizz_buzz.map_upto(max, @fizz_buzz.method(:fizzbuzz)) }
    @fizz_buzz.console(result)
    out = str2fizzbuzz_list(op.string)
    assert_equal(@ans, out)
  ensure
    $stdout = STDOUT
  end
  def str2fizzbuzz_list(str)
    str.split.map { |n| n =~ /(Fi|Bu)zz/ ? n : n.to_i }
  end
end

まとめ

最終的には、下記のようになった。

いろいろと足りない点や気づきが得られた。

STDOUT をテストするコードは、rspec だと簡単にかけるよう。
あとでまとめる。

補遺