LoginSignup
6
3

More than 1 year has passed since last update.

Ruby用テスティングフレームワーク「Oktest.rb」の紹介

Last updated at Posted at 2021-12-06

Ruby Advent Calendar 2021 その2 第7日目)

自作の「Oktest.rb」というテスティングフレームワークについて紹介します。Ruby >= 2.0 用です。

URL: https://github.com/kwatch/oktest/tree/ruby/ruby

Oktest.rbの特長

Oktest.rb」は、RSpecをより書きやすくしたようなテスティングフレームワークです。次のような特徴があります。

  • ok {1+1} == 2 というスタイルでアサーションが書ける。
  • DI (Dependency Injection) に似た「Fixture Injection」機能。
  • JSONデータの仕様が記述しやすい。
  • 便利なヘルパー関数が充実。
  • テストケースのフィルタリングがコマンドラインからできる。
  • 他と比べてコードサイズが小さく、動作も高速(RSpecの約5倍)。

Oktest.rbでのテストコードと、Test::Unitでのテストコードを比較してみます。Oktest.rbのアサーションが直感的であることがわかります。

### Oktest                           ### Test::Unit
require 'oktest'                     #  require 'test/unit'
                                     #
Oktest.scope do                      #
                                     #
  topic "Example" do                 #  class ExampleTest < Test::Unit::TestCase
                                     #
    spec "...description..." do      #    def test_1     # ...description...
      ok {1+1} == 2                  #      assert_equal 2, 1+1
      not_ok {1+1} == 3              #      assert_not_equal 3, 1+1
      ok {3*3} < 10                  #      assert 3*3 < 10
      not_ok {3*4} < 10              #      assert 3*4 >= 10
      ok {@var}.nil?                 #      assert_nil @var
      not_ok {123}.nil?              #      assert_not_nil 123
      ok {3.14}.in_delta?(3.1, 0.1)  #      assert_in_delta 3.1, 3.14, 0.1
      ok {'aaa'}.is_a?(String)       #      assert_kind_of String, 'aaa'
      ok {'123'} =~ (/\d+/)          #      assert_match /\d+/, '123'
      ok {:sym}.same?(:sym)          #      assert_same? :sym, :sym
      ok {'README.md'}.file_exist?   #      assert File.file?('README.md')
      ok {'/tmp'}.dir_exist?         #      assert File.directory?('/tmp')
      ok {'/blabla'}.not_exist?      #      assert !File.exist?('/blabla')
      pr = proc { .... }             #      exc = assert_raise(Error) { .... }
      ok {pr}.raise?(Error, "mesg")  #      assert_equal "mesg", exc.message
    end                              #    end
                                     #
  end                                #  end
                                     #
end                                  #

インストールとスケルトン作成

インストールは gem install oktest で行います。

$ gem install oktest
$ oktest --help

Oktest.rbにはスケルトン(サンプルコード)を作成する機能があるので、それを使ってみましょう。

$ mkdir test
$ oktest --skeleton > test/example_test.rb
$ less test/example_test.rb

実行は次のどれかで行います。このうち oktest コマンドだと様々なオプションが指定できます(後述)。

$ ruby test/example_test.rb    # ruby <ファイル名>
$ oktest test/example_test.rb  # oktest <ファイル名>
$ oktest test                  # oktest <ディレクトリ名>

実行すると、次のような表示結果になります。

$ oktest test   # または oktest -s verbose test
* Class
  * #method_name()
    - [pass] 1+1 should be 2.
    - [pass] fixture injection examle.
## total:2 (pass:2, fail:0, error:0, skip:0, todo:0) in 0.001s

Oktest.rbでの表示結果には色がつきます。他のテスティングライブラリでは、正常なら「緑」、失敗やエラーなら「赤」を使いますが、一部の人には緑と赤の区別がつきにくいことが知られています。そのため、Oktest.rbでは正常時の色として「シアン」を使っています(以前は「青」を使っていましたが、ターミナルが黒背景だと青は見にくいので、背景が白でも黒でも見やすいシアンに変更しました)。

チュートリアル

基本的な書き方

Oktest.rbでのテストスクリプトの書き方は、次のようになります。

test/example01_test.rb
# coding: utf-8

require 'oktest'

class Hello
  def hello(name="world")
    return "Hello, #{name}!"
  end
end


Oktest.scope do

  topic Hello do

    topic '#hello()' do

      spec "returns greeting message." do
        actual = Hello.new.hello()
        ok {actual} == "Hello, world!"
      end

      spec "accepts user name." do
        actual = Hello.new.hello("RWBY")
        ok {actual} == "Hello, RWBY!"
      end

    end

  end

end

これを見ると、RSpecのような構造化された記述をしつつ、RSpecより直感的にアサーションが書けることが分かります。

  • テストケースの構造を topic()spec() で記述し、全体を Oktest.scope() で囲む。
  • アサーションは ok {actual} == expected のように記述する。

全体を `Oktest.scope()` で囲むのは、トップレベルに `topic()` や `spec()` を定義するのを避けるためです。RSpecではトップレベルに `describe()` などのメソッドを定義するので、RSpecを使った場合とそうでない場合でプログラムの挙動が変わる可能性があります。テスティングフレームワークはこのような「汚染」をすべきではない(あるいは最小限に抑えるべき)という思想のもとに、Oktest.rbは設計されています。

アサーションが `ok(1+1) == 2` ではなく `ok {1+1} == 2` という書き方になっているのは、`ok` の直後に空白があったほうがアサーションが読みやすいからです。Ruby では `ok (1+1) == 2` と書くと `ok((1+1) == 2)` とみなされてしまうので、この書き方は採用しませんでした(`(ok (1+1)) == 2` とみなされないと実装上困るのです)。また `ok_(1+1) == 2` という書き方も検討しましたが、やはり `ok` の直後は半角空白が望ましいと思い、最終的に `ok {1+1} == 2` という書き方を採用しました。これだと `ok` の直後に半角空白があっても `(ok {1+1}) == 2` と解釈されるので、見た目も実装上も都合がよいです。

このテストスクリプトを実行してみましょう。

$ oktest test/example01_test.rb   # または ruby test/example01_test.rb
## test/example01_test.rb
* Hello
  * #hello()
    - [pass] returns greeting message.
    - [pass] accepts user name.
## total:2 (pass:2, fail:0, error:0, skip:0, todo:0) in 0.000s

なお topic() の引数には任意の値を指定でき、たとえば topic Hello do のようにクラスオブジェクトを指定できます。これに対して、spec() の引数には文字列を指定します。

また RSpec で仕様を記述するには it で始まる文章にする必要がありますが、Oktest.rb では it() ではなく spec() を使うのでそのような制約がなく、自然な文章で仕様を記述できます。

アサーションの失敗、およびエラー

アサーションが失敗したり、何らかのエラー(例外)が発生したときの表示を見てみましょう。

test/example02_test.rb
require 'oktest'

Oktest.scope do

  topic 'other examples' do

    spec "example of assertion failure" do
      ok {1+1} == 2     # pass
      ok {1+1} == 0     # FAIL
    end

    spec "example of something error" do
      x = foobar        # NameError
    end

  end

end

実行結果:

$ oktest test/example02_test.rb   # or: ruby test/example02_test.rb
## test/example02_test.rb
* other examples
  - [Fail] example of assertion failure
  - [ERROR] example of something error
----------------------------------------------------------------------
[Fail] other examples > example of assertion failure
    test/example02_test.rb:9:in `block (3 levels) in <main>'
        ok {1+1} == 0     # FAIL
$<actual> == $<expected>: failed.
    $<actual>:   2
    $<expected>: 0
----------------------------------------------------------------------
[ERROR] other examples > example of something error
    test/example02_test.rb:13:in `block (3 levels) in <main>'
        x = foobar        # NameError
NameError: undefined local variable or method `foobar' for #<#<Class:...>:...>
----------------------------------------------------------------------
## total:2 (pass:0, fail:1, error:1, skip:0, todo:0) in 0.000s

ここでは色がついていませんが、実際には失敗とエラーは赤色で表示されます。

アサーションが未実行のときは警告

失敗やエラーとは違いますが、ok{} が呼び出されたけどアサーションが何も実行されなかった場合は、警告メッセージが出ます。

たとえば ok {1+1} == 2 と書くつもりが間違って ok {1+1} としか書かれなかった場合、アサーションが何も実行されないので、「warning: ok() is called but not tested yet」という警告メッセージが出ます。

あるいは ok {1+1} == var と書くつもりが変数名を間違えて ok {1+1} == vaa と書いてしまったとしましょう。この場合、vaa の時点で NameError が発生します。すると ok {1+1} は呼ばれたもののアサーションである == が実行されないため、やはり「warning: ok() is called but not tested yet」という警告メッセージが(NameError とは別に)出ます。

この警告メッセージが出たら、アサーションが意図せず未実行になっているので、たとえ失敗やエラーになっていなくてもテストコードを修正してください。

テストケースのスキップとToDo

何らかの条件によってテストケースをスキップするには、skip_when <条件>, <理由> を使います。

またプログラムは未実装だけどテストコードを先に用意した場合は、TODO() を使います。テストコードをまだ書いてないけどテストすべき内容を書き留めておきたいときは、spec() のブロック引数を未指定にします。

test/example03_test.rb
require 'oktest'

Oktest.scope do

  topic 'other examples' do

    spec "example of skip" do
      # Rubyのバージョンが3.0未満ならスキップする
      skip_when RUBY_VERSION < "3.0", "requires Ruby3"
      ok {1+1} == 2
    end

    spec "example of todo"    # ブロック引数のない spec() は TODO 相当

    spec "example of todo (when passed unexpectedly)" do
      TODO()                  # プログラムが未実装なので、
                              # このテストケースは失敗になるべき。
      ok {1+1} == 2           # もし失敗しなければ、それは
                              # 「意図しない成功」という失敗になる。
    end

  end

end

実行結果:

$ oktest test/example03_test.rb   # or: ruby test/example03_test.rb
## oktest test/example03_test.rb
* other examples
  - [Skip] example of skip (reason: requires Ruby3)
  - [TODO] example of todo
  - [Fail] example of todo (when passed unexpectedly)
----------------------------------------------------------------------
[Fail] other examples > example of todo (when passed unexpectedly)
    test/example03_test.rb:14:in `block (2 levels) in <top (required)>'
        spec "example of todo (when passed unexpectedly)" do
