Tbpgr Blog

元エンジニア 人事 tbpgr(てぃーびー) のブログ

Strike Three | FizzBuzzでストライク・スリーを実装してみる

概要

FizzBuzzでストライク・スリーを実装してみる

内容

FizzBuzzでストライク・スリーを実装してみます。
ストライク・スリーについては親記事参照。

仕様

DSLを利用してfizz_buzz_spec.rb を記載すると、
fizz_buzz_spec.rb をrequireすればプロダクトコードとして利用可能
FizzBuzzの実クラスはメモリ上に定義されます。
fizz_buzz_spec.rb をgまたはgenerateオプションを指定して呼ぶとテストコードを生成
fizz_buzz_spec.rb をdまたはdocオプションを指定して呼ぶとドキュメントを生成

構成

│  fizz_buzz_spec.rb # => DSLを利用して仕様件、テスト件、ソースを書くファイル
│
├─doc
│      fizz_buzz.html # fizz_buzz_spec.rbから生成されるドキュメント
│
├─spec # fizz_buzz_spec.rbから生成されるテスト群
│      fizz_buzz_buzz_spec.rb
│      fizz_buzz_fizz_buzz_spec.rb
│      fizz_buzz_fizz_spec.rb
│      spec_helper.rb # ※このファイルは単にrspec --initで生成したもの
│
└─strike_three # => DSLの処理本体
        strike_three.rb # => DSLスコープ管理用
        class_spec.rb # => クラスDSL本体
        method_spec.rb # => メソッドDSL本体
        test_spec.rb # => テストDSL本体
        doc_spec.rb # => ドキュメントDSL本体

ソース

fizz_buzz_spec.rb
# encoding: utf-8
require_relative "./strike_three/strike_three"
require "pp"

StrikeThree.class_spec(:FizzBuzz, binding).spec do
  name :FizzBuzz
  description "FizzBuzzゲーム"

  method_define.spec do
    name :fizz
    args :input
    desc "入力値が3の倍数の場合のみFizzを返却する"
    logic <<-EOS
      input % 3 == 0 ? "Fizz" : ""
    EOS
    sample :input => 2, :expected => '', :description => "3で割り切れない場合は空文字を返却"
    sample :input => 3, :expected => "Fizz", :description => "3で割り切れる場合はFizzを返却"
    define
  end

  method_define.spec do
    name :buzz
    args :input
    desc "入力値が5の倍数の場合のみBuzzを返却する"
    logic <<-EOS
      input % 5 == 0 ? "Buzz" : ""
    EOS
    sample :input => 4, :expected => '', :description => "5で割り切れない場合は入力値を空文字を返却"
    sample :input => 5, :expected => "Buzz", :description => "5で割り切れる場合はBuzzを返却"
    define
  end

  method_define.spec do
    name :fizz_buzz
    args :input
    desc <<-EOS
入力値が3の倍数の場合のみFizzを返却する。
入力値が5の倍数の場合のみBuzzを返却する。
入力値が3の倍数かつ5の倍数の場合のみFizzBuzzを返却する。
入力値が3の倍数、5の倍数どちらにも一致しない場合は入力値をそのまま返却する。
    EOS
    logic <<-EOS
      fb = fizz(input) + buzz(input)
      fb.empty? ? input.to_s : fb
    EOS
    sample :input => 3, :expected => "Fizz", :description => "3で割り切れる場合はFizzを返却"
    sample :input => 4, :expected => "4", :description => "3,5で割り切れない場合は入力値を返却"
    sample :input => 5, :expected => "Buzz", :description => "5で割り切れる場合はBuzzを返却"
    sample :input => 15, :expected => "FizzBuzz", :description => "3,5で割り切れる場合はFizzBuzzを返却"
    define
  end

  doc
end
strike_three.rb
# encoding: utf-8
require_relative "./class_spec"

class StrikeThree
  class << self
    def class_spec(class_name, kernel_scope)
      @cs = ClassSpec.new(kernel_scope)
    end
  end
end
class_spec.rb
# encoding: utf-8
require_relative "./method_spec"

class ClassSpec
  attr_accessor :klass, :method_specs, :kernel_scope, :_name, :_description
  alias :spec :instance_eval

  def initialize(kernel_scope)
    @kernel_scope = kernel_scope
    @method_specs = []
  end

  def name(name)
    eval "class #{name.to_s};end", kernel_scope
    @_name = name
    eval "self.klass = #{name.to_s}.new"
  end

  def description(description)
    @_description = description
  end

  def method_define
    m = MethodSpec.new(@klass)
    @method_specs << m
    m
  end

  def doc
    return unless $*.first || ($*.first == "d" || $*.first == "doc")
    doc = DocSpec.new(self)
    doc.generate_document
  end
end
method_spec.rb
# encoding: utf-8
require_relative "./test_spec"
require_relative "./doc_spec"

class MethodSpec
  attr_accessor :klass , :_name, :_args,:_logic,  :test_spec, :doc_spec, :_desc
  alias :spec :instance_eval

  def initialize(klass)
    @klass = klass
    @test_spec = TestSpec.new(@klass, self)
    @doc_spec = DocSpec.new(self)
  end

  def name(name)
    @_name = name
  end

  def args(args)
    @_args = args
  end

  def desc(desc)
    @_desc = desc
  end

  def logic(logic)
    @_logic = logic
  end

  def sample(options)
    @test_spec.append(options[:input], options[:expected], options[:description])
  end

  def define
    create_product_code

    # gまたはgenerateオプションを指定して実行した場合のみテストクラスを生成
    if $*.first && ($*.first == "g" || $*.first == "generate")
      create_test_code
    end
  end

  private
  def create_product_code
    args_str = ""
    if @_args
      if @_args.instance_of? Array
        args_str << "|#{@_args.join(",")}|" 
      elsif @_args.instance_of? Symbol
        args_str << "|#{@_args.to_s}|" 
      end
    end

    @klass.class.class_eval <<-EOS
      define_method :#{@_name.to_s} do #{args_str}
        #{@_logic}
      end
    EOS
  end

  def create_test_code
    test_spec.generate_test
  end
