やさしいRubyメタプログラミング: RSpecを自分で作って学ぶ(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

以下の記事を参考に、テスト対象コードとテスト用コードを1つのファイルにまとめて記述しておくと試しやすいでしょう。

Rails tips: コードとテストを同じファイルに書けるRSpec autorun(翻訳)

やさしいRubyメタプログラミング: RSpecを自分で作って学ぶ(翻訳)

RubyのDSLとメタプログラミングのやさしい入門記事です。

DSL(Domain Specific Language): 言語を新たに作ることで特定の問題を記述する、プログラミング技法の一種です。Ruby on Railsフレームワークのルーティングや、RSpecの構文はDSLのよい例です。

メタプログラミング: プログラミング技法の一種で、指定のコードに別のコードを生成する能力を与えます。メタプログラミングはDSLとの関連が深く、メタプログラミングが使えないとDSLを使えません。

RSpec: RubyコードをテストするDSLのテストツールであり、Rubyで記述されています。RSpecはgemとして利用できるので、Ruby on Railsアプリで簡単に使えます。

DSLとメタプログラミングは、どちらもそれ単体だけで大きなトピックになりますので、本記事では楽しくわかりやすい方法でざっくりご紹介するにとどめます。RSpecを使ったことのない方には、先にRSpecを少しでも使ってみてからお読みいただくことをおすすめします。

DSLを使うべき理由

独自のRSpecをこしらえる前に、DSLやメタプログラミングの使い所や使うべき理由がまだよくわからない方向けに、簡単なコード例を1つご覧いただきます。次の設定用クラスで考えてみましょう。

class Server
  attr_reader :env, :domain
end

class Config
  def server
    @server ||= Server.new
  end
end

これは設定用クラスの模造品です。環境やサーバーのドメインを設定するには、次のような呼び出しをかけなければなりません。

config = Config.new
config.server.env = 'production'
config.server.domain = 'domain.com'

この設定方法を実際に使うわけではないので、このままでは何だかつまらないですね。メタプログラミングをカッコよく使って、もう少し楽しくしてみましょう。ここではinstance_evalメソッドを使うことにします。

インスタンスをevalって?

はい、本記事で最も多用されるのがこのメソッドです。先ほどの新しい設定用クラスを題材に、意味のあるコード例を書いてご説明します。

class Server
  attr_accessor :env, :domain
end

class Config
  def initialize(&block)
    instance_eval &block
  end

  def server
    @server ||= Server.new
  end
end

Config.new do
  server.env = 'production'
  server.domain = 'domain.com'
end

オシャレ感増し増しの設定方法になりましたね。先ほどの実装から唯一変更された点は、initializeメソッドを追加して、ブロック引数を1つ取れるようにしたことです。このメソッドで、本日の主役であるinstance_evalを呼び出すわけです。instance_evalが何をするかというと、渡されたブロック内のあらゆるコードを「Configクラスインスタンスのコンテキストで実行」します。言い換えれば、渡すブロック内ではいちいちconfig.プレフィックスを付けなくても(そのインスタンス内の)どんなオブジェクトにもアクセスできるということです。

クラスの例

独自のRSpecをこしらえるからには、その独自RSpecでテストする対象が必要です。ここではTDD(テスト駆動開発)方式ではなく、最初にサンプルのクラスを作成して本記事での説明に役立てたいと思います。

class NumberService
  def number
    12
  end
end

張りぼてのクラスができたので、RSpecで以下のようなテストを書いてみましょう。

describe NumberService do
  describe '#number' do
    it '12を返す' do
      expect(NumberService.new.number).to eq(12)
    end

    it '10を返さない' do
      expect(NumberService.new.number).not_to eq(10)
    end
  end
end

既にinstance_evalについて学んだので、このようなコードの書き方について薄々見当がついているかと思います。このテストで使われているすべてのメソッドをどう自作するかを考えることにしましょう。

  1. describe: クラスまたは文字列を1つの引数として受け取ります。どちらを渡しても出力は文字列になります。このあたりは、--format documentationを追加してテストを実行してみればおわかりいただけると思います。
  2. it: 指定された実行パスのdescriptionを受け取ります。describeメソッドのブロック内に記述することで、テストの概要をドキュメントらしい形式で表示できます。
  3. expect: 単に値を1つ取ります。私もRSpecのソースコードを詳しく見たわけではありませんので、実際の実装と比べたところで相当かけ離れているかと思います。
  4. tonot_toは、それぞれ==演算子と!=演算子と同等です。
  5. eqは読みやすくするためのシンタックスシュガーです。

少なくともこの6つのメソッドを実装しなければなりません。まずはdescribeメソッドから。

class Describe
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end
end

このメソッドは引数を2つ取ります。1つ目はdescriptionを、2つ目はブロックをそれぞれ1つずつ取ります。これで例の張りぼてクラスのインスタンスを作成してみましょう。

Describe.new NumberService do
  # 何かする
end

ここでの問題は、Describe.newではなくdescribeと書きたいことです。修正のためにヘルパーメソッドを1つ作らなければなりません。

def describe(context_name, &block)
  Describe.new(context_name, &block)
end

describe NumberService do
  # 何かチェックする
end

だいぶいい感じになってきました。先の例では、2つのdescribeブロックがネストしているので、ネストをサポートしなければなりません。

class Describe
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def describe(context_name, &block)
    Describe.new(context_name, &block)
  end
end

## ヘルパーメソッド

def describe(context_name, &block)
  Describe.new(context_name, &block)
end

## おれおれテスト

describe NumberService do
  describe '#number' do

  end
end

これでdescribeブロック内に別のdescribeブロックをネストできるようになりました。これらをrspec.rbファイルに保存してruby rspec.rbを実行できます。

今度は、実行パスを記述するitメソッドを実装します。

class Describe
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def describe(context_name, &block)
    Describe.new(context_name, &block)
  end

  def it(context_name, &block)

  end
end

def describe(context_name, &block)
  Describe.new(context_name, &block)
end

describe NumberService do
  describe '#number' do
    it 'returns 12' do

    end
  end
end

次は、指定の結果のexpectationを得るコードを追加します。そのためには、expectメソッド、toメソッド、eqメソッドの実装が必要です。これらについては、新しくExampleクラスを作ってそこに追加します。

class Example
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    self
  end

  def to(expectation)
    self
  end

  def eq(expectation)

  end
end

describe NumberService do
  describe '#number' do
    it 'returns 12' do
      expect(NumberService.new.number).to eq(12)
    end
  end
end

このコード例を実行しても何も起きません。チェイン内でメソッドを呼び出せるようになっただけで、まだ何も実装されていないからです。

expectメソッドは何をすべきなのでしょうか?あるメソッド呼び出しの結果を代入して、toメソッドで取り出せるようになればよいのです。

class Example
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    @result = result
    self
  end

  def to(expectation)
    self
  end

  def eq(expectation)

  end

  private
  attr_reader :result
end

describe NumberService do
  describe '#number' do
    it 'returns 12' do
      expect(NumberService.new.number).to eq(12)
    end
  end
end

このeqメソッドは単なる==の置き換えですが、ここでの課題は、これをtoメソッドに渡して結果と比較することです。その答えはProcです。

class Example
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    @result = result
    self
  end

  def to(expectation)
    self
  end

  def eq(expectation)
    Proc.new { |n| n.eql?(expectation) }
  end

  private
  attr_reader :result
end

describe NumberService do
  describe '#number' do
    it 'returns 12' do
      expect(NumberService.new.number).to eq(12)
    end
  end
end

締めくくりは、toメソッドの実装です。toは、結果をexpectationと比較します。

class Example
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    @result = result
    self
  end

  def to(expectation)
    expectation.call(result)
  end

  def eq(expectation)
    Proc.new { |n| n.eql?(expectation) }
  end

  private
  attr_reader :result
end

describe NumberService do
  describe '#number' do
    it 'returns 12' do
      expect(NumberService.new.number).to eq(12)
    end
  end
end

 puts expect(NumberService.new.number).to eq(12)を実行してみると、trueと出力されました。やりましたね!

テスト結果を出力する

ロジックは実装できましたが、まだコンソールにテスト結果が何も表示されません。まずはExampleクラスにpublicなreaderを追加してテスト結果をそこに保存し、Describeクラスからアクセスできるようにしましょう。

class Example
  attr_reader :context_name, :test_result

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    @result = result
    self
  end

  def to(expectation)
    @test_result = expectation.call(result)
  end

  def eq(expectation)
    Proc.new { |n| n.eql?(expectation) }
  end

  private
  attr_reader :result
end

describeブロックやitブロックがたくさん使われることはわかっているので、それらをDescribeクラスに集めてテスト出力を生成するときにアクセスできるようにします。

class Describe
  attr_reader :context_name, :examples

  def initialize(context_name, &block)
    @context_name = context_name
    @describes = []
    @examples = []
    instance_eval &block
  end

  def describe(context_name, &block)
    describes << Describe.new(context_name, &block)
  end

  def it(context_name, &block)
    examples << Example.new(context_name, &block)
  end

  private
  attr_accessor :describes
end

締めくくりは、出力用メソッドの実装です。メソッド名をtestにして、Describeクラスに追加します。

class NumberService
  def number
    12
  end
end

class Describe
  attr_reader :context_name, :examples

  def initialize(context_name, &block)
    @context_name = context_name
    @describes = []
    @examples = []
    instance_eval &block
  end

  def describe(context_name, &block)
    describes << Describe.new(context_name, &block)
  end

  def it(context_name, &block)
    examples << Example.new(context_name, &block)
  end

  def test
    puts context_name
    describes.each do |describe_node|
      puts "  " + describe_node.context_name
      describe_node.examples.each do |example_node|
        puts "    " + example_node.context_name
      end
    end
  end

  private
  attr_accessor :describes
end

def describe(context_name, &block)
  Describe.new(context_name, &block)
end

class Example
  attr_reader :context_name, :test_result

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    @result = result
    self
  end

  def to(expectation)
    @test_result = expectation.call(result)
  end

  def eq(expectation)
    Proc.new { |n| n.eql?(expectation) }
  end

  private
  attr_reader :result
end

rspec = describe NumberService do
  describe '#number' do
    it 'returns 12' do
      expect(NumberService.new.number).to eq(12)
    end
  end
end

rspec.test

ruby rspec.rbを実行すると、以下が出力されます。

NumberService
  #number
    returns 12

ところでspecがパスしたか失敗したかはどうすればわかるのでしょうか?失敗の場合は赤で、成功の場合は緑を表示しましょう。そのためにはcolorize gemを追加しなければなりません。

gem install colorize

colorize gemの#colorizeメソッドを用いて、テキストの色を指定できます。このメソッドは色のシンボルを引数に取ります。ここでは:red:greenを指定します。example_node.test_resultを呼び出せば使うべき色を検出できます。最終的なコードは以下のようになります。

require 'colorize'

class NumberService
  def number
    12
  end
end

class Describe
  attr_reader :context_name, :examples

  def initialize(context_name, &block)
    @context_name = context_name
    @describes = []
    @examples = []
    instance_eval &block
  end

  def describe(context_name, &block)
    describes << Describe.new(context_name, &block)
  end

  def it(context_name, &block)
    examples << Example.new(context_name, &block)
  end

  def test
    puts context_name
    describes.each do |describe_node|
      puts "  " + describe_node.context_name
      describe_node.examples.each do |example_node|
        color = example_node.test_result ? :green : :red
        puts "    " + example_node.context_name.colorize(color)
      end
    end
  end

  private
  attr_accessor :describes
end

def describe(context_name, &block)
  Describe.new(context_name, &block)
end

class Example
  attr_reader :context_name, :test_result

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    @result = result
    self
  end

  def to(expectation)
    @test_result = expectation.call(result)
  end

  def eq(expectation)
    Proc.new { |n| n.eql?(expectation) }
  end

  private
  attr_reader :result
end

rspec = describe NumberService do
  describe '#number' do
    it 'returns 12' do
      expect(NumberService.new.number).to eq(12)
    end
  end
end

rspec.test

さらに手を加えるには

非常にシンプルかつ限定的な実装ですが、皆さんがRSpecのしくみを知る手がかりになればと思います。クラスの宣言を別ファイルに切り出すなり、to_notメソッドをサポートするなり、好きに改造するとよいでしょう。

RSpecが既に存在しているおかげで、アプリをテストする独自RSpecを何もないところからこしらえなくて済むありがたいことですね。

お知らせ: RSpec & TDDの電子書籍を無料でダウンロード

もっと稼ぎたい方や会社をさらに発展させたい方へ: テスティングのスキルの重要性にお気づきでしょうか?テストを正しく書き始めることが、唯一のファーストステップです。無料でダウンロードいただける私の書籍『RSpec & Test Driven Developmentの無料ebook』をどうぞお役立てください。

関連記事

Ruby: 「マジック」と呼ぶのをやめよう(翻訳)

Ruby: メタプログラミングに役立つフック系メソッド(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