プログラムを書くときにはテストを必ず書こうと思ってから、なかば予想したとおりではあるけどプログラムを全く書かなくなった(テストを書く方法として次のような話を見たことがある。printデバッグを一切しない。実行結果をprintで表示することによる動作確認をしない。代わりに動作を確認する関数やスクリプトを書く。まあ、これを実践する一番簡単な方法がプログラムを書かないことなのだけど)。
気分を改めて、テストをどうやるのかについて基本的なところからメモ。
ディレクトリの構成
ファイルの置く位置を気にしておかないと、ロードパスの指定やテストデータファイルの読み込みでハマる場合がある。
aProjectDirというディレクトリを起点にしてプログラムを書いている場合、次のような感じにすることが多いみたい。
aProjectDir - test # テストファイルを置く - lib # ライブラリプログラムを置く # これ以外はいろいろ。実行可能なプログラムを置くディレクトリにしても、 # トップレベルだったりbinやcmdやtoolsなどだったり、 # プロジェクト名に基づく名前のディレクトリだったり。
- テストファイルを置くディレクトリの名前はtest以外にもtestsだったりtだったりする。
- Perlではtに統一されている。また実装者用テスト(コードの文法チェックのように実行環境に依存しないもの=使用者が改めてチェックする必要の無いテスト)はxtディレクトリに置くというルールもあるみたい。
- 配布されているPerlのモジュールのほとんどにテストが付いていてソースコードも検索サイトから手軽に読めるので、テストの書き方の参考になるかも。
- 実行スクリプトのテストや単一のモジュール以上の粒度のテストは別のディレクトリにおいてあるプログラムがいくつかあった。他にもRubyのRSpecで書かれたテストはspecディレクトリに置くなど、テストツールに応じてテストファイルディレクトリの名前が違う場合もある。
テストをするときに問題になるのは、どの場所でテストを実行するか。テストを実行する位置が変わると、テストしたいプログラムやモジュールのロードパスや、テストで使うデータファイルのパスの指定に影響が出るので。
だいたい三つの可能性がある。
ただしテストディレクトリ以下が複数階層に分かれていない場合は、2と3の区別は無い。
Makefileがトップレベルに置いてあることが多いので、makeでテストを実行する場合はたぶんトップレベルでテストを実行することになる。
Perlのテストプログラムをいくつか見ると
BEGIN { chdir 't' if -d 't'; @INC = ('../lib', ……); }
とかこれに類することが書かれているものがけっこうある。こうすることで1の場合を2に帰着させて、ライブラリのロードパスはテストディレクトリにいること前提で指定されている。
Gaucheだとこうか。
(when (file-is-directory? "t") (sys-chdir "t")) (add-load-path "../lib")
ライブラリの読み込みについては、カレントディレクトリがどこかに依存しない読み込み方法を取っているものもある。例えばRubyのプログラムのテストの中には
$LOAD_PATH.unshift(File.dirname(__FILE__)) # __FILE__ は実行しているファイルの名
としてテストプログラムの置かれたディレクトリをロードパスに追加するものや、
require File.expand_path(File.dirname(__FILE__) + "/../test_helper")
のようにテストプログラムからの相対パスを絶対パスに展開してから読み込んでいるものもあった。
Gaucheだと(たぶんモジュールの評価順序がからむ部分で変なことができないように)add-load-path、require、useの引数の実行時の展開ができないようにしてある(コンパイル時にパスが決まる)。loadやデータファイルの読み込みには使える。
(open-input-file (sys-normalize-pathname (build-path (sys-dirname (current-load-path)) "data_dir" "data_file") :absolute #t))
(疑問: 普通はライブラリをインストールする前にテストを実行して通ることを確認する。でもインストールした後に同じテストを実行して確認したい場合があるかもしれない。正しくインストールされないバグのあるとかローカルな環境だけで成り立つ前提が仮定されているとか。そういう場合にも動くテストはどう書けるか)
テストファイルの命名と構成
テストファイルの命名規則はいくつかあった。
- 「test…….…」とか「……test.…」のようにファイル名の特定の位置に「test」を入れる、またはそれに類する命名規則(RSpecでの「……_spec.rb」など)。
- 拡張子を「.t」にする(Perl)。
- 「01_……」「002_……」のように頭に数字を付ける(連番とは限らない)(Perlの一部のモジュール)。単純なテストほど小さい番号にするとか、関連性の高いテストを近い番号にしてまとめるなど。あと、「99_cleanup.t」みたいなもので、テストで作成したファイルなどの後始末をするというものも。
各テストファイルの構成の仕方はいろいろ。
スクリプトのテスト
ライブラリや大きめのプログラムを書くことよりも小さなスクリプトを書くが多いけど、そういう場合にもテストを書きたい。
1が正攻法。スクリプト内の関数もテストできるし、プロセス制御関数を使えばコマンド引数の使い方も含めたスクリプトの呼び出しテストもできる。スクリプトを外側からテストするとか他のコマンドとの組み合わせをテストするなら3も。
スクリプト内にテストも同梱する
比較的小さいスクリプトの場合、わざわざテスト用に別ファイルを作るのをちゅうちょしてしまう(で結局テストは書かないことになる)。それを避ける方法のひとつが、スクリプトの内部にテストも一緒に書いておくこと。
スクリプトの最後に、適当な環境変数が定義されている場合にだけテストを実行するコードを書いておく。こんなふうに。
(define (run-test) ;; テストをいろいろ 0) (when (get-environment-variable "TEST") (exit (run-test)))
(exitしているのはmain関数を呼ぶ前に終了させるため)
Perlの場合なら、proveが定義する環境変数HARNESS_ACTIVEを使えばよい。
ただしこのやり方だと、スクリプトの呼び出しテストをしようとすると困る。
例えばa-script.scmの中で、a-script.scm自身の呼び出しテストをしようとして次に書くと、再びテストを実行してループに入ってしまう。
(define (run-test) (let1 p (run-process '(gosh a-script.scm -a -b -c file)) ... )) (when (get-environment-variable "TEST") (exit (run-test)))
これを避けるには、サブプロセスを呼ぶ前にsys-unsetenvで環境変数を未定義にするというのがひとつのやり方だと思う(HARNESS_ACTIVEみたいに別の役割がある変数の場合は、スクリプトを呼んだ後、元の値にしないといけないけど)。もっと良い方法があるかも。
(追記: 0.9.3以降の場合)
(define (run-test) ;; テスト 0) (define-module run-test (define (main args) (with-module user (run-test))))
などと書いておいて、mオプションでrun-testモジュールのmainを呼ぶとか。
> gosh -m run-test ...
シェルスクリプトのテスト
シェルスクリプトでテストを書く場合のことを考えると、テスト用のライブラリみたいなものが簡単なものでいいから欲しい。
調べるとshunit2というのがあるけど、けっこうたくさんのファイルとたくさんの関数があって、めんどうな感じがしてしまった。
最低限の機能なら(使いやすさはともかく)数十行ぐらいで書けそうに思えたので、bash用のテストヘルパーを書いてみた(書き加えているうちに無駄に行数が増えたけど)。
それを使ったテストの例。grepとdiffの動作を調べる。
aTest.sh
#!/bin/bash . "./testsimple.sh" # 実際の中身は後述 # テストデータ x=$( cat <<\END alpha beta gamma delta END ) y=$( cat <<\END alpha beta GAMMA delta END ) # ok : 引数の文字列をevalで評価し、終了コードが0ならテスト成功。 # 第2引数は(もしあれば)テストの名前orテストコメント # assert_succ : 直前のコマンドが成功(終了コードが0)なら何も起こらない。 # 直前のコマンドが失敗だった場合、exit 1を呼ぶ。 # assert_fail : 直前のコマンドが失敗(終了コードが0意外)なら何も起こらない。 # 直前のコマンドが成功だった場合、exit 1を呼ぶ。 # <(……) はプロセス置換構文。サブプロセスの出力をファイルのように扱う ok ' grep "gamma" <(echo "$x") assert_succ grep "gamma" <(echo "$y") assert_fail ' "grep" ok ' grep -i "gamma" <(echo "$x") assert_succ grep -i "gamma" <(echo "$y") assert_succ ' "grep ignore case" expected_diff_x_y=$( cat <<\END 3c3 < gamma --- > GAMMA END ) ok 'diff -i <(echo "$x") <(echo "$y")' "diff ignore case" ok ' out=$( diff <(echo "$x") <(echo "$y") ) assert_fail [ "${out}" = "${expected_diff_x_y}" ] ' "diff output" done_testing
テストを実行する。
$ prove aTest.sh aTest.sh .. ok All tests successful. Files=1, Tests=4, 0 wallclock secs ( 0.02 usr + 0.00 sys = 0.02 CPU) Result: PASS
テストができるようになっても何をどうテストするかが判らないとどうしようもないのだけど。
テストの書き方
これが本題なのだけど、まだそこまでたどり着けない。
testsimple.shのソース
testsimple.sh
test_total=0 test_succ=0 test_fail=0 test "${VERBOSE:+def}" = "def" is_verbose=$? diag() { local indent="${2:-}" { echo "$1" | sed -e "s/^/# ${indent}/" } 1>&2 } diag_unless_empty() { if [ -n "$1" ] ; then diag "$1" "$2" fi } newline_if_harness() { if [ "${HARNESS_ACTIVE:+def}" = "def" ] ; then echo "" 1>&2 fi } count_success_failure() { # arg(exit_code) if [ $1 = 0 ] ; then test_succ=$((${test_succ} + 1)) else test_fail=$((${test_fail} + 1)) fi } echo_test_line() { # arg(ret_val, test_name, directive) local test_line if [ $1 = 0 ] ; then test_line="ok ${test_total}" else test_line="not ok ${test_total}" fi if [ -n "$2" ] ; then test_line="${test_line} - $2" fi if [ -n "$3" ] ; then test_line="${test_line} # $3" fi echo ${test_line} } ok() { local cmd="$1" local test_name="$(echo "$2" |sed -e 's/#/\\#/g')" # $2 is optional local directive="$3" # $3 is optional local cmd_output local returned_value local info_failed local is_todo if echo "${directive}" | grep -q -i "TODO" ; then is_todo=" (TODO)" fi test_total=$((${test_total} + 1)) if echo "${directive}" | grep -q -i "skip" ; then : elif [ ${is_verbose} -eq 0 ] ; then cmd_output=$(eval "${cmd}" 2>&1) else cmd_output=$(eval "${cmd}" 2>&1 1>/dev/null) fi returned_value=$? if [ ${returned_value} -ne 0 ] ; then info_failed=$( printf "Failed%s test: ${test_name}\nreturned_value: ${returned_value}" \ "${is_todo}") fi echo_test_line ${returned_value} "${test_name}" "${directive}" if [ -n "${is_todo}" ] ; then returned_value=0 fi count_success_failure ${returned_value} if [ -n "${info_failed}" ] ; then ( if [ -n "${is_todo}" ] ; then exec 2>&1 fi newline_if_harness diag_unless_empty "${info_failed}" " " diag "${cmd}" " " diag "" diag_unless_empty "${cmd_output}" " " ) fi return ${returned_value} } skip() { # arg(cmd_str, test_name, directive_comment) $2 $3 is optional ok "$1" "$2" "skip $3" } todo() { # arg(cmd_str, test_name, directive_comment) $2 $3 is optional ok "$1" "$2" "TODO $3" } min2() { if [ $1 -le $2 ] ; then return $1 else return $2 fi } done_testing() { echo "1..${test_total}" if [ ${test_fail} -gt 0 -o ${is_verbose} -eq 0 ] ; then diag "FAILURE: ${test_fail}, success: ${test_succ}, total: ${test_total}" " " fi min2 ${test_fail} 254 } assert_succ() { if [ $? -ne 0 ] ; then echo "# exit in aasert_succ: $1" 1>&2 exit 1 fi } assert_fail() { if [ $? -eq 0 ] ; then echo "# exit in aasert_fail: $1" 1>&2 exit 1 fi }
testsimple.shに対するテスト
testsimple_test.sh
#!/bin/bash . "./testsimple.sh" test_reset() { test_total=0 test_succ=0 test_fail=0 } ok 'diag "alpha" 2>&1 |grep -q "^# alpha$"' "no indent arg" ok 'diag "beta" "" 2>&1 |grep -q "^# beta$"' "indent0" ok 'diag "gamma" " " 2>&1 |grep -q "^# gamma$"' "indent1" ok 'diag "delta" " " 2>&1 |grep -q "^# delta$"' "indent2" # In (cmd0; cmd1; ...) , cmd0 cmd1 ... is executed in sub shell. ok ' (true assert_succ) [ $? -eq 0 ]' "assert_succ" ok ' (false assert_succ true) [ $? -ne 0 ]' "assert_succ" ok ' (false assert_fail) [ $? -eq 0 ]' "assert_fail" ok ' (true assert_fail true) [ $? -ne 0 ]' "assert_fail" ok ' test_reset output=$(ok "[ 1 -eq 1 ]" "xxx 12 3") [ "${output}" = "ok 1 - xxx 12 3" ] ' "ok output" ok ' test_reset output=$(ok "[ 1 -eq 1 ]" "abc#abc") [ "${output}" = "ok 1 - abc\#abc" ] ' "ok output" ok ' test_reset output=$(ok "[ 2 -eq 3 ]" "xx YY zz") [ "${output}" = "not ok 1 - xx YY zz" ] ' "ok output" ok ' test_reset ok "[ 1 -eq 1 ]" "xxx" ok "[ 2 -eq 2 ]" "yyy" output=$(ok "[ 3 -eq 3 ]" "z z z") [ "${output}" = "ok 3 - z z z" ] ' "ok output" ok ' test_reset ok "[ 1 -eq 1 ]" "xxx" ok "[ 1 -eq 2 ]" "yyyyyy" output=$(ok "[ 1 -eq 3 ]" "zzz") [ "${output}" = "not ok 3 - zzz" ] ' "ok output" ok ' test_reset ok "[ 1 -eq 2 ]" "xxx a" ok "[ 2 -eq 2 ]" "xxx b" output=$(skip "this should be skipped" "zzz c") [ "${output}" = "ok 3 - zzz c # skip" ] ' "skip output" ok ' test_reset ok "[ 1 -eq 1 ]" "xxx" grep "^not ok 2 - yyy # TODO" <(todo "[ 1 -eq 2 ]" "yyy") ' "todo output" ok ' test_reset ok "[ 1 -eq 1 ]" "xxx" grep "^not ok 2 - yyy zzz # TODO www" <(todo "[ 1 -eq 2 ]" "yyy zzz" "www") ' "todo output" ok ' test_reset ok "[ 1 -eq 1 ]" "xxx" output=$(todo "[ 1 -eq 2 ]" "abc def") [[ "${output}" =~ "not ok 2 - abc def # TODO" ]] && [[ "${output}" =~ "# Failed (TODO) test:" ]] ' "todo output" ok ' test_reset ok "[ 1 -eq 1 ]" ok "[ 1 -eq 2 ]" todo "[ 1 -eq 3 ]" skip "[ 1 -eq 2 ]" output=$(done_testing) assert_fail [ "${output}" = "1..4" ] ' "done_testing output" ok ' test_reset ok "[ 1 -eq 1 ]" ok "[ 2 -eq 2 ]" err_out=$(done_testing 2>&1 >/dev/null) assert_succ [ "${VERBOSE:+def}" = "def" -o "${err_out}" = "" ] ' "done_testing error output" ok ' test_reset ok "[ 1 -eq 1 ]" ok "[ 1 -eq 2 ]" skip "[ 2 -eq 3 ]" todo "[ 3 -eq 4 ]" err_out=$(done_testing 2>&1 >/dev/null) [ "${err_out}" = "# FAILURE: 1, success: 3, total: 4" ] ' "done_testing error output" ok ' test_reset output=$( ok "[ 1 -eq 1 ]" "abc " ok "[ 1 -eq 2 ]" "DEFG" skip "[ 2 -eq 3 ]" "HIJKL" "z123" todo "[ 3 -eq 4 ]" "MN" "y45x" done_testing) assert_fail [[ "${output}" =~ "ok 1 - abc" ]] && [[ "${output}" =~ "not ok 2 - DEFG" ]] && [[ "${output}" =~ "ok 3 - HIJKL # skip z123" ]] && [[ "${output}" =~ "not ok 4 - MN # TODO y45x" ]] && [[ "${output}" =~ "1..4" ]] ' "exit code and output" ok ' test_reset for i in $(seq 1 10) do ok "[ $i -eq 0 ]" done done_testing [ $? -eq 10 ] ' "exit code" skip ' test_reset for i in $(seq 1 260) do ok "[ $i -eq 0 ]" done done_testing [ $? -eq 254 ] ' "exit code 254" "slow test" done_testing