spec should be failed (because not implemented yet), but passed unexpectedly.
----------------------------------------------------------------------
## total:2 (pass:0, fail:1, error:0, skip:1, todo:1) in 0.000s

実行結果の表示スタイル

Oktest.rb では、-s <STYLE> オプションで表示スタイルを変更できます。オプションの指定は、ファイル名の前と後のどちらでも構いません(つまり oktest -s <STYLE> <file> でも oktest <file> -s <STYLE> のどちらでもよい)。

Verboseモード(デフォルト):

$ oktest test/example01_test.rb -s verbose  # or -sv
## test/example01_test.rb
* Hello
  * #hello()
    - [pass] returns greeting message.
    - [pass] accepts user name.
## total:2 (pass:2, fail:0, error:0, skip:0, todo:0) in 0.000s

Simpleモード:

$ oktest test/example01_test.rb -s simple   # or -ss
## test/example01_test.rb
* Hello: 
  * #hello(): ..
## total:2 (pass:2, fail:0, error:0, skip:0, todo:0) in 0.000s

Compactモード:

$ oktest test/example01_test.rb -s compact  # or -sc
test/example01_test.rb: ..
## total:2 (pass:2, fail:0, error:0, skip:0, todo:0) in 0.000s

Plainモード:

$ oktest test/example01_test.rb -s plain    # or -sp
..
## total:2 (pass:2, fail:0, error:0, skip:0, todo:0) in 0.000s

Quietモード:

$ oktest test/example01_test.rb -s quiet    # or -sq

## total:2 (pass:2, fail:0, error:0, skip:0, todo:0) in 0.000s

-s オプションは、先頭の1文字だけでも指定できます。たとえば -s plain のかわりに -sp と指定できます。

デフォルトでは -s verbose スタイルで表示されます。デフォルトを変更したいなら、環境変数 $OKTEST_RB にコマンドオプションを設定してください。

$ export OKTEST_RB="-s plain"

ディレクトリを指定して実行する

oktest コマンドの引数に、ファイル名ではなくディレクトリ名を指定すると、そのディレクトリ以下のテストスクリプトがすべて実行されます。

たとえば、test ディレクトリに3つのテストスクリプトがあるとします。

$ ls test/
example01_test.rb       example02_test.rb       example03_test.rb

このようなとき、oktest test を実行すると3つのテストスクリプトがすべて読み込まれて実行されます。

$ oktest -s compact test  # or: ruby -r oktest -e 'Oktest.main' -- test -s compact
test/example01_test.rb: ..
test/example02_test.rb: fE
----------------------------------------------------------------------
[Fail] other examples > example of assertion failure
    test/example02_test.rb:9:in `block (3 levels) in <top (required)>'
        ok {1+1} == 0     # FAIL
    -e:1:in `<main>'
$<actual> == $<expected>: failed.
    $<actual>:   2
    $<expected>: 0
----------------------------------------------------------------------
[ERROR] other examples > example of something error
    test/example02_test.rb:13:in `block (3 levels) in <top (required)>'
        x = foobar        # NameError
    -e:1:in `<main>'
NameError: undefined local variable or method `foobar' for #<#<Class:...>:...>
----------------------------------------------------------------------
test/example03_test.rb: st
## total:6 (pass:2, fail:1, error:1, skip:1, todo:1) in 0.000s

テストスクリプトのファイル名は、`test_xxx.rb` または `xxx_test.rb` である必要があります。`test-xxx.rb` や `xxx-test.rb` ではないので注意してください。

`oktest` コマンドを使わず、`ruby -r oktest -e 'Oktest.main' -- test -s compact` のように実行することもできます。これはRubyコマンドのパスを指定して実行したい(たとえば `/usr/bin/ruby` ではなく `/opt/local/bin/ruby` を使いたい)場合に便利です。

タグとフィルタリング

scope()topic()spec() には、キーワード引数を使ってタグを指定できます。

test/example04_test.rb
require 'oktest'

Oktest.scope do

  topic 'Example topic' do

    topic Integer do
      spec "example #1" do
        ok {1+1} == 2
      end
      spec "example #2", tag: 'old' do     # タグ名: 'old'
        ok {1-1} == 0
      end
    end

    topic Float, tag: 'exp' do             # タグ名: 'exp'
      spec "example #3" do
        ok {1.0+1.0} == 2.0
      end
      spec "example #4" do
        ok {1.0-1.0} == 0.0
      end
    end

    topic String, tag: ['exp', 'old'] do   # タグ名: 'old' と 'exp'
      spec "example #5" do
        ok {'a'*3} == 'aaa'
      end
    end

  end

end

タグ名を使って、テストケースをフィルタリングできます。つまり、特定のテストケースだけを実行できます。フィルタリングには、*?[]{} のようなメタキャラクタが使えます。

$ oktest -F tag=exp         test/      # タグ名でフィルタ
$ oktest -F tag='*exp*'     test/      # タグ名のパターンでフィルタ
$ oktest -F tag='{exp,old}' test/      # 複数のタグ名でフィルタ

タグ名ではなく、トピック名やスペック名でもフィルタできます。

$ oktest -F topic='*Integer*' test/    # トピック名のパターンでフィルタ
$ oktest -F spec='*#[1-3]'    test/    # スペック名のパターンでフィルタ

特定のテストケースだけを除外して実行したい場合は、= のかわりに != を使って指定します。

$ oktest -F spec!='*#5'      test/     # スペック 'example #5' を除外
$ oktest -F tag!='{exp,old}' test/     # タグ名 'exp' と 'old' を除外

case_whencase_else

case_when()case_else() は、仕様の条件を表現します。skip_when() とは違い、条件に合った場合だけ実行する/スキップするというものではないので、間違えないようにしてください(実際のところ、case_when()case_else() は内部的には topic() の一種なのです)。

test/example05_test.rb
require 'oktest'

Oktest.scope do
  topic Integer do
    topic '#abs()' do

      case_when "value is negative..." do
        spec "converts value into positive." do
          ok {-123.abs()} == 123
        end
      end

      case_when "value is zero..." do
        spec "returns zero." do
          ok {0.abs()} == 0
        end
      end

      case_else do
        spec "returns itself." do
          ok {123.abs()} == 123
        end
      end

    end
  end
end

実行結果:

$ ruby test/example05_test.rb
## test/example05_test.rb
* Integer
  * #abs()
    - When value is negative...
      - [pass] converts value into positive.
    - When value is zero...
      - [pass] returns zero.
    - Else
      - [pass] returns itself.
## total:3 (pass:3, fail:0, error:0, skip:0, todo:0) in 0.001s

単項演算子

topic() には単項演算子の + がつけられます。また spec() には単項演算子の - がつけられます。これらをつけると、テストケースの構造が視覚的にわかりやすくなります。

require 'oktest'

Oktest.scope do

+ topic('example') do            # 単項演算子の `+`

  + topic('example') do          # 単項演算子の `+`

    - spec("1+1 is 2.") do       # 単項演算子の `-`
        ok {1+1} == 2
      end

    - spec("1*1 is 1.") do       # 単項演算子の `-`
        ok {1*1} == 1
      end

    end

  end

end

単項演算子をつけるときは、+ topic '...' do- spec "..." do のように書くとRubyの文法エラーになります。かわりに、+ topic('...') do- spec("...") do のように書いてください。

単項演算子をつけると、エディタやIDEでの自動インデント機能が意図したようには機能しなくなります。「テストケースの構造が視覚的にわかりやすくなる」というメリットはあるものの、デメリットも大きいので、無理して採用する必要はありません。

テストコードの自動生成

Oktest.rb では、プログラムを読み込んでテストコードのひな形を自動生成する機能があります。クラスやメソッドが topic() の引数になり、プログラム中の #; から始まる行コメントが spec() の引数になります。

たとえば次のようなプログラムがあるとします。

hello.rb
class Hello

  def hello(name=nil)
    #; default name is 'world'.
    if name.nil?
      name = "world"
    end
    #; returns greeting message.
    return "Hello, #{name}!"
  end

end

このプログラムからテストコードのひな形を生成するには、次のようにします。

$ oktest --generate hello.rb > test/hello_test.rb

生成されたテストコードは次のようになります。

test/hello_test.rb
# coding: utf-8

require 'oktest'

Oktest.scope do


  topic Hello do


    topic '#hello()' do

      spec "default name is 'world'."

      spec "returns greeting message."

    end  # #hello()


  end  # Hello


end

またコマンドラインオプションを --generate=unaryop にすると、topic()spec() に単項演算子がつきます。

$ oktest --generate=unaryop hello.rb > test/hello2_test.rb
test/hello2_test.rb
# coding: utf-8

require 'oktest'

Oktest.scope do


+ topic(Hello) do


  + topic('#hello()') do

    - spec("default name is 'world'.")

    - spec("returns greeting message.")

    end  # #hello()


  end  # Hello


end

トピック内でのメソッド定義

topic() のブロック内でメソッドを定義すると、それを spec() の中から呼び出せます。

require 'oktest'
Oktest.scope do

  topic "Method example" do

    def hello()                 # topic() のブロック内でメソッドを定義
      return "Hello!"
    end

    spec "example" do
      s = hello()               # spec() のブロック内でそれを呼び出す
      ok {s} == "Hello!"
    end

  end

end

親トピック内で定義されたメソッドも呼び出せます。子トピック内で定義されたメソッドは呼び出せません。

require 'oktest'
Oktest.scope do

+ topic('Outer') do

  + topic('Middle') do

      def hello()              # topic() ブロック内でメソッドを定義
        return "Hello!"
      end

    + topic('Inner') do

      - spec("inner spec") do
          s = hello()          # 親トピックで定義されたメソッドを呼び出すのはOK
          ok {s} == "Hello!"
        end

      end

    end

  - spec("outer spec") do
      s = hello()              # 子トピックで定義されたメソッドを呼び出すのはエラー
      ok {x} == "Hello!"
    end

  end

end

アサーション

RSpec ではアサーションの書き方が複雑なので、実のところ「この書き方でいいのかな?」と疑念を抱きながら書いてる人も多いのではないでしょうか。Oktset.rb ではアサーションがとても直感的なので、自信を持ってアサーションが書けます。

アサーションの書き方

Oktest.rb では、アサーションを以下のように書きます。ここで a は actual を表し、e は expected を表すものとします。

ok {a} == e              # a == e なら成功
ok {a} != e              # a != e なら成功
ok {a} === e             # a === e なら成功
ok {a} !== e             # a !== e なら成功

ok {a} >  e              # a > e なら成功
ok {a} >= e              # a >= e なら成功
ok {a} <  e              # a < e なら成功
ok {a} <= e              # a <= e なら成功

ok {a} =~ e              # a =~ e なら成功
ok {a} !~ e              # a !~ e なら成功

ok {a}.same?(e)          # a.equal?(e) なら成功
ok {a}.in?(e)            # e.include?(a) なら成功
ok {a}.in_delta?(e, x)   # e-x < a < e+x なら成功
ok {a}.truthy?           # !!a == true なら成功
ok {a}.falsy?            # !!a == false なら成功

ok {a}.file_exist?       # File.file?(a) なら成功
ok {a}.dir_exist?        # File.directory?(a) なら成功
ok {a}.symlink_exist?    # File.symlink?(a) なら成功
ok {a}.not_exist?        # ! File.exist?(a) なら成功

ok {a}.attr(name, e)     # a.__send__(name) == e なら成功
ok {a}.keyval(key, e)    # a[key] == e なら成功
ok {a}.item(key, e)      # ok {a}.keyval(key, e) と同じ
ok {a}.length(e)         # a.length == e なら成功

.attr().keyval() は、メソッドを連続して呼び出せます。

ok {a}.attr(:name1, 'val1').attr(:name2, 'val2').attr(:name3, 'val3')
ok {a}.keyval(:key1, 'val1').keyval(:key2, 'val2').keyval(:key3, 'val3')

述語アサーション

述語メソッド(メソッド名が '?' で終わり、かつtrueまたはfalseを返すようなメソッド)は、自動的にアサーションとして利用できます。

ok {a}.nil?              # ok {a.nil?} == true に相当
ok {a}.empty?            # ok {a.empty?} == true に相当
ok {a}.key?(e)           # ok {a.key?(e)} == true に相当
ok {a}.is_a?(e)          # ok {a.is_a?(e)} == true に相当
ok {a}.include?(e)       # ok {a.include?(e)} == true に相当
ok {a}.between?(x, y)    # ok {a.between?(x, y)} == true に相当

たとえば、ok {a}.empty?ok {a.empty?} == true に相当します。しかし a.empty? が false だったとき、前者では a の値がエラーメッセージの中に表示されるのに対し、後者では a の値が表示されません。アサーション失敗時のために、後者ではなく前者の書き方をしてください。

Ruby の `Object#equal?()` はどのクラスでも上書きすべきではないため、`ok {actual}.equal?(expected)` と書いても述語アサーションとして機能しません。かわりに `ok {actual}.same?(expected)` を使ってください。

