Tbpgr Blog

Employee Experience Engineer tbpgr(てぃーびー) のブログ

Ruby | Convention over Configration(設定より規約。CoC) 〜 プロダクトコード・テストコード・ドキュメントの一括生成・一括更新をCoCで行ってみる

概要

Convention over Configration(設定より規約。CoC) 〜 プロダクトコード・テストコード・ドキュメントの一括生成・一括更新をCoCで行ってみる

詳細

Convention over Configration(設定より規約。CoC)はRuby on Railsで利用されているパターンです。

規約を利用することで、設定ファイルなどの記述を最小限に押さえることができます。
自明のデフォルト設定については何も記述する必要がなく、
特別なケースのみ設定値を記述すれば済むようになります。

代表例

例えばRuby on RailsActiveRecordです。
クラス名を元にテーブルとのマッピングを行います。
これにより、クラスと実テーブルのマッピングに関する設定が不要になります。

サンプル仕様

プロダクトコード、テストコード、ドキュメント
を下記の構成で作成します。(fizzbuzzクラスを作成する場合)

┣ bin
┃ ┗ generator.rb
┣ doc
┃ ┗ fizzbuzz_spec.md
┣ lib
┃ ┗ fizzbuzz.rb
┗ spec
    ┗ fizzbuzz_spec.rb

generator.rbはこの構成=規約を前提に各ファイルの生成や
ファイル内の一部のAPIコメントの更新を行うツールです。

generator.rbには以下の機能を持たせます。

・create機能
ruby ./bin/generator.rb create xxxx と呼び出すと
./doc/xxx.md
./lib/xxx.rb
./spec/xxx_spec.rb
を生成します。

・update_api_doc機能
ruby ./bin/generator.rb update_api_doc xxxx と呼び出すと
rspecで ./spec/xxx_spec.rb のテストを実行し、その実行結果から
xxx.mdにAPIドキュメントを生成
xxx.rbのメソッドヘッダーにも同様のAPIドキュメントを生成
します。

これによって、プロダクトコード・テストコード・ドキュメントのひな型を生成や
テストコード内のサンプルケースをもとに、プロダクトコードのAPIコメントやドキュメント内のメソッドの利用サンプルを
コマンドひとつで行うことができます。

サンプルコード

※プロダクトコードとテストコードのひな形生成には rspec_piccolo gemを利用します。

generator.rb

require 'active_support'

module Generator
  def self.create(basename)
    klass = basename.capitalize
    `rspec --init`
    `piccolo e -p #{klass} #{basename} #{basename}`
    Dir.mkdir('doc') unless Dir.exists?('doc')
    File.open("./doc/#{basename}.md", 'w:utf-8') do |f|
      f.print <<-EOS
