単体テストのための枠組み・ライブラリについて

xUnitとその類似

多くの場合、次のような仕組み・機能を持っている。

  • テスト定義用のクラス(テストケースクラス)を継承し、そのクラスの内部でテストを処理するメソッドを定義するというやり方が多い。
  • 定義したメソッドの内部でassertなんとかメソッドを呼んで個々のテストをおこなう。
    • たくさんの種類のassertなんとかメソッドが定義されていることが多い。
      assertEqual(3, 1+2)   # 1+2の実行結果の期待値は3
      assertTrue(aList.contains("b"))   # aListは"b"を含んでいる
    • JUnit4.4以降ではassertThatというメソッドだけで多くのテストができるようになっている。代わりに、マッチャーというものを生成するメソッドがたくさん定義されている。
      assertThat(1+2, is(3))   # isはマッチャーを返すメソッド
      assertThat(aList, hasItem("b"))   # hasItemもマッチャーを返すメソッド
    • RubyRspecのshouldもマッチャーを使用するタイプのテストメソッド。
      (1+2).should == 3
      aArray.should include("b")
  • 複数のテストをまとめたり、共通した事前処理・事後処理を定義する機能。
  • テストを実際に実行する部分はユーザが自分で書くのではなく、テストを探して実行する機構(テストランナー)が用意されている。
    • テストのパラメータ化が面倒な場合が多い。

xUnitの機能は次の考え方に近い。

テスト装備は、使用するテクノロジーに関係なく、以下の能力を持っているべきです。

  • 事前処理と後始末を記述するための標準的な方法
  • 個別のテストか、すべての利用可能なテストを行うかを選択する方法
  • 標準形式にのっとったエラー・レポート

(アンドリュー・ハント, デビッド・トーマス『達人プログラマー』p.198)

これらは、テストのための機能をたくさん提供する路線を取っている。これとは逆の路線もある。

PerlとTAP(Test Anything Protocol)

Perlではテスト結果の出力にTAPという形式を使うのが普通みたい。テスト結果を作っていくためのモジュール(実行したテストの数を数えたりTAP形式の出力をおこなう)Test::BuilderとTAP形式の出力を解析するモジュールTest::Harnessがあって、いろいろなテスト用ライブラリがこれを使ってTAPにしたがうテスト機能を提供している。
TAPは次のようなもの。

  • テスト結果は標準出力に書き出す。
  • テストの総数を示すplan行を、最初の行または最後の行に出力する。テスト総数がNの場合
    1..N
    とする。これを書く理由はテストが途中で終了してしまったかどうかを判断するためで、できれば最初に書くのが望ましいみたい。最後にplan行があるかないかで判断すればよいのでは、とも思うけど。
  • okで始まる行はテストの成功を表し、not okで始まる行はテストの失敗を表す(全て小文字で。インデントは不可)。その後ろは
    • 何番目のテストかを示す整数(省略可)。
    • テストの名前・説明などの文章(省略可)。
    • もし#が出てきたら、#の後ろには命令指定文が置かれる(命令にはTODOとSKIPがある)。
  • 1文字目が#で始まる行はコメント。
  • テストが全て成功していたら終了ステータスは0にする。終了ステータスとして0以外を返すと(出力が全てok行でも)ツールはテストが失敗している判断するみたい。失敗したテストの総数を終了ステータスにするのが良い作法みたいだけど、面倒なら0でも問題なさそう。

たとえば次のような感じになる。

1..5
# start phase 1
ok 1 - fun1(0) check boundary value
ok 2 - fun1(-1) check boundary value
ok 3 - fun1(10)
# start phase 2
not ok 4 - fun1(20) expected 3, but got 4
ok 5 - fun1(-10) == fun1(10)

この出力のままでは失敗結果だけを見るのには向かないから、フィルターコマンドやツールと連携させるか、あるいは失敗結果の診断を最後に標準エラー出力に書きだしてTAP出力は/dev/null行きにするとか。

proveコマンド

proveコマンドは、TAPにしたがっているプログラムを実行して、結果をまとめて表示してくれる。次の二つの「テスト」があったとする。
t1.sh

cat <<END
ok
ok
ok
ok
ok
1..5
END

t2.sh

cat <<END
ok
ok
ok
not ok - comment
ok
ok
# total 6, failure 1
# - comment
1..6
END

これに対してproveを実行してみる。

$ prove --exec='/bin/sh' t1.sh t2.sh 
t1.sh .. ok   
t2.sh .. Failed 1/6 subtests 