述語アサーションの活用例として、Pathname() を挙げておきます。Pathname() オブジェクトには述語メソッドがたくさん定義されているので、それらをアサーションとして利用できます。Pathname() についてはpathname.rbのマニュアルを参照してください。

require 'pathname'      # !!!!!

ok {Pathname(a)}.owned?      # ok {Pathname(a).owned?} == true に相当
ok {Pathname(a)}.readable?   # ok {Pathname(a).readable?} == true に相当
ok {Pathname(a)}.writable?   # ok {Pathname(a).writable?} == true に相当
ok {Pathname(a)}.absolute?   # ok {Pathname(a).absolute?} == true に相当
ok {Pathname(a)}.relative?   # ok {Pathname(a).relative?} == true に相当

後述するように、Oktest.rbではカスタムアサーションを定義できます。しかしそれだとOktest.rbに強く依存してしまいます。それよりも Pathname() のような述語メソッドを備えたラッパーオブジェクトを使うことを検討してください。

否定アサーション

not_ok{} または ok{}.NOT を使うと、アサーションの意味を反転できます。

not_ok {a} == e          # a == e なら失敗
ok {a}.NOT == e          # a == e なら失敗

not_ok {a}.file_exist?   # File.file?(a) なら失敗
ok {a}.NOT.file_exist?   # File.file?(a) なら失敗

実際には、ok {a}.NOT == e ではなく ok {a} != e と書くべきだし、ok {a}.NOT.file_exist?ok {a}.not_exist? とすべきです。後述する ok {}.NOT.raise? を除いて、否定アサーションはなるべく使わないほうがいいでしょう。

例外のアサーション

例外が発生するかどうかのアサーションは、次のようにします。

## 基本的な書き方
pr = proc do
  "abc".len()        # NoMethodError が発生
end
ok {pr}.raise?(NoMethodError)
ok {pr}.raise?(NoMethodError, "undefined method `len' for \"abc\":String")
ok {pr}.raise?(NoMethodError, /^undefined method `len'/)
ok {pr}.raise?       # NoMethodError例外が発生すれば成功、何も発生しなければ失敗

## 例外オブジェクトを使いたいとき
ok {pr}.raise?(NoMethodError) {|exc|
  ok {exc.class}   == NoMethodError
  ok {exc.message} == "undefined method `len' for \"abc\":String"
}

raise ではなく throw されたかを調べるには、ok{}.throw? を使います。

## Symbolをthrowする場合の書き方
pr2 = proc do
  throw :quit        # :quit を throw する(raise ではないことに注意)
end
ok {pr2}.throw?(:quit)  # :quit が throw されたら成功、それ以外は失敗

例外が何も発生しないことを確かめたいときは、否定アサーションを使います。このとき、例外クラスとエラーメッセージは指定しません。

ok {pr}.NOT.raise?   # 例外クラスとエラーメッセージは何も指定しないこと
not_ok {pr}.raise?   # 上と同じ

もしテスト対象のプロラムが例外クラスを指定せずに raise している場合は、アサーションでも例外クラスを省略してエラーメッセージだけを指定できます。

pr = proc do
  raise "something wrong"           # 例外クラスを省略してraiseしてるなら、
end
ok {pr}.raise?("something wrong")   # アサーションでも例外クラスを省略できる

実は、ok{}.raise?() では例外クラスの比較を == 演算子で行っており、.is_a?() メソッドは使っていません。これは次のようなサンプルコードで確かめられます。

## 前提:ZeroDivisionError は StandardError を継承しており、
##       StandardError は Exception を継承している

pr = proc { 1/0 }     # ZeroDivisionError が発生

ok {pr}.raise?(ZeroDivisoinError)   # 成功(exc.class == ZeroDivisionError だから)
ok {pr}.raise?(StandardError)       # エラー(exc.class != StandardError だから)
ok {pr}.raise?(Exception)           # エラー(exc.class != Exception だから)

奇妙に感じるかもしれませんが、これは「アサーションの意図しない成功」を防ぐための仕様です。たとえば MiniTest では assert_raises(NameError) { .... } というアサーションが、NameError だけでなく NoMethodError が発生したときでも成功となってしまいます。なぜなら、Ruby では NoMethodError クラスが NameError クラスを継承しているからです。

require 'minitest/spec'
require 'minitest/autorun'

describe "assert_raise()" do
  it "results in success unexpectedly" do
    ## NameError と指定しているのに NoMethodError もキャッチしてしまう。
    ## なぜなら NoMethodError は NameError のサブクラスだから。
    assert_raises(NameError) do
      "str".foobar()              # この行はNoMethodErrorを発生する。
    end
  end
end

Oktest.rb ではこのような「アサーションの意図しない成功」を防げます。なぜなら、ok{}.raise?() では例外クラスの比較を == で行い、.is_a?() を使わないからです。