end
test_spec.rb
# encoding: utf-8
require "erb"
require "active_support/core_ext/string"

class TestSpec
  attr_accessor :klass , :_method, :_name, :_samples
  alias :spec :instance_eval

  def initialize(klass, _method)
    @_samples = []
    @klass = klass
    @_method = _method
  end

  def append(input, expected, description)
    @_samples << {:input => input, :expected => expected, :description => description}
  end

  def generate_test
    test_code = create_test_code
    output_test_file test_code
  end

  private
  def create_test_code
    template =<<-EOS
# encoding: utf-8
require_relative "./spec_helper"
require_relative "../<%= spec_path%>"

describe <%= class_name%> do
  describe :<%= method_name%> do
<%= method_tests%>
  end
end

    EOS
    spec_path = "#{@klass.class.to_s.underscore}_spec"
    class_name =  @klass.class
    method_name =  @_method._name.to_s

    method_test_ary = []
    _samples.each do |s|
      method_test_ary << "    it \"#{s[:description]}\" do"
      method_test_ary << "      actual = #{class_name}.new.#{method_name} #{s[:input]}"
      method_test_ary << "      expect(actual).to eq('#{s[:expected]}')"
      method_test_ary << "    end"
      method_test_ary << ""
    end

    method_tests = method_test_ary.join("\n")
    erb = ERB.new(template)
    test_code = erb.result(binding)

    test_code
  end

  def output_test_file(test_code)
    File.open("./spec/#{@klass.class.to_s.underscore}_#{@_method._name.to_s}_spec.rb", "w") do |f|
      f.puts test_code
    end
  end
end
doc_spec.rb
# encoding: utf-8
require 'kramdown'

class DocSpec
  attr_accessor :class_spec

  def initialize(class_spec)
    @class_spec = class_spec
  end

  def generate_document
    contents = get_document_contents
    output_document contents
  end

  def get_document_contents
    doc = []
    doc << "# #{class_spec._name}"
    doc << ""
    doc << "#{class_spec._description}"
    doc << ""

    class_spec.method_specs.each do |ms|
      args = ""
      args = ms._args.to_s if ms._args.instance_of? Symbol
      args = ms._args.join(",") if ms._args.instance_of? Array
      doc << "## ##{ms._name}(#{args})"
      doc << ""
      doc << "#{ms._desc.gsub("\n", "\n\n")}"
      doc << ""
    end
    doc.join("\n")
  end

  def output_document(contents)
    inner_body_html = Kramdown::Document.new(contents.force_encoding('utf-8')).to_html
    html_template =<<-EOS
<html>
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
%contents%
</body>
</html>
    EOS

    html = html_template.gsub("%contents%", inner_body_html)

    File.open("doc/#{class_spec._name.to_s.underscore}.html", "w:utf-8") do |f|
      f.puts html.encode("utf-8")
    end
  end
end

テストコード

生成コマンド
ruby fizz_buzz_spec.rb g

下記の3ファイルが生成されます。

fizz_buzz_fizz_spec.rb
# encoding: utf-8
require_relative "./spec_helper"
require_relative "../fizz_buzz_spec"

describe FizzBuzz do
  describe :fizz do
    it "3で割り切れない場合は空文字を返却" do
      actual = FizzBuzz.new.fizz 2
      expect(actual).to eq('')
    end

    it "3で割り切れる場合はFizzを返却" do
      actual = FizzBuzz.new.fizz 3
      expect(actual).to eq('Fizz')
    end

  end
end
fizz_buzz_buzz_spec.rb
# encoding: utf-8
require_relative "./spec_helper"
require_relative "../fizz_buzz_spec"

describe FizzBuzz do
  describe :buzz do
    it "5で割り切れない場合は入力値を空文字を返却" do
      actual = FizzBuzz.new.buzz 4
      expect(actual).to eq('')
    end

    it "5で割り切れる場合はBuzzを返却" do
      actual = FizzBuzz.new.buzz 5
      expect(actual).to eq('Buzz')
    end

  end
end
fizz_buzz_fizz_buzz_spec.rb
# encoding: utf-8
require_relative "./spec_helper"
require_relative "../fizz_buzz_spec"

describe FizzBuzz do
  describe :fizz_buzz do
    it "3で割り切れる場合はFizzを返却" do
      actual = FizzBuzz.new.fizz_buzz 3
      expect(actual).to eq('Fizz')
    end

    it "3,5で割り切れない場合は入力値を返却" do
      actual = FizzBuzz.new.fizz_buzz 4
      expect(actual).to eq('4')
    end

    it "5で割り切れる場合はBuzzを返却" do
      actual = FizzBuzz.new.fizz_buzz 5
      expect(actual).to eq('Buzz')
    end

    it "3,5で割り切れる場合はFizzBuzzを返却" do
      actual = FizzBuzz.new.fizz_buzz 15
      expect(actual).to eq('FizzBuzz')
    end

  end
end
テスト実行結果
$rspec
Run options: include {:focus=>true}

All examples were filtered out; ignoring {:focus=>true}
........

Finished in 0.007 seconds
8 examples, 0 failures

Randomized with seed 35675

ドキュメント

生成コマンド
ruby fizz_buzz_spec.rb d
生成されたドキュメント