Test Summary Report
-------------------
t2.sh (Wstat: 0 Tests: 6 Failed: 1)
  Failed test:  4
Files=2, Tests=11,  0 wallclock secs ( 0.01 usr +  0.00 sys =  0.01 CPU)
Result: FAIL

Gauchegauche.testモジュール

gauche.testモジュールのテスト用関数(xUnitでassertなんとかメソッドにあたるもの)はtestとtest*しかない。test*はtestを薄くラップしたマクロなので実質ひとつしかない。test*は

(test* 名前 期待する値 実行する式)

または

(test* 名前 期待する値 実行する式 比較に使う関数)

という形で呼び出す。
たぶん多くの単体テストライブラリではテストを

fun test_something()
  前処理
  assert_equal(expected, テストしたい式);
end

というように関数やメソッドとして定義しておいて(さらにもしかするとこのような定義をクラス定義の中に書く必要があるかもしれない)、定義した関数やメソッドをテストランナーが見つけて実行していくという形を取っている。こうする理由のひとつは、「テストしたい式」でエラー・例外が発生してもそこでテスト全体を終了させずに、呼び出し側が例外を捕捉して次のテストに進められるようにするためだと思う(別の理由としてありそうなのは、テストの前処理・後始末のための標準的なやり方をライブラリでサポートするため)。
Gaucheのtest関数はテストしたい式はthunkで渡すので、例外が発生してもtestが補足してくれる。test*マクロはテストしたい式をlambda式でくるんでthunkにしてくれるのでやはり例外を補足してくれる。そのためGaucheでは特にクラスや関数を定義しなくても、test関数やtest*マクロをそのまま並べてテストを書けば良い(もちろん必要に応じて関数を定義してもかまわない)。
例えば次のようになる。

(test* "+: simple addition" 3 (+ 1 2))
;[出力] test +: simple addition, expects 3 ==> ok
(test* "list item" "b" aList member)

例外のテストもtest、test*でおこなえる。

(test* "car of number: should error" (test-error) (car 1))
;[出力] test car of number: should error, expects #<error> ==> ok

比較に使う関数は、2つの引数(期待する値、実際の結果)を受け取って判定を行う関数ならなんでも良い。

(define (relative-error x y)
  (/ (abs (- x y)) (max (abs x) (abs y))))

(define (float-nearly=? tolerance)
  (lambda (x y) (<= (relative-error x y) tolerance)))

(test* "float arithmetic:1" 0.01 (- 0.12 (/ 0.77 7.0)))
;[出力] test float arithmetic:1, expects 0.01 ==> ERROR: GOT 0.009999999999999995

(test* "float arithmetic:2" 0.01 (- 0.12 (/ 0.77 7.0)) (float-nearly=? 1.0e-5))
;[出力] test float arithmetic:2, expects 0.01 ==> ok

テストが簡単な場合、テストの名前を付けるのが面倒になったりする。他のテストとの区別やテストに使ったデータぐらいが判れば良いなら、次のようなマクロを使うこともできる。

(define-syntax test**
  (syntax-rules ()
    ((_ msg expected expr . rest)
     (test*
       (test-name-replace
         msg (debug-source-info 'expr) 'expr)
       expected expr . rest))))

(define (test-name-replace name s-info s-expr)
  (regexp-replace* name
                   #/~~/ "~" #/~i/ (x->string s-info) #/~e/ (x->string s-expr)))

使用例

(define (test-car-cons x y)
  (test** #`"~i:x=,|x| y=,|y|: ~e" x (car (cons x y))))

(dolist (y '(2 () (2 3)))
  (test-car-cons 1 y))
;[出力]
; test (./foo.scm 16):x=1 y=2: (car (cons x y)), expects 1 ==> ok
; test (./foo.scm 16):x=1 y=(): (car (cons x y)), expects 1 ==> ok
; test (./foo.scm 16):x=1 y=(2 3): (car (cons x y)), expects 1 ==> ok

Pythonのdoctest

Pythonのdoctestモジュールは、テキストの中からプログラムの対話実行を記した形になっている部分を見つけて、そのプログラムを実行し結果が書かれたものと一致するかを調べる。
この機能は、実行例を先に作っておいてdoctestを通るようにプログラムを作るという使い方もできるし、実行例をテキストに貼り付ければ回帰テスト(以前の実行結果と一致するか調べるテスト)として使うこともできる。たぶん。