テストの仕方についてのメモ

プログラムを書くときにはテストを必ず書こうと思ってから、なかば予想したとおりではあるけどプログラムを全く書かなくなった(テストを書く方法として次のような話を見たことがある。printデバッグを一切しない。実行結果をprintで表示することによる動作確認をしない。代わりに動作を確認する関数やスクリプトを書く。まあ、これを実践する一番簡単な方法がプログラムを書かないことなのだけど)。

気分を改めて、テストをどうやるのかについて基本的なところからメモ。

ディレクトリの構成

ファイルの置く位置を気にしておかないと、ロードパスの指定やテストデータファイルの読み込みでハマる場合がある。

aProjectDirというディレクトリを起点にしてプログラムを書いている場合、次のような感じにすることが多いみたい。

aProjectDir
- test   # テストファイルを置く
- lib    # ライブラリプログラムを置く
# これ以外はいろいろ。実行可能なプログラムを置くディレクトリにしても、
# トップレベルだったりbinやcmdやtoolsなどだったり、
# プロジェクト名に基づく名前のディレクトリだったり。
  • テストファイルを置くディレクトリの名前はtest以外にもtestsだったりtだったりする。
  • Perlではtに統一されている。また実装者用テスト(コードの文法チェックのように実行環境に依存しないもの=使用者が改めてチェックする必要の無いテスト)はxtディレクトリに置くというルールもあるみたい。
  • 配布されているPerlのモジュールのほとんどにテストが付いていてソースコード検索サイトから手軽に読めるので、テストの書き方の参考になるかも。
  • 実行スクリプトのテストや単一のモジュール以上の粒度のテストは別のディレクトリにおいてあるプログラムがいくつかあった。他にもRubyRSpecで書かれたテストはspecディレクトリに置くなど、テストツールに応じてテストファイルディレクトリの名前が違う場合もある。

テストをするときに問題になるのは、どの場所でテストを実行するか。テストを実行する位置が変わると、テストしたいプログラムやモジュールのロードパスや、テストで使うデータファイルのパスの指定に影響が出るので。
だいたい三つの可能性がある。

  1. トップディレクト
  2. テストディレクトリのトップ
  3. 実行するテストファイルが置かれたディレクト

ただしテストディレクトリ以下が複数階層に分かれていない場合は、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」みたいなもので、テストで作成したファイルなどの後始末をするというものも。

各テストファイルの構成の仕方はいろいろ。

  • Perlでは、比較的短めのテストファイルをたくさん作るというやり方が多い感じ。proveコマンドで複数のテストファイルを気軽に実行できることが一因か。
  • モジュールごと、クラスごとに一つのテストファイルを対応させるもの。さらにテストディレクトリの階層をクラス階層に対応させるものも。

スクリプトのテスト

ライブラリや大きめのプログラムを書くことよりも小さなスクリプトを書くが多いけど、そういう場合にもテストを書きたい。

  1. モジュールの場合と同じように、(同じ言語による)テストファイルを用意してテストする。
  2. スクリプト内部に自分自身のテストも含めて書いておく。
  3. シェルスクリプトでテストを用意する。

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