require 'oktest'

Oktest.scope do
  topic 'ok().raise?' do
    spec "doesn't catch subclasses." do
      pr = proc do
        "str".foobar()      # NoMethodError が発生
      end
      ok {pr}.raise?(NoMethodError)   # 成功
      ok {pr}.raise?(NameError)       # エラー:NoMethodError が発生
    end
  end
end

もし子クラスの例外も対象としたい場合は、ok{}.raise? のかわりに ok{}.raise! を使ってください。上のサンプルコードを、.raise? から .raise! に変更すると次のようになります。

require 'oktest'

Oktest.scope do
  topic 'ok().raise!' do
    spec "catches subclasses." do
      pr = proc do
        "str".foobar()      # NoMethodError が発生
      end
      ok {pr}.raise!(NoMethodError)   # 成功
      ok {pr}.raise!(NameError)       # これも成功!!!!!
    end
  end
end

カスタムアサーション

独自のアサーションメソッドを追加するには、次のようにします。

require 'oktest'

## 独自のアサーションメソッドを追加する
Oktest::AssertionObject.class_eval do
  def readable?     # custom assertion: file readable?
    _done()
    result = File.readable?(@actual)
    __assert(result == @bool) {
      "File.readable?($<actual>) == #{@bool}: failed.\n" +
      "    $<actual>:   #{@actual.inspect}"
    }
    self
  end
end

## 使い方
Oktest.scope do

  topic "Custom assertion" do

    spec "example spec" do
      ok {__FILE__}.readable?     # 独自のアサーションメソッド
    end

  end

end

カスタムアサーションを使うよりも、述語メソッドを備えたラッパーオブジェクト(例:`Pathname()`)を使うことを検討してください。作り方は次を参考にしてください。

class AssertDate

  def initialize(actual)
    @actual = actual
  end

  def inspect()       # エラーメッセージの表示に必要
    return @actual.inspect()
  end

  def year?(expected) ; @actual.year  == expected; end
  def month?(expected); @actual.month == expected; end
  def day?(expected)  ; @actual.day   == expected; end

  def end_of_month?
    nextday = @actual + 1
    return nextday.day == 1
  end

end
def AssertDate(actual)          # 好みでどうぞ
  return AssertDate.new(actual)
end

## 使い方
actual = Date.new(2021, 12, 31)
ok {AssertDate.new(actual)}.year?(2021)
ok {AssertDate.new(actual)}.end_of_month?

フィクスチャ

テストケースの前処理と後処理

テストケースの前処理と後処理には、次のメソッドを使います。

  • before do ... end ‥‥ spec() ごとの前処理
  • after do ... end ‥‥ spec() ごとの後処理
  • before_all do ... end ‥‥ topic() ごとの後処理
  • after_all do ... end ‥‥ topic() ごとの後処理
test/example21a_test.rb
require 'oktest'

Oktest.scope do

  topic "Fixture example" do

    before do       # spec() ごとの前処理
      puts "=== before() ==="
    end

    after do        # spec() ごとの後処理
      puts "=== after() ==="
    end

    before_all do   # topic() ごとの前処理
      puts "*** before_all() ***"
    end

    after_all do    # topic() ごとの後処理
      puts "*** after_all() ***"
    end

    spec "example spec #1" do
      puts "---- example spec #1 ----"
    end

    spec "example spec #2" do
      puts "---- example spec #2 ----"
    end

  end

end

実行結果:

$ oktest -s quiet test/example21a_test.rb
*** before_all() ***             ← before_all()を実行
=== before() ===                 ← before()を実行
---- example spec #1 ----
=== after() ===                  ← after()を実行
=== before() ===                 ← before()を実行
---- example spec #2 ----
=== after() ===                  ← after()を実行
*** after_all() ***              ← after_all()を実行

## total:2 (pass:2, fail:0, error:0, skip:0, todo:0) in 0.000s

before()/after()/before_all()/after_all() は、topic() または scope() ごとに定義できます。

  • before() ブロックは、内側の topic/scope で定義されたものよりも、外側の topic/scope で定義されたもののほうが先に呼び出される。
  • after() ブロックは、外側の topic/scope で定義されたものよりも、内側の topic/scope で定義されたもののほうが先に呼び出される。
test/example21b_test.rb
require 'oktest'

Oktest.scope do

  topic 'Outer' do
    before { puts "=== Outer: before ===" }         # !!!!!
    after  { puts "=== Outer: after ===" }          # !!!!!

    topic 'Middle' do
      before { puts "==== Middle: before ====" }    # !!!!!
      after  { puts "==== Middle: after ====" }     # !!!!!

      topic 'Inner' do
        before { puts "===== Inner: before =====" } # !!!!!
        after  { puts "===== Inner: after =====" }  # !!!!!

        spec "example" do
          ok {1+1} == 2
        end

      end

    end

  end

end

実行結果:

$ oktest -s quiet test/example21b_test.rb
=== Outer: before ===
==== Middle: before ====
===== Inner: before =====
===== Inner: after =====
==== Middle: after ====
=== Outer: after ===

## total:1 (pass:1, fail:0, error:0, skip:0, todo:0) in 0.000s

もし before/after/before_all/after_all の各ブロックの中でエラーが発生したら、テストスクリプトの実行は即時に中止されます。

クリーンアップハンドラ at_end()

at_end() を使うと、テストケース実行後に行う後始末処理を登録できます。after() と違い、テストケースごとに異なる後始末処理を at_end() では登録できます。

test/example22_test.rb
require 'oktest'

Oktest.scope do

  topic "Auto clean-up" do

    spec "example spec" do
      tmpfile = "tmp123.txt"
      File.write(tmpfile, "foobar\n")
      at_end do                 # 後始末処理を登録してから
        File.unlink(tmpfile)
      end
      #
      ok {tmpfile}.file_exist?  # アサーションを実行する
    end

  end

end
  • 1つのテストケースの中で at_end() を複数回呼び出せます。
  • at_end() で登録された処理がテストケースの終わりに実行されます。このとき、登録されたのとは逆の順番で処理が実行されます。
  • at_end() で登録された処理が実行されてから、after() のブロックが実行されます。
  • at_end() で登録された処理でエラーが発生すると、テストスクリプトの実行が中断します。

名前つきフィクスチャ

topic() または scope() のブロックの中において、fixture() { ... } はフィクスチャを定義します(つまりフィクスチャを生成するブロックを登録します)。
また spec() のブロックの中では、fixture() はフィクスチャを生成します。

test/example23_test.rb
require 'oktest'

Oktest.scope do

  fixture :alice do               # フィクスチャを定義
    {name: "Alice"}
  end

  fixture :bob do                 # フィクスチャを定義
    {name: "Bob"}
  end

  topic "Named fixture" do

    spec "example spec" do
      alice = fixture(:alice)     # フィクスチャを生成
      bob   = fixture(:bob)       # フィクスチャを生成
      ok {alice[:name]} == "Alice"
      ok {bob[:name]}   == "Bob"
    end

  end

end

フィクスチャ定義では、ブロック引数を指定できます。

test/example24_test.rb
require 'oktest'

Oktest.scope do

  fixture :team do |mem1, mem2|   # 引数つきフィクスチャ定義
    {members: [mem1, mem2]}
  end

  topic "Named fixture with args" do

    spec "example spec" do
      alice = {name: "Alice"}
      bob   = {name: "Bob"}
      team = fixture(:team, alice, bob)  # 引数を指定してフィクスチャを生成
      ok {team[:members][0][:name]} == "Alice"
      ok {team[:members][1][:name]} == "Bob"
    end

  end

end
  • フィクスチャ定義は、topic() または Oktest.scope() の中でのみ行なえます。
  • フィクスチャの後始末が必要なら、フィクスチャ定義のブロックの中で at_end() を使ってください。
  ## フィクスチャを定義する
  fixture :tmpfile do
    tmpfile = "tmp#{rand().to_s[2..5]}.txt"
    File.write(tmpfile, "foobar\n", encoding: 'utf-8')
    ## 後始末が必要なので at_end を使う
    at_end { File.unlink(tmpfile) if File.exist?(tmpfile) }   # !!!!!
    tmpfile
  end

フィクスチャ・インジェクション (Fixture Injection)

spec()fixture() のブロック引数は、フィクスチャ名を表します。Oktest.rb は、フィクスチャ名をもとにフィクスチャを生成し、自動的に spec()fixture() のブロック引数に指定します。これはいわゆる DI (Dependency Injection) と同じ仕組みです。

test/example25_test.rb
require 'oktest'

Oktest.scope do

  fixture :alice do               # フィクスチャを定義
    {name: "Alice"}
  end

  fixture :bob do                 # フィクスチャを定義
    {name: "Bob"}
  end

  ## fixture() のブロック引数に、フィクスチャが自動的に生成されて渡される
  fixture :team do |alice, bob|   # !!! フィクスチャ・インジェクション !!!
    {members: [alice, bob]}
  end

  topic "Fixture injection" do

    ## spec() のブロック引数に、フィクスチャが自動的に生成されて渡される
    spec "example spec" do
      |alice, bob, team|          # !!! フィクスチャ・インジェクション !!!
      ok {alice[:name]} == "Alice"
      ok {bob[:name]}   == "Bob"
      #
      ok {team[:members]}.length(2)
      ok {team[:members][0]} == {name: "Alice"}
      ok {team[:members][1]} == {name: "Bob"}
    end

  end

end

実験的な機能として、フィクスチャ名に `this_topic` を指定すると `topic()` の第1引数の値がインジェクトされ、また `this_spec` を指定すると `spec()` の第1引数の値がインジェクトされます。これは実験的機能なので、将来変更・廃止される可能性があります。

フィクスチャ定義のブロックに引数があると、その引数名に対応した他のフィクスチャがインジェクトされます。これによりフィクスチャの依存関係を指定できます。

test/example26_test.rb
require 'oktest'

Oktest.scope do

  ## 引数をとるフィクスチャ
  fixture :team do |alice, bob|   # alice と bob に依存する
    {members: [alice, bob]}
  end

  fixture :alice do {name: "Alice"} end
  fixture :bob   do {name: "Bob"}   end

  topic 'Example' do

    ## フィクスチャ「team」を指定すると、自動的に「alice」と「bob」も生成される
    spec "team fixture" do |team|
      ok {team} == {members: [{name: "Alice"}, {name: "Bob"}] }
    end

  end

end

フィクスチャの依存関係がループしていると、実行時エラーになります。たとえば `fixture :a do |b| 1 end` と `fixture :b do |a| 2 end` という定義だと、フィクスチャ a が フィクスチャ b に依存し、かつ b が a に依存しています(相互依存)。この場合、a または b を生成すると `Oktest::LoopedDependencyError: fixture dependency is looped: a=>b=>a` というエラーが発生します。 依存関係のチェックはフィクスチャ定義の段階では行われず、フィクスチャをインジェクションするときに行われます。言い換えると、依存関係にループがあってもテストスクリプトを読み込んだだけではエラーは発生せず、実行してみて初めて発生します。

scope()fixture: というキーワード引数を指定すると、フィクスチャの値を上書きできます。「このテストケースではフィクスチャの値を少し変えたい」という場合に利用できます。

test/example27_test.rb
require 'oktest'

Oktest.scope do

  fixture :user do |uname, uid: 101|   # `uid` はキーワード引数
    {name: uname, id: uid}
  end

  fixture :uname do
    "Alice"
  end

  topic 'Example' do

    ## spec() のキーワード引数 `fixture:` で、フィクスチャの値を上書き
    spec "example", fixture: {uname: "Bob", uid: 201} do   # !!!!!
      |user|
      ok {user[:name]} == "Bob"    # != "Alice"
      ok {user[:id]}   == 201      # != 101
    end

  end

end

fixture: キーワード引数は使いこなすのに慣れが必要なので、たいていの場合は spec() の中で fixture() を素直に呼び出すほうが簡単でしょう。

test/example28_test.rb
require 'oktest'

Oktest.scope do

  fixture :user do |uname, uid: 101|   # `uid` はキーワード引数
    {name: uname, id: uid}
  end

  fixture :uname do
    "Alice"
  end

  topic 'Example' do

    spec "example" do                          # `fixture:` キーワード引数を止めて、
      user = fixture(:user, "Bob", uid: 201)   # かわりに `fixture()` を呼び出す
      ok {user[:name]} == "Bob"    # != "Alice"
      ok {user[:id]}   == 201      # != 101
    end

  end

end

グローバルスコープ

よく使うフィクスチャは、テストスクリプトごとに定義するよりも、共通で使うファイルに分離するほうがいいでしょう。このような場合は、Oktest.scope() のかわりに Oktest.global_scope() を使ってください。

test/fixtures.rb
require 'oktest'

## 複数のテストスクリプトで使われるフィクスチャは、
## グローバルスコープの中で定義する
Oktest.global_scope do     # これがグローバルスコープ

  fixture :alice do
    {name: "Alice"}
  end

  fixture :bob do
    {name: "Bob"}
  end

  fixture :team do |alice, bob|
    {members: [alice, bob]}
  end

end

他のテストスクリプトでこれらのフィクスチャを使うには、このファイルを読み込んでください(例:require_relative 'fixtures')。

ヘルパー関数

Oktest.rbには、テストを書くのに便利な関数があらかじめ用意されています。ここではそれらを紹介します。

capture_sio()

capture_sio() は、標準入力と標準出力と標準エラー出力を横取り(キャプチャ)します。

test/example31_test.rb
require 'oktest'

Oktest.scope do

  topic "Capturing" do

    spec "example spec" do
      data = nil
      sout, serr = capture_sio("blabla") do            # !!!!!
        data = $stdin.read()     # 標準入力から読み込む
        puts "fooo"              # 標準出力へ書き出す
        $stderr.puts "baaa"      # 標準エラー出力へ書き出す
      end
      ok {data} == "blabla"
      ok {sout} == "fooo\n"
      ok {serr} == "baaa\n"
    end

  end

end
  • capture_sio() の第1引数は、標準入力 ($stdin) からの入力データを表します。これは省略可能なので、その場合は caputre_sio() do ... end のように書けます。
  • もし $stdin.tty? == true$stdout.tty? == true が必要なら、capture_sio(tty: true) do ... end のように呼び出してください。

dummy_file()

dummy_file() は、ダミーファイルを一時的に作成します。作成されたダミーファイルは、テストケース終了後に自動的に削除されます。

test/example32_test.rb
require 'oktest'

Oktest.scope do

  topic "dummy_file()" do

    spec "usage #1: without block" do
      ## ダミーファイルを作成する
      tmpfile = dummy_file("_tmp_file.txt", "blablabla")    # !!!!!
      ok {tmpfile} == "_tmp_file.txt"
      ok {tmpfile}.file_exist?          # ファイルが存在する
      ## ダミーファイルは、spec() ブロックの終わりに自動的に削除される
    end

    spec "usage #2: with block" do
      ## ブロック引数つきだと、ダミーファイルはブロックの終わりで削除される。
      ## またブロックの最後に評価した値が戻り値となる。
      result = dummy_file("_tmp_file.txt", "blabla") do |tmpfile|  # !!!!!
        ok {tmpfile} == "_tmp_file.txt"
        ok {tmpfile}.file_exist?        # ファイルが存在する
        1234
      end
      ok {result} == 1234
      ok {"_tmp_file.txt"}.not_exist?   # ファイルが存在しない
    end

  end

end
  • dummy_file() の第1引数にはダミーファイルのファイル名を指定します。もし第1引数が nil なら、重複しないファイル名が自動生成されます。

dummy_dir()

dummy_dir() は、ダミーのディレクトリを一時的に作成します。作成されたダミーディレクトリは、テストケースの終わりに自動的に中身ごと削除されます。

test/example33_test.rb
require 'oktest'

Oktest.scope do

  topic "dummy_dir()" do

    spec "usage #1: without block" do
      ## ダミーディレクトリを作成する
      tmpdir = dummy_dir("_tmp_dir")               # !!!!!
      ok {tmpdir} == "_tmp_dir"
      ok {tmpdir}.dir_exist?       # ディレクトリが存在する
      ## ダミーディレクトリは、spec() ブロックの終わりに中身ごと自動削除される
    end

    spec "usage #2: with block" do
      ## ブロック引数があると、ブロックの終わりで自動的に削除される。
      ## またブロックの最後に評価された値が戻り値となる。
      result = dummy_dir("_tmp_dir") do |tmpdir|   # !!!!!
        ok {tmpdir} == "_tmp_dir"
        ok {tmpdir}.dir_exist?     # ディレクトリが存在する
        2345
      end
      ok {result} == 2345
      ok {"_tmp_dir"}.not_exist?   # ディレクトリが存在しない
    end

  end

end
  • dummy_dir() の第1引数にはディレクトリ名を指定します。もし第1引数が nil なら、重複しないディレクトリ名が自動的に生成されます。

dummy_values()

dummy_values() は、Hashオブジェクトの値を一時的に変更・追加し、テストケースの終わりで自動的に元に戻します。

test/example34_test.rb
require 'oktest'

Oktest.scope do

  topic "dummy_values()" do

    spec "usage #1: without block" do
      hashobj = {:a=>1, 'b'=>2, :c=>3}   # `:x` is not a key
      ## Hashオブジェクトの値を一時的に変更・追加し、
      ## spec() ブロックの終わりで自動的に元に戻す。
      ret = dummy_values(hashobj, :a=>100, 'b'=>200, :x=>900)  # !!!!!
      ok {hashobj[:a]} == 100         # 変更されている
      ok {hashobj['b']} == 200        # 変更されている
      ok {hashobj[:c]} == 3           # 変更されている
      ok {hashobj[:x]} == 900         # 追加されている
      ok {ret} == {:a=>100, 'b'=>200, :x=>900}
    end

    spec "usage #2: with block" do
      hashobj = {:a=>1, 'b'=>2, :c=>3}   # `:x` is not a key
      ## ブロック引数があれば、ブロックの終わりで自動的に元に戻す。
      ## またブロックの最後に評価した値が戻り値となる。
      ret = dummy_values(hashobj, :a=>100, 'b'=>200, :x=>900) do |keyvals| # !!!!!
        ok {hashobj[:a]} == 100       # 変更されている
        ok {hashobj['b']} == 200      # 変更されている
        ok {hashobj[:c]} == 3         # 変更されている
        ok {hashobj[:x]} == 900       # 追加されている
        ok {keyvals} == {:a=>100, 'b'=>200, :x=>900}
        3456
      end
      ok {hashobj[:a]} == 1           # 元に戻っている
      ok {hashobj['b']} == 2          # 元に戻っている
      ok {hashobj[:c]} == 3           # 元に戻っている
      not_ok {hashobj}.key?(:x)       # 元に戻っている
      ok {ret} == 3456
    end

  end