# #{klass} class
## #{basename} method
pending
      EOS
    end
  end

  def self.update_api_doc(basename)
    spec = "#{basename}_spec.rb"
    result = `rspec -fd spec/#{spec}`
    samples = []
    result.each_line do |line|
      samples << line.chop.strip if line.strip.match /^Fizzbuzz.new.fizzbuzz/
    end
    update_src_api_doc_sample(basename, samples)
    update_api_doc_sample(basename, samples)
  end

  private
    def self.update_src_api_doc_sample(basename, samples)
      src = File.read("./lib/#{basename}.rb")
      samples = samples.join("\n  #   ")
      samples = "  #   #{samples}\n"
      api_renew_src = src.gsub /(\$fizzbuzz examples start\$\n).*(  # \$fizzbuzz examples end\$)/m, "\\1#{samples}\\2"
      File.open("./lib/#{basename}.rb", 'w:utf-8') { |f|f.print api_renew_src}
    end

    def self.update_api_doc_sample(basename, samples)
      klass = basename.capitalize
      File.open("./doc/#{basename}.md", 'w:utf-8') do |f|
        f.print <<-EOS
# #{klass} class
## #{basename} method
~~~ruby
#{samples.join("\n")}
~~~
        EOS
      end
    end
end

command = $*[0]
basename = $*[1]

case command
when 'create'
  Generator.create(basename)
when 'update_api_doc'
  Generator.update_api_doc(basename)
else
  fail "invalid command name #{command}"
end

generator.rb createの動作検証

$ ruby bin/generator.rb create fizzbuzz
$ tree
┣ bin
┃ ┗ generator.rb
┣ doc
┃ ┗ fizzbuzz.md
┣ lib
┃ ┗ fizzbuzz.rb
┗ spec
    ┗ fizzbuzz_spec.rb

生成されたファイルの確認

doc/fizzbuzz.md

# Fizzbuzz class
## fizzbuzz method
pending

lib/fizzbuzz.rb

# encoding: utf-8


class Fizzbuzz
  def fizzbuzz
    # TODO: implement your code
  end
end

spec/fizzbuzz_spec.rb

# encoding: utf-8
require "spec_helper"
require "fizzbuzz"

describe Fizzbuzz do

  context :fizzbuzz do
    cases = [
      {
        case_no: 1,
        case_title: "case_title",
        expected: "expected",
      },
    ]

    cases.each do |c|
      it "|case_no=#{c[:case_no]}|case_title=#{c[:case_title]}" do
        begin
          case_before c

          # -- given --
          fizzbuzz = Fizzbuzz.new

          # -- when --
          # TODO: implement execute code
          # actual = fizzbuzz.fizzbuzz

          # -- then --
          # TODO: implement assertion code
          # expect(actual).to eq(c[:expected])
        ensure
          case_after c
        end
      end

      def case_before(c)
        # implement each case before
      end

      def case_after(c)
        # implement each case after
      end
    end
  end
end

fizzbuzzを実装します。

$fizzbuzz examples start$

$fizzbuzz examples end$
は後でAPIコメントを差し込める箇所とするための規約です。

./lib/fizzbuzz.rb

# encoding: utf-8

class Fizzbuzz
  # FizzBuzz
  #
  # === Example
  # $fizzbuzz examples start$
  # $fizzbuzz examples end$
  #
  def fizzbuzz(count)
    ret = []
    (1..count).each do |c|
      if c % 15 == 0
        ret << 'fizzbuzz'
      elsif c % 3 == 0
        ret << 'fizz'
      elsif c % 5 == 0
        ret << 'buzz'
      else
        ret << c
      end
    end
    ret
  end
end

./spec/fizzbuzz_spec.rb

# encoding: utf-8
require "spec_helper"
require "fizzbuzz"

describe Fizzbuzz do
  context :fizzbuzz do
    cases = [
      {
        case_no: 1,
        case_title: "sample case",
        input: 15,
        expected: [1, 2, "fizz", 4, "buzz", "fizz", 7, 8, "fizz", "buzz", 11, "fizz", 13, 14, "fizzbuzz"],
      },
    ]

    cases.each do |c|
      it "Fizzbuzz.new.fizzbuzz(#{c[:input]}) # => #{c[:expected].to_s}" do
        begin
          case_before c

          # -- given --
          fb = Fizzbuzz.new

          # -- when --
          actual = fb.fizzbuzz(c[:input])

          # -- then --
          expect(actual).to eq(c[:expected])
        ensure
          case_after c
        end
      end

      def case_before(c)
        # implement each case before
      end

      def case_after(c)
        # implement each case after
      end
    end
  end
end

テスト実行

$ rspec -fd
Run options: include {:focus=>true}

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

Fizzbuzz
  fizzbuzz
    Fizzbuzz.new.fizzbuzz(15) # => [1, 2, "fizz", 4, "buzz", "fizz", 7, 8, "fizz", "buzz", 11, "fizz", 13, 14, "fizzbuzz"]

Finished in 0.002 seconds
1 example, 0 failures

generator.rb update_api_doc の動作検証

$ ruby bin/generator.rb update_api_doc fizzbuzz

update_api_docによって更新された ./lib/fizzbuzz.rb(抜粋)
メソッドヘッダーにテストケースの例が差し込まれました。

  # FizzBuzz
  #
  # === Example
  # $fizzbuzz examples start$
  #   Fizzbuzz.new.fizzbuzz(15) # => [1, 2, "fizz", 4, "buzz", "fizz", 7, 8, "fizz", "buzz", 11, "fizz", 13, 14, "fizzbuzz"]
  # $fizzbuzz examples end$
  #

update_api_docによって更新された ./doc/fizzbuzz.md

# Fizzbuzz class
## fizzbuzz method
~~~ruby
Fizzbuzz.new.fizzbuzz(15) # => [1, 2, "fizz", 4, "buzz", "fizz", 7, 8, "fizz", "buzz", 11, "fizz", 13, 14, "fizzbuzz"]
~~~

さらにテストケースを一つ増やしてみます。
./spec/fizzbuzz_spec.rb(抜粋)

      {
        case_no: 2,
        case_title: "sample case2",
        input: 10,
        expected: [1, 2, "fizz", 4, "buzz", "fizz", 7, 8, "fizz", "buzz"],
      },

ケースが増えたので、api documentも更新してみます

$ ruby bin/generator.rb update_api_doc fizzbuzz

update_api_docによって更新された ./lib/fizzbuzz.rb(抜粋)
メソッドヘッダーにテストケースの例が更新されました。

  # FizzBuzz
  #
  # === Example
  # $fizzbuzz examples start$
  #   Fizzbuzz.new.fizzbuzz(15) # => [1, 2, "fizz", 4, "buzz", "fizz", 7, 8, "fizz", "buzz", 11, "fizz", 13, 14, "fizzbuzz"]
  #   Fizzbuzz.new.fizzbuzz(10) # => [1, 2, "fizz", 4, "buzz", "fizz", 7, 8, "fizz", "buzz"]
  # $fizzbuzz examples end$
  #

update_api_docによって更新された ./doc/fizzbuzz.md

# Fizzbuzz class
## fizzbuzz method
~~~ruby
Fizzbuzz.new.fizzbuzz(15) # => [1, 2, "fizz", 4, "buzz", "fizz", 7, 8, "fizz", "buzz", 11, "fizz", 13, 14, "fizzbuzz"]
Fizzbuzz.new.fizzbuzz(10) # => [1, 2, "fizz", 4, "buzz", "fizz", 7, 8, "fizz", "buzz"]
~~~