Rubyのブロック引数についてご存じでしょうか。
非常に便利な機能で皆さんもよく使っている、正確にはブロック引数に対してブロックを渡していますが、ブロック引数を定義する機会がほとんどない方も多いでしょう。
例: eachの場合
# do以降の処理がブロック引数として受け渡される処理 list = [1, 2, 3, 4, 5] list.each do |e| puts e end # 値は1 # 値は2 : # 値は5
普段、Rubyを書いている方でも何となくでブロック引数を使っている方も多いかもしれませんが、ブロック引数を自在に使うことができるとRubyでの表現の幅がグッと広がります。
今回の題材
ブロック引数について紹介して、学ぶだけだと面白くないので、ブロック引数を使って面白いものが作れないかなと模索していたところ、Rubyを書いてる人は知らないであろうRspec
の存在を思い出しました。Rspec
では単体テストをグルーピング・階層化することでどこに何の単体テストがあるのかを明確にしてくれます。
# 仮のコードであり動作を確認していません RSpec.describe 'User' do context 'バリデーション' do it '電話番号が記録されていないとエラー' do expect(user.valid?).to eq(false) end it 'すでに登録済みでないメールアドレスの場合にエラーにならない' do expect(user.valud?).to eq(true) end end end
また、結果の確認にはexpect(結果).to eq(期待値
と記述します。eq
の部分はMatcher(マッチャー)と呼ばれるもので、eq
以外にも様々なものがあります。
今回は最低限の機能をもった簡単なオレオレRspecを作っていきます。仕様は以下になります。
- contextによってitをグループ化する
- itによって単体テストを定義する
- expect(結果).to eq(期待値)によって結果を確認する
それでは早速、作ってきます。
itの作成
まずは単体テストを定義するには不可欠なit
の作成から始めていきます。
本家Rspecを見て分かるようにit
は単体テストの名前とブロック引数の2つを受けっています。
it '電話番号が記録されていないとエラー' do # do something end
ではit
を関数として定義してみます。ブロック引数を受け取る場合は引数にアンド(&)
を付与します。注意点としては、ブロック引数は必ず関数の最後の引数とする必要があります。
def it(name, &block) : end
ブロック引数が最後の引数でないとsyntaxエラー
def it(&block, name) : end # syntax error, unexpected ',', expecting ')' # def it(&block, name)
処理の作成
次に定義したit
関数の処理を作っていきます。it
関数にさせたい処理はなんでしょうか。
単体テストの結果と期待値が一致しているのかを確認して、一致していれば「テストに成功!」という旨の出力をし、失敗していれば失敗の旨の出力を行います。
今回は、簡単のため成功した場合はpassed test: テスト名
を出力し、失敗した場合はfailed test: テスト名
を出力させます。
ということで、まずは単体テストの結果を受け取れるようにします。
単体テストの結果はブロック引数に受け渡されたブロック処理の結果から取得することができます。yield
を使うことでブロックの実行がされます。
def sample(&block) yield end sample do puts "hello" end # hello
またyeild
の実行結果は引数に束縛することができます。
def sample(&block) res = yield puts res end sample do 'hello' end # hello
ではyield
の結果を使って、出力を分岐する処理を追記します。
def it(name, &block) res = yield if res puts "passed test: #{name}" else raise "failed test: #{name}" end end
これでit
関数の作成が完了しました。
contextの作成
it
が作成できたので、次はcontext
を作成します。といってもcontext
はit
をまとめているだけなので、ただ受け取ったブロック引数を実行するだけです。
def context(name, &block) yield end
この時点でcontext
によってit
をグループ化することができるようになりました。
context 'context' do it 'it1' do true # passed test: it1 end it 'it2' do true # passed test: it2 end end
今後、実装を簡単にするためにit
とcontext
をモジュール化してモジュール関数にしておきます。
module MyRspec def context(name, &block) yield end def it(name, &block) res = yield if res puts "passed test: #{name}" else raise "failed test: #{name}" end end module_function :context, :it # モジュール関数として定義 end
expectの作成
- ⭕️ contextによってitをグループ化する
- ⭕️ itによって単体テストを定義する
- ❌ expect(結果).to eq(期待値)によって結果を確認する
すでに2つの仕様が満たされています。あとは結果の確認の処理のみです。
本家Rspecのexpect
の呼び出し方にならってexpect
に代入した結果に対して.to
を呼び出す必要があります。
expect(結果).to
と呼び出すためにはto
はexpect(結果)
のインスタンスメソッドである必要がありそうです。なのでexpect(結果
から何かしらのクラスを返して、そのクラスに実装されたto
関数を呼び出すようにしてみます。
class Result def initialize(result) @result = result end def to end end
そしてexpect
関数からResult
クラスのインスタンスを返すようにします。
module MyRspec : def expect(result) Result.new(result) end module_function :context, :it, :expect # expectを追加 end
これでexpect(結果).to
の呼び出しまでできるようになりました。
# MyRspecの記述をしなくて良いように追加 include MyRspec it 'sample' do res = 1 + 1 expect(res).to # `it': failed test: sample (RuntimeError) end
eq(Matcher)の作成
残るはeq
のみです。eq
はto
関数の引数として受け渡されます。
eq
が確認したいのはexpect
関数に渡された結果の値とeq
関数に渡された値が一致しているかどうかです。
またto
関数は様々なMatcherを受け取れるようにしておきたいので、to
関数にもブロック引数を定義できないか考えてみます。ただ、本家Rspecの呼び出し方を見て分かるようにブロックの定義を行なっていません。
なのでブロックではなくProc
オブジェクトを渡すようにします。Proc
はブロックを値として定義することができ、.call
を呼び出すと実行されます。
p = Proc.new { |a| puts a } p.call("hello") # hello
Proc
をeq
関数から返すようにします。受け取った期待値となる引数をProc
のスコープ内部に束縛させます。そうすることで、to
関数側で@result
の値を引数として代入してもらえば、結果と期待値が一致するかを確認することができます。
module MyRspec : def eq(expect) Proc.new { |result| expect == result } end module_function :context, :it, :expect, :eq # 追加 end
to
関数も変更します。
class Result : def to(matcher) matcher.call(@result) end end
これでexpect
関数に渡された結果とeq
関数に渡された期待値が一致するかを確認することができるようになりました。
include MyRspec it '1 + 2' do res = 1 + 2 expect(res).to eq(3) # passed test: 1 + 2 end
動作確認
これで全ての実装が完了しました。実際にオレオレRspecを使って、単体テストを書いて動作を確認してみましょう。
include MyRspec context 'add関数' do it '1 + 2の結果が3となる' do res = 1 + 2 expect(res).to eq(3) end it '1 + -2の結果が-1となる' do res = 1 + -2 expect(res).to eq(-1) end end # passed test: 1 + 2の結果が3となる # passed test: 1 + -2の結果が-1となる
やりました...!
ブロック引数とProc
を組み合わせることで最低限の機能を持ったオレオレRspecの実装に成功しました。
GitHub - okabe-yuya/my-rspec: minimum and simple test framework like Rspec
最後に
いかがでしたでしょうか。
今回はブロック引数を覚えながら、最低限の機能を持ったオレオレRspecの実装を行いました。
ブロック引数を使いこなすことで、Rspecのような世界中で使われているライブラリに似た処理が作れてしまいます。ブロックは非常にパワフルな機能なので、使いこなせるようになることでRubyでのコードの記述の幅がグッ!...っと広がります。
ぜひ、皆さんもブロック引数を使ってみてください。
少しでも「ええな〜」と思ったらイイネ!・シェア!・はてなブックマークを頂けると励みになります。
おまけ1: Matcher(include)の追加
MyRspec
モジュールに追加するだけです。include
は予約語のためinclude_
としました。
module MyRspec : def include_(expect) Proc.new { |result| result.include?(expect) } end module_function ... :include_ # 追加 end
include MyRspec it '[1,2,3]に1が含まれる' do expect([1,2,3]).to include_(1) # passed test: [1,2,3]に1が含まれる end
おまけ: 他のプログラミング言語にはブロック引数があるのか
他プログラミング言語ではブロック引数ではなく、関数を引数に渡すことがよくあります。
僕が最初にRubyを書き始めた時に「え?関数を引数に渡せないのか...」と戸惑ったものですが、ブロックという機能があることを知り関数を引数に渡すのと、同じようなことができると知りました。
const it = (name, unit_test) => { if (unit_test()) { console.log(`passed test: ${name}`); } else { throw new Error(`failed test: ${name}`); } }; it('1 + 2の結果が3となる', () => { const res = 1 + 2; return res == 3; # passed test: 1 + 2の結果が3となる });