やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

オレオレRspecを自作して覚えるRubyのブロック引数について

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

github.com

また、結果の確認には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を作成します。といってもcontextitをまとめているだけなので、ただ受け取ったブロック引数を実行するだけです。

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

今後、実装を簡単にするためにitcontextをモジュール化してモジュール関数にしておきます。

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と呼び出すためにはtoexpect(結果)のインスタンスメソッドである必要がありそうです。なので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のみです。eqto関数の引数として受け渡されます。
eqが確認したいのはexpect関数に渡された結果の値とeq関数に渡された値が一致しているかどうかです。

またto関数は様々なMatcherを受け取れるようにしておきたいので、to関数にもブロック引数を定義できないか考えてみます。ただ、本家Rspecの呼び出し方を見て分かるようにブロックの定義を行なっていません。

なのでブロックではなくProcオブジェクトを渡すようにします。Procはブロックを値として定義することができ、.callを呼び出すと実行されます。

p = Proc.new { |a| puts a }
p.call("hello") # hello

Proceq関数から返すようにします。受け取った期待値となる引数を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となる
});