(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でのテストスクリプトの書き方は、次のようになります。
# 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()
を使うのでそのような制約がなく、自然な文章で仕様を記述できます。
アサーションの失敗、およびエラー
アサーションが失敗したり、何らかのエラー(例外)が発生したときの表示を見てみましょう。
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()
のブロック引数を未指定にします。
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()
には、キーワード引数を使ってタグを指定できます。
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_when
と case_else
case_when()
と case_else()
は、仕様の条件を表現します。skip_when()
とは違い、条件に合った場合だけ実行する/スキップするというものではないので、間違えないようにしてください(実際のところ、case_when()
と case_else()
は内部的には topic()
の一種なのです)。
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()
の引数になります。
たとえば次のようなプログラムがあるとします。
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
生成されたテストコードは次のようになります。
# 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
# 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()
ごとの後処理
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 で定義されたもののほうが先に呼び出される。
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()
では登録できます。
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()
はフィクスチャを生成します。
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
フィクスチャ定義では、ブロック引数を指定できます。
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) と同じ仕組みです。
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引数の値がインジェクトされます。これは実験的機能なので、将来変更・廃止される可能性があります。
フィクスチャ定義のブロックに引数があると、その引数名に対応した他のフィクスチャがインジェクトされます。これによりフィクスチャの依存関係を指定できます。
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:
というキーワード引数を指定すると、フィクスチャの値を上書きできます。「このテストケースではフィクスチャの値を少し変えたい」という場合に利用できます。
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()
を素直に呼び出すほうが簡単でしょう。
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()
を使ってください。
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()
は、標準入力と標準出力と標準エラー出力を横取り(キャプチャ)します。
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()
は、ダミーファイルを一時的に作成します。作成されたダミーファイルは、テストケース終了後に自動的に削除されます。
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()
は、ダミーのディレクトリを一時的に作成します。作成されたダミーディレクトリは、テストケースの終わりに自動的に中身ごと削除されます。
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オブジェクトの値を一時的に変更・追加し、テストケースの終わりで自動的に元に戻します。
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()
は、オブジェクトの属性値を一時的に変更します。
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()
は、オブジェクトのインスタンス変数を一時的に変更・追加し、テストケースの終わりで自動的に元に戻します。
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 を参照してください。
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
テストスクリプト全体は次のようになります。
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データが入れ子になっている場合のサンプルコードは次のようになります。
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()
は何にでもマッチします。
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以下にマッチします。
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
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
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パターンの実装クラスです。
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
-
Oktest.rb では
Set#===()
を自動定義しません。なぜなら、テスティング用ライブラリが既存のクラスやモジュールを変更すべきではない(しても最小限に留めるべき)と考えているからです。 ↩