概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Build your own RSpec - a gentle introduction to metaprogramming in Ruby
- 原文公開日: 2018/03/01
- 著者: Paweł Dąbrowsk
以下の記事を参考に、テスト対象コードとテスト用コードを1つのファイルにまとめて記述しておくと試しやすいでしょう。
やさしい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
について学んだので、このようなコードの書き方について薄々見当がついているかと思います。このテストで使われているすべてのメソッドをどう自作するかを考えることにしましょう。
describe
: クラスまたは文字列を1つの引数として受け取ります。どちらを渡しても出力は文字列になります。このあたりは、--format documentation
を追加してテストを実行してみればおわかりいただけると思います。it
: 指定された実行パスのdescriptionを受け取ります。describe
メソッドのブロック内に記述することで、テストの概要をドキュメントらしい形式で表示できます。expect
: 単に値を1つ取ります。私もRSpecのソースコードを詳しく見たわけではありませんので、実際の実装と比べたところで相当かけ離れているかと思います。to
とnot_to
は、それぞれ==
演算子と!=
演算子と同等です。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』をどうぞお役立てください。