end
  • dummy_values() は、環境変数を格納している ENV オブジェクトを一時的に変更するときにとても便利です。たとえば dummy_values(ENV, 'LANG'=>'en_US.UTF-8') とすれば、環境変数 $LANG の値を一時的に変更できます。

dummy_attrs()

dummy_attrs() は、オブジェクトの属性値を一時的に変更します。

test/example35_test.rb
require 'oktest'

class User
  def initialize(id, name)
    @id   = id
    @name = name
  end
  attr_accessor :id, :name
end

Oktest.scope do

  topic "dummy_attrs()" do

    spec "usage #1: without block" do
      ## オブジェクトを作成する
      user = User.new(123, "alice")
      ok {user.id} == 123
      ok {user.name} == "alice"
      ## オブジェクトの属性を一時的に変更し、spec()ブロックの終わりで元に戻す
      ret = dummy_attrs(user, :id=>999, :name=>"bob")   # !!!!!
      ok {user.id} == 999             # 変更されている
      ok {user.name} == "bob"         # 変更されている
      ok {ret} == {:id=>999, :name=>"bob"}   # 変更内容が戻り値となっている
    end

    spec "usage #2: with block" do
      ## オブジェクトを作成する
      user = User.new(123, "alice")
      ok {user.id} == 123
      ok {user.name} == "alice"
      ## ブロック引数があると、ブロックの終わりで元に戻す。
      ## またブロックの最後に評価した値が戻り値となる。
      ret = dummy_attrs(user, :id=>999, :name=>"bob") do |keyvals|  # !!!!!
        ok {user.id} == 999           # 変更されている
        ok {user.name} == "bob"       # 変更されている
        ok {keyvals} == {:id=>999, :name=>"bob"}
        4567
      end
      ok {user.id} == 123             # 元に戻っている
      ok {user.name} == "alice"       # 元に戻っている
      ok {ret} == 4567
    end

  end

end

dummy_ivars()

dummy_ivars() は、オブジェクトのインスタンス変数を一時的に変更・追加し、テストケースの終わりで自動的に元に戻します。

test/example36_test.rb
require 'oktest'

class User
  def initialize(id, name)
    @id   = id
    @name = name
  end
  attr_reader :id, :name    # setter, not accessor
end

Oktest.scope do

  topic "dummy_attrs()" do

    spec "usage #1: without block" do
      ## オブジェクトを作成する
      user = User.new(123, "alice")
      ok {user.id} == 123
      ok {user.name} == "alice"
      ## インスタンス変数を一時的に変更し、spec()ブロックの終わりで自動的に元に戻す
      ret = dummy_ivars(user, :id=>999, :name=>"bob")   # !!!!!
      ok {user.id} == 999            # 変更された
      ok {user.name} == "bob"        # 変更された
      ok {ret} == {:id=>999, :name=>"bob"}  # 戻り値は変更内容
    end

    spec "usage #2: with block" do
      user = User.new(123, "alice")
      ok {user.id} == 123
      ok {user.name} == "alice"
      ## ブロックつきで呼び出すと、ブロックの終わりで自動的に元に戻る。
      ## またブロックの最後に評価した値が戻り値となる。
      ret = dummy_ivars(user, :id=>999, :name=>"bob") do |keyvals|  # !!!!!
        ok {user.id} == 999          # 変更された
        ok {user.name} == "bob"      # 変更された
        ok {keyvals} == {:id=>999, :name=>"bob"}
        6789
      end
      ok {user.id} == 123            # 元に戻った
      ok {user.name} == "alice"      # 元に戻った
      ok {ret} == 6789
    end

  end

end

recorder()

recorder() は、Benry::Recorder オブジェクトを生成します。これを使うと、どのメソッドがどんな引数で呼ばれたかを記録できます。詳しくは Benry::Recorder README を参照してください。

test/example37_test.rb
require 'oktest'

class Calc
  def total(*nums)
    t = 0; nums.each {|n| t += n }   # or: nums.sum()
    return t
  end
  def average(*nums)
    return total(*nums).to_f / nums.length
  end
end


Oktest.scope do

  topic 'recorder()' do

    spec "records method calls." do
      ## 記録対象となるオブジェクト
      calc = Calc.new
      ## メソッド呼び出しを記録するオブジェクトとメソッド名を指定する
      rec = recorder()               # !!!!!
      rec.record_method(calc, :total)
      ## メソッドを呼び出す
      v = calc.average(1, 2, 3, 4)   # calc.average() は内部で calc.total() を呼び出す
      p v                   #=> 2.5
      ## メソッド呼び出しの記録を調べる
      p rec.length          #=> 1
      p rec[0].obj == calc  #=> true
      p rec[0].name         #=> :total
      p rec[0].args         #=> [1, 2, 3, 4]
      p rec[0].ret          #=> 2.5
    end

    spec "defines fake methods." do
      ## 記録対象となるオブジェクト
      calc = Calc.new
      ## オブジェクトにフェイクのメソッドとその戻り値を定義する
      rec = recorder()                 # !!!!!
      rec.fake_method(calc, :total=>20, :average=>5.5)
      ## フェイクのメソッドを呼び出す
      v1 = calc.total(1, 2, 3)         # フェイクのメソッドの戻り値
      p v1                  #=> 20
      v2 = calc.average(1, 2, 'a'=>3)  # フェイクのメソッドはどんな引数も受けつける
      p v2                  #=> 5.5
      ## メソッド呼び出しの記録を表示する
      puts rec.inspect
        #=> 0: #<Calc:0x00007fdb5482c968>.total(1, 2, 3) #=> 20
        #   1: #<Calc:0x00007fdb5482c968>.average(1, 2, {"a"=>3}) #=> 5.5
    end

  end

end

partial_regexp()

partial_regexp() は、文字列を正規表現オブジェクトに変換します。このとき、文字列の中の一部分にだけ正規表現のメタキャラクタを使い、残りの部分ではメタキャラクタをエスケープします。partial_regexp() を使うと、あたかも「一部にだけ正規表現が使える文字列」のような感覚でアサーションを書けます。

例として、次のような関数 f1() を考えます。この関数は複数行の文字列を生成し、その中には今日の日付と16文字のランダム文字列を含んでいます。つまり、f1() の戻り値は呼び出すごとに変化します。

def f1()
  today  = Date.today.to_s                  # ex: '2021-12-31'
  secret = Random.bytes(8).unpack('H*')[0]  # ex: "cd0b260ac728eda5"
  return <<END
* [config.date]   #{today}
* [config.secret] #{secret}
END
end

ff1() が返す文字列は呼び出しごとに変化するので、アサーションを書くには正規表現が必要です。Rubyの正規表現リテラルを使うと、次のようになるでしょう。

  topic 'f1()' do
    spec "generates multiline string." do
      expected = /\A\* \[config\.date\]   \d\d\d\d-\d\d-\d\d\n\* \[config\.secret\] [0-9a-f]+\n/
      ok {f1()} =~ expected
    end
  end

見ての通り、とても複雑な正規表現になってしまいます。

正規表現の x オプション (例:/.../x) を使うと、正規表現リテラルを複数行に分けて書けます。ただし、メタキャラクタ (*, ., []) と半角空白をエスケープする必要があります。

  topic 'f1()' do
    spec "generates multiline string." do
      expected = /\A
\*\ \[config\.date\]\ \ \ \d\d\d\d-\d\d-\d\d\n
\*\ \[config\.secret\]\ [0-9a-f]+\n
\z/x      # !!!!!
      ok {f1()} =~ expected
    end
  end

少しましになりましたが、十分複雑ですね。

このような場合には、partial_regexp() がとても便利です。partial_regexp() は文字列を正規表現オブジェクトに変換ます。このとき {== ==} で囲まれた部分だけ正規表現のメタキャラクタが使え、それ以外の部分ではメタキャラクタは自動的にエスケープされます。

  topic 'f1()' do
    spec "generates multiline string." do
      ## 複数行文字列を正規表現オブジェクトに変換する。
      ## このとき、`{== ==}` で囲まれた部分だけメタキャラクタが使え、
      ## 残りの部分では Regexp.escape() によりエスケープされる。
      expected = partial_regexp <<'END'      # !!!!!
* [config.date]   {== \d\d\d\d-\d\d-\d\d ==}
* [config.secret] {== [0-9a-f]+ ==}
END
      ok {f1()} =~ expected
      ## これは次と同じ
      #expected = /\A
      #\*\ \[config\.date\]\ \ \ \d\d\d\d-\d\d-\d\d\n
      #\*\ \[config\.secret\]\ [0-9a-f]+\n
      #\z/x      # !!!!!
      #ok {f1()} =~ expected
    end
  end

埋め込みの {== [0-9a-f]+ ==} では読みやすくするために半角空白を入れられるようにしていますが、半角空白を入れずに {==[0-9a-f]+==} と書いてもいいです。でも読みにくくなるので、特にこだわりがなければ半角空白を入れましょう。

partial_regexp() には引数が4つあります。

def partial_regexp(pattern, begin_='\A', end_='\z', mark='{== ==}')

partial_regexp() は、先頭に \A を、また末尾に \z を自動的につけます。これをつけたくない場合は、第2引数と第3引数に空文字列を指定します。

partial_regexp <<-'END', '', ''    # !!!!!
...
END

デフォルトの埋め込みの目印は {== ==} ですが、第4引数で変更できます。

partial_regexp <<-'END', '\A', '\z', '%% %%'    # !!!!!
* [config.date]   %% \d\d\d\d-\d\d-\d\d %%
* [config.secret] %% [0-9a-f]+ %%
END

partial_regexp!() というヘルパー関数もあります。partial_regexp()partial_regexp!() の違いは、戻り値である正規表現オブジェクトの .inspect() メソッドにあります。前者の .inspect() は Ruby の正規表現リテラルの形式になり、後者の .inspect()partial_regexp() の呼び出し形式になります。アサーション失敗時にどちらの形式で表示してほしいかを考えて、partial_regexp()partial_regexp!() の好きなほうを使ってください。

r1 = partial_regexp <<-'END'
* [config.date]   {== \d\d\d\d-\d\d-\d\d ==}
* [config.secret] {== [0-9a-f]+ ==}
END
p r1
   #=> /\A
   #   \*\ \[config\.date\]\ \ \ \d\d\d\d-\d\d-\d\d\n
   #   \*\ \[config\.secret\]\ [0-9a-f]+\n
   #   \z/x

r2 = partial_regexp! <<-'END'                   # !!!!!
* [config.date]   {== \d\d\d\d-\d\d-\d\d ==}
* [config.secret] {== [0-9a-f]+ ==}
END
p r2
   #=> partial_regexp(<<PREXP, '\A', '\z')
   #   * [config.date]   {== \d\d\d\d-\d\d-\d\d ==}
   #   * [config.secret] {== [0-9a-f]+ ==}
   #   PREXP

JSON Matcher

Oktest.rb では、JSONデータのアサーションが簡単にかける方法を用意しています。ただし乱用は禁物です。

JSON Matcherの簡単なサンプル

たとえばこのようなJSON(の基となる)データがあったとします。

actual = {
  "name":     "Alice",
  "id":       1001,
  "age":      18,
  "email":    "alice@example.com",
  "gender":   "F",
  "deleted":  false,
  "tags":     ["aaa", "bbb", "ccc"],
 #"twitter":  "@alice",
}

従来のテストスクリプトでは、JSONデータのアサーションは要素ごとに個別に書く必要がありました。

## 要素ごとにアサーションを個別に書く
ok {actual[:name]} == "Alice"
ok {actual[:id]}.between?(1000, 9999)
ok {actual[:age]}.is_a?(Integer)
ok {actual[:email]} =~ /^\w+@example\.com$/
ok {actual[:gender]}.in?(["M", "F"])
ok {actual[:deleted]}.in?([true, false])
ok {actual[:tags]}.all? {|tag| tag =~ /^\w+$/ }
if actual[:twitter]
  ok {actual[:twitter]} == "@alice"
end

Oktest.rb の JSON Matcher を使うと、これをすっきりとした形で書けます。

require 'set'
ok {JSON(actual)} === {               # `JSON()` と `===` 演算子を使う
  "name":     "Alice",                # スカラー値
  "id":       1000..9999,             # Rangeオブジェクト
  "age":      Integer,                # クラスオブジェクト
  "email":    /^\w+@example\.com$/,   # 正規表現オブジェクト
  "gender":   Set.new(["M", "F"]),    # 集合オブジェクト ("M" または "F")
  "deleted":  Set.new([true, false]), # 真偽値 (true または false)
  "tags":     [/^\w+$/].each,         # Enumeratorオブジェクト (!= Array obj)
  "twitter?": /^@\w+$/,               # キー 'xxx?'は非必須を表す
}

内部では、これを次のように変換して実行しています。=== 演算子を使っていること、左右を入れ替えて比較していることがポイントです。また Enumerator オブジェクトは特別扱いしています。

"Alice"      === actual["name"]           # スカラー値
(1000..9999) === actual["id"]             # Rangeオブジェクト
Integer      === actual["age"]            # クラスオブジェクト
/^\w+@example\.com$/ === actual["email"]  # 正規表現オブジェクト
Set.new(["M", "F"])  === actual["gender"] # 集合オブジェクト ("M" または "F")
Set.new([true, false]) === actual["deleted"]  # 真偽値 (true または false)
actual["tags"].each {|x| /^\w+$/ === x }  # Enumeratorオブジェクト (!= Array obj)
if actual.key?("twitter")                 # キー 'xxx?'は非必須を表す
  /^@\w+$/   === actual["twitter"]
end

テストスクリプト全体は次のようになります。

test/example41_test.rb
require 'oktest'
require 'set'                               # !!!!!

Oktest.scope do
  topic 'JSON Example' do

    spec "simple example" do
      actual = {
        "name":     "Alice",
        "id":       1001,
        "age":      18,
        "email":    "alice@example.com",
        "gender":   "F",
        "deleted":  false,
        "tags":     ["aaa", "bbb", "ccc"],
       #"twitter":  "@alice",
      }
      ## JSON Matcherを使ったアサーション
      ok {JSON(actual)} === {               # `JSON()` と `===` 演算子を使う
        "name":     "Alice",                # スカラー値
        "id":       1000..9999,             # Rangeオブジェクト
        "age":      Integer,                # クラスオブジェクト
        "email":    /^\w+@example\.com$/,   # 正規表現オブジェクト
        "gender":   Set.new(["M", "F"]),    # 集合オブジェクト ("M" または "F")
        "deleted":  Set.new([true, false]), # 真偽値 (true または false)
        "tags":     [/^\w+$/].each,         # Enumeratorオブジェクト (!= Array obj)
        "twitter?": /^@\w+$/,               # キー 'xxx?'は非必須を表す
      }
    end

  end
end


Ruby 2.4 とそれ以前では `Set#===()` が定義されていないため、上のコードは実行エラーになります。その場合は次のコードをテストスクリプトに追加してください。

(追加コード)1

require 'set'
unless Set.instance_methods(false).include?(:===)  # for Ruby 2.4 or older
  class Set; alias === include?; end
end

なお JSON Matcher では、Enumerator オブジェクトは Array オブジェクトとは違う役割を持っているので注意してください。

  actual = {"tags": ["foo", "bar", "baz"]}

  ## Array:配列全体が一致するか調べる
  ok {JSON(actual)} == {"tags": ["foo", "bar", "baz"]}

  ## Enumerator:配列の要素が正規表現やSetやRangeにマッチするか調べる
  ok {JSON(actual)} == {"tags": [/^\w+$/].each}

入れ子データのサンプル

JSONデータが入れ子になっている場合のサンプルコードは次のようになります。

test/example42_test.rb
require 'oktest'
require 'set'                            # !!!!!

Oktest.scope do
  topic 'JSON Example' do

    spec "nested example" do
      actual = {
        "teams": [
          {
            "team": "Section 9",
            "members": [
              {"id": 2500, "name": "Aramaki", "gender": "M"},
              {"id": 2501, "name": "Motoko" , "gender": "F"},
              {"id": 2502, "name": "Batou"  , "gender": "M"},
            ],
            "leader": "Aramaki",
          },
          {
            "team": "SOS Brigade",
            "members": [
              {"id": 1001, "name": "Haruhi", "gender": "F"},
              {"id": 1002, "name": "Mikuru", "gender": "F"},
              {"id": 1003, "name": "Yuki"  , "gender": "F"},
              {"id": 1004, "name": "Itsuki", "gender": "M"},
              {"id": 1005, "name": "Kyon"  , "gender": "M"},
            ],
          },
        ],
      }
      ## JSON Matcherを使ったアサーション
      ok {JSON(actual)} === {            # `JSON()` と `===` 演算子が必要
        "teams": [
          {
            "team": String,
            "members": [
              {"id": 1000..9999, "name": String, "gender": Set.new(["M", "F"])}
            ].each,                     # Enumerator object (!= Array obj)
            "leader?": String,           # key 'xxx?' means optional value
          }
        ].each,                          # Enumerator object (!= Array obj)
      }
    end

  end
end

より複雑なサンプル

  • OR(x, y, z) は、x または y または z にマッチします。 例:OR(String, Integer) は、文字列または整数にマッチします。
  • AND(x, y, z) は、x かつ y かつ z にマッチします。 例:AND(Integer, 1..1000) は、1以上1000以下の整数にマッチします。
  • キー "*" は、Hashオブジェクトのどのキーでもマッチします。
  • Any() は何にでもマッチします。
test/example43_test.rb
require 'oktest'
require 'set'

Oktest.scope do
  topic 'JSON Example' do

    ## OR()を使う例
    spec "OR() example" do
      ok {JSON({"val": "123"})} === {"val": OR(String, Integer)}    # OR()
      ok {JSON({"val":  123 })} === {"val": OR(String, Integer)}    # OR()
    end

    ## AND()を使う例
    spec "AND() example" do
      ok {JSON({"val": "123"})} === {"val": AND(String, /^\d+$/)}   # AND()
      ok {JSON({"val":  123 })} === {"val": AND(Integer, 1..1000)}  # AND()
    end

    ## キー '*' とANY()を使う例
    spec "`*` and `ANY` example" do
      ok {JSON({"name": "Bob", "age": 20})} === {"*": Any()}    # '*' と Any()
    end

    ## それらを組み合わせて使う例
    spec "complex exapmle" do
      actual = {
        "item":   "awesome item",
        "colors": ["red", "#cceeff", "green", "#fff"],
        "memo":   "this is awesome.",
        "url":    "https://example.com/awesome",
      }
      ## assertion
      color_names = ["red", "blue", "green", "white", "black"]
      color_pat   = /^\#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/
      ok {JSON(actual)} === {
        "colors": [
          AND(String, OR(Set.new(color_names), color_pat)),   # AND() と OR()
        ].each,
        "*": Any(),             # 任意のキー (`"*"`) と値 (`ANY`) にマッチ
      }
    end

  end
end

(注意:/^\d+$/ はそれだけで値が文字列オブジェクトであることを前提としているので、わざわざ AND(String, /^\d+$/) とする必要はありません。また 1..1000 はそれだけで値が整数であることを前提としているので、わざわざ AND(Integer, 1..1000) と書く必要はありません。)

## こう書くだけでよい
ok {JSON({"val": "A"})} === {"val": /^\d+$/}   # implies String value
ok {JSON({"val": 99 })} === {"val": 1..100}    # implies Integer value

## こう書く必要はない
ok {JSON{...}} === {"val": AND(String, /^\d+$/)}
ok {JSON{...}} === {"val": AND(Integer, 1..100)}

JSON Matcher のためのヘルパー関数

JSON Matcher のためのヘルパー関数がいくつか用意されています。

  • Enum(x, y, z)Set.new([x, y, z]) の短縮記法です。
  • Bool()Enum(true, false) の短縮記法です。
  • Length(3) は長さ3にマッチし、Length(1..3) は長さ1以上3以下にマッチします。
test/example44_test.rb
  actual = {"gender": "M", "deleted": false, "code": "ABCD1234"}
  ok {JSON(actual)} == {
    "gender":  Enum("M", "F"),        # Set.new(["M", "F"]) と同じ
    "deleted": Bool(),                # Enum(true, false) と同じ
    "code":    Length(6..10),         # 長さは6以上10以下
  }

Tips

MiniTest で ok {} を使いたい

MiniTest でも ok {actual} == expected のスタイルでアサーションを書きたいなら、minitest-ok gem をインストールしてください。Oktest.rb は必要ありません。

$ gem install minitest-ok
test/example51_test.rb
require 'minitest/spec'
require 'minitest/autorun'
require 'minitest/ok'      # !!!!!

describe 'MiniTest::Ok' do

  it "helps to write assertions" do
    ok {1+1} == 2          # !!!!!
  end

end

詳細は minitest-ok README を見てください。

Rack アプリケーションをテストする

rack-test_app gem を使うと、Rack アプリケーションのテストがしやすくなります。

$ gem install rack-app_test
test/example52_test.rb
require 'rack'
require 'rack/lint'
require 'rack/test_app'      # !!!!!
require 'oktest'

## Rackアプリケーションのサンプル
app = proc {|env|
  text = '{"status":"OK"}'
  headers = {"Content-Type"   => "application/json",
             "Content-Length" => text.bytesize.to_s}
  [200, headers, [text]]
}

## Rackアプリケーションのラッパーオブジェクトを作る
$http = Rack::TestApp.wrap(Rack::Lint.new(app))

## テストコード
Oktest.scope do

+ topic("GET /api/hello") do

  - spec("returns JSON data.") do
      response = $http.GET("/api/hello")       # Rackアプリを呼び出す
      ok {response.status}       == 200
      ok {response.content_type} == "application/json"
      ok {response.body_json}    == {"status"=>"OK"}
    end

  end

end

エンドポイントごとにトピックを分けて、その中でヘルパー関数を定義するといいでしょう。トピックが分かれていれば、ヘルパー関数名は同じで構いません。

Oktest.scope do

+ topic("GET /api/hello") do

    ## トピックごとにヘルパー関数を定義
    def api_call(**kwargs)
      $http.GET("/api/hello", **kwargs)
    end

  - spec("returns JSON data.") do
      resp = api_call()                    # Rackアプリを呼び出す
      ok {resp.status}       == 200
      ok {resp.content_type} == "application/json"
      ok {resp.body_json}    == {"status"=>"OK"}
    end

  end

+ topic("POST /api/hello") do

    ## トピックごとにヘルパー関数を定義
    def api_call(**kwargs)
      $http.POST("/api/hello", **kwargs)
    end

    ....

  end

end

環境変数 $OKTEST_RB

環境変数 $OKTEST_RB に、デフォルトのコマンドラインオプションを設定できます。たとえば oktest コマンドのデフォルトの出力スタイルを変更するには、次のようにします。

### デフォルトの出力スタイルを plain スタイルに変更する
$ export OKTEST_RB="-s plain"

### すると '-s' オプションを指定しなくても、出力が plain スタイルになる
$ ruby test/foo_test.rb

Traverserクラス

Oktest.rb では Traverser というクラスが用意されています。これはいわゆるVisitorパターンの実装クラスです。

test/example54_test.rb
require 'oktest'

Oktest.scope do
+ topic('Example Topic') do
  - spec("sample #1") do ok {1+1} == 2 end
  - spec("sample #2") do ok {1-1} == 0 end
  + case_when('some condition...') do
    - spec("sample #3") do ok {1*1} == 1 end
    end
  + case_else() do
    - spec("sample #4") do ok {1/1} == 1 end
    end
  end
end

## Traverserクラスを継承して、on_topic() や on_scope() を上書きする。
## ただし on_topic() の中では yield が必要(on_scope() では必要ない)。
class MyTraverser < Oktest::Traverser  # !!!!!
  def on_scope(filename, tag, depth)   # !!!!!
    print "  " * depth
    print "# scope: #{filename}"
    print " (tag: #{tag})" if tag
    print "\n"
    yield                              # should yield !!!
  end
  def on_topic(target, tag, depth)     # !!!!!
    print "  " * depth
    print "+ topic: #{target}"
    print " (tag: #{tag})" if tag
    print "\n"
    yield                              # should yield !!!
  end
  def on_case(cond, tag, depth)        # !!!!!
    print "  " * depth
    print "+ case: #{cond}"
    print " (tag: #{tag})" if tag
    print "\n"
    yield                              # should yield !!!
  end
  def on_spec(desc, tag, depth)        # !!!!!
    print "  " * depth
    print "- spec: #{desc}"
    print " (tag: #{tag})" if tag
    print "\n"
  end
end

## 継承したTraverserクラスを実行する
Oktest::Config.auto_run = false    # stop running test cases
MyTraverser.new.start()

実行結果:

$ ruby test/example54_test.rb
# scope: test/example54_test.rb
  + topic: Example Topic
    - spec: sample #1
    - spec: sample #2
    + case: When some condition...
      - spec: sample #3
    + case: Else
      - spec: sample #4

ベンチマーク

Oktest.rb の gem ファイルにはベンチマークスクリプトが付属しています。実行方法は次の通りです。

$ gem install oktest        # ver 1.2.0
$ gem install rspec         # ver 3.10.0
$ gem install minitest      # ver 5.14.4
$ gem install test-unit     # ver 3.4.4

$ cp -pr $GEM_HOME/gems/oktest-1.2.0/benchmark .
$ cd benchmark/
$ rake -T
$ ruby --version
ruby 3.0.2p107 (2021-07-07 revision 0db68f0233) [x86_64-darwin18]

$ rake benchmark:all

詳しい実行結果は Oktest.rbのREADME を見てもらうとして、サマリーだけを紹介すると次の通りです。Oktest.rbの実行速度がRSpecの約5倍であることが分かります。

Oktest:              6.815 real     6.511 user     0.257 sys
Oktest (--faster):   6.401 real     6.123 user     0.240 sys
RSpec:              32.062 real    27.778 user     4.383 sys
MiniTest:            9.140 real     8.657 user     0.705 sys
Test::Unit:         19.580 real    19.020 user     0.885 sys

RSpecが遅いのは、「仕様をテストコードとして記述する(仕様とテストコードを一致させる)」という野心的なゴールを掲げているからです。たとえRSpecが遅くとも、高いゴールを目指すその姿勢は尊重されるべきでしょう。

Oktest.rbはそこまでの野心は持っておらず、「仕様は文字列で記述するだけにとどめ、テストコードが直感的に書けることに注力する」と割り切っています。そのおかげでRSpecと比べてOktest.rbはシンプルな実装で済んでおり、結果として大きく高速化できています。

IMHO、RSpecが遅いのはゴールの高さを考えれば理解できるのですが、Test::Unitが遅い理由がよく分かりません(未調査)。Test::Unitは本来ならMiniTestと同程度の速度がでるはずです。PowerAssertを使わず assert_equal を使っているのにこれだけ遅いのなら、改善の余地が大いにありそうです。

まとめ

Ruby向けテスティングフレームワーク「Oktest.rb」を紹介しました。Oktest.rbはアサーションが直感的で、いろんな便利機能が備わっており、コンパクトな実装で動作も高速です。特に Fixture Injection と JSON Matcher と partial_regexp()at_end() は便利であり、個人的にとても気に入っています。ぜひ使ってみてください。

なおPython用もあります
ドキュメント:https://pythonhosted.org/Oktest/
紹介スライド:https://bit.ly/3E3XL6C


  1. Oktest.rb では Set#===() を自動定義しません。なぜなら、テスティング用ライブラリが既存のクラスやモジュールを変更すべきではない(しても最小限に留めるべき)と考えているからです。 

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3