Bシェルのテストランナー

シェルでもちゃんとテストを記述したく、シェル版のxUnitにあたるようなものを書いた。ただアサーション、結果集計するだけでなく、JUnitライクな結果xmlを出力できるようにした。おかげでちょっと長いけどこれでHudson上でシェルテスト結果のレポート集計をすることができる。使うときにランナーが複数ファイルあると使いづらいのでとりあえずひとつにまとめた。

註:まとめてて気がついたけど、 command not foundがエラー扱いになってない。

使いかたは、JUnitと同様にテストファイルを用意してそれをランナーにかける。テストファイル

#!/bin/sh

test_hoge1() {
  foo=hoge
  echo foo : $foo
  assert_equals "hoge" $foo
}

test_hoge2() {
  bar=hoeg
  echo bar : $bar
  assert_equals "hoge" $bar
}

test_hoge3() {
  assert_dir /home/ihiroky
  hogehoge # 存在しない関数
}

に対してテストを実行するときはこんな感じ。

[ihiroky@puppypc:~]$ ./shunit.sh test_hoge.sh 
test script : 'test_hoge.sh'
 test case : 'test_hoge1'
 test case : 'test_hoge2'
  test_hoge2  NG.
  expected:hoge
  actual  :hoeg

 test case : 'test_hoge3'
done test. total : 3, OK : 2, NG : 1
テストランナー shunit.sh
#!/bin/sh
#
# test runner for sh.
# modify __FUNC into "declare -f" to execute on cygwin.
#
# $1 - test script to execute.
#
# $Id: shunit.sh,v 1.4 2009/04/23 06:29:17 ihiroky Exp $
#

test $# -ne 1  && echo "usage : `basename $0` <test script>" && exit 1
test ! -f "$1" && echo "$1 not found." && exit 2

#
# __FUNCはテスト関数を拾うために使う。solarisのshだとsetで拾えるけど、
# gnuのsh(bash)ではdeclare-fでないと拾えない。
# __TEST_OUT, __TEST_ERRはテスト実行時の標準出力、標準エラー出力を
# 取っておくためのファイル。
#
__FUNC="declare -f"
__TEST_OUT=./__shunit_stdout.log
__TEST_ERR=./__shunit_stderr.log
__ENCODE=EUC-JP

#
# OK/NG count.
#
__OK_COUNT=0
__NG_COUNT=0

#
# test report.
# テストの結果をJUnitのXML形式で出力するために利用する。
# __TC_OK, __TC_NGはそれぞれOK、NG時の出力テンプレート
#
__CASES=""
__RPTOUT=""
__RPTERR=""
__TC_OK='<testcase classname="_CN" name="_NM" time="_TM" />'
__TC_NG='<testcase classname="_CN" name="_NM" time="_TM">
  <error message="_MSG" type="_TYPE">_TRACE</error>
</testcase>'

#
# assertion.
# アサーション結果格納変数
#
__NG_FLAG=
__EXPECTED=
__ACTUAL=

#
# initialize variables associated with assertion.
#
__init_assert() {
  __NG_FLAG=0
  __EXPECTED=""
  __ACTUAL=""
}

#
# add test ok report.
#
__report_ok() {
 __CASES=`echo ${__CASES}; echo ${__TC_OK}`
 __CASES=`echo ${__CASES} | sed "s#_CN#$1#" | sed "s#_NM#$2#" | sed "s#_TM#$3#"`
 __RPTOUT=`echo ${__RPTOUT}; echo "$4"`
 __RPTERR=`echo ${__RPTERR}; echo "$5"`
}

#
# add test ng report.
#
__report_ng() {
 __CASES=`echo ${__CASES}; echo ${__TC_NG}`
 __CASES=`echo ${__CASES} | sed "s#_CN#$1#" | sed "s#_NM#$2#" | sed "s#_TM#$3#"`
 __CASES=`echo ${__CASES} | sed "s#_MSG#$4#" | sed "s#_TYPE#$5#"`
 __CASES=`echo ${__CASES} | sed "s#_TRACE#$6#"`
 __RPTOUT=`echo ${__RPTOUT}; echo "$7"`
 __RPTERR=`echo ${__RPTERR}; echo "$8"`
}

#
# output test report.
# __report_ok, __report_ng でため込んだ結果をXML形式でechoする。
# 1件もレポートされていなかったらなにもしない。
#
__report() {
  test -z "${__CASES}" && return
  echo "<?xml version=\"1.0\" encoding=\"${__ENCODE}\" ?>
<testsuite errors=\"${__NG_COUNT}\" failures=\"0\" hostname=\"`hostname`\" name=\" $1\" tests=\"`expr ${__OK_COUNT} + ${__NG_COUNT}`\" time=\"$2\" timestamp=\"`TZ=GMT date +%Y-%m-%dT%H:%M:%S`\">
  <properties>
    <property name=\"test.runner\" value=\"shunit\" />
  </properties>"
echo ${__CASES}
echo "  <system-out><![CDATA[${__RPTOUT}]]></system-out>
  <system-err><![CDATA[${__RPTERR}]]></system-err>
</testsuite>"
}

#
# update assertion variables.
#
__assert() {
  test $__NG_FLAG -eq 1 && return

  if test ${1} -ne 0; then
    __NG_FLAG=1
    __EXPECTED="${2}"
    __ACTUAL="${3}"
  fi
}

#
# assert equals.
# $1 - expected
# $2 - actual
#
assert_equals() {
  test ${#} -ne 2 && echo '*** invalid argument length '${#}', must be 2. ***'
  test "${1}" = "${2}"
  __assert ${?} "${1}" "${2}"
}

#
# assert if the file is normal file.
# $1 - file to check.
#
assert_file() {
  test ${#} -ne 1 && echo '*** invalid argument length '${#}', must be 1. ***'
  test -f "${1}"
  __assert ${?} "${1} exists." " not exist."
}

#
# assert if the file is not normal file.
# $1 - file to check.
#
assert_not_file() {
  test ${#} -ne 1 && echo '*** invalid argument length '${#}', must be 1. ***'
  test ! -f "${1}"
  __assert ${?} "${1} doesn't exist." " exists."
}

#
# assert if the file is directory.
# $1 - file to check
#
assert_dir() {
  test ${#} -ne 1 && echo '*** invalid argument length '${#}', must be 1. ***'
  test -d "${1}"
  __assert ${?} "${1} exists." " not exist."
}

#
# assert if the the file is not directory.
# $1 - file to check
#
assert_not_dir() {
  test ${#} -ne 1 && echo '*** invalid argument length '${#}', must be 1. ***'
  test ! -d "${1}"
  __assert ${?} "${1} doesn't exist." " exists."
}

#
# assert if the file is symlink.
# $1 - file to check
#
assert_link() {
  test ${#} -ne 1 && echo '*** invalid argument length '${#}', must be 1. ***'
  test -L "${1}"
  __assert ${?} "${1} exists." " not exist."
}

#
# assert if the file is not symlink.
# $1 - file to check
#
assert_not_link() {
  test ${#} -ne 1 && echo '*** invalid argument length '${#}', must be 1. ***'
  test ! -L "${1}"
  __assert ${?} "${1} doesn't exist." " exists."
}

#
# assert file contents.
# $1 - expected contents(text)
# $2 - acutual generated file
#
assert_contents() {
  test ${#} -ne 2 && echo '*** invalid argument length '${#}', must be 2. ***'
  if test -f ${2}; then
    __contents=`cat ${2}`
    test "${1}" = "${__contents}"
    __assert ${?} "${1}" "${__contents}"
  else
    test -z "${1}"
    __assert ${?} "${1}" ""
  fi
}


##
## main
##

## test suite script to execute.
#  . コマンドでテストスクリプトを読み込むとき、きちんとパス指定しないと
#  いけないので絶対パス指定じゃないときはカレントディレクトリからの相対パス
#  にする。
__TEST_SCRIPT="${1}"
__head=`echo ${__TEST_SCRIPT} | cut -c 1-1`
test ${__head} != "." -o ${__head} != "/" && __TEST_SCRIPT="./${__TEST_SCRIPT}"

. ${__TEST_SCRIPT}
__TEST_SCRIPT=`basename ${__TEST_SCRIPT}` # chop the dirname to display

## tests to execute.
#  . で読み込んだ関数から頭がtest出始まるものを収集
__TESTS=`${__FUNC} | egrep '^test.*\(\).*' | sed 's/\(test[^ (]*\).*/\1/'`

## check if before_shell, before, after_shell, after is defined.
#  テストスクリプト起動/終了時にに1回だけ呼ばれる関数、
#  テストメソッドの起動/終了時に毎回呼ばれる関数があるかチェック
__BEFORE_SHELL=`${__FUNC} | egrep '^before_shell *\(\)'`
__AFTER_SHELL=`${__FUNC} | egrep '^after_shell *\(\)'`
__BEFORE=`${__FUNC} | egrep '^before *\(\)'`
__AFTER=`${__FUNC} | egrep '^after *\(\)'`

## レポートに使うテスト名
## 末尾の.shを取ることで、Hudson集計時シェルスクリプト名がクラスの変わり
## になる。shunit.は集計時にグルーピングするための擬似的なパッケージ名
__CLASS=shunit.`basename ${__TEST_SCRIPT} .sh`

## テスト開始 
echo "test script : '"${__TEST_SCRIPT}"'"

## before_shell が定義されていれば実行
[ ! -z "${__BEFORE_SHELL}" ] && before_shell
for __t in ${__TESTS}
do
  ## アサーション変数初期化
  __init_assert

  ## before が定義されていれば実行
  test ! -z "${__BEFORE}" && before

  ## execute a test.
  echo " test case : '"${__t}"'"
  ${__t} 1>${__TEST_OUT} 2>${__TEST_ERR}

  ## assertion repot / count.
  if test ${__NG_FLAG} -eq 0; then
    ## OK のカウント、レポート
    ## テスト時間計測は未実装。gnuならdate +%sミリ秒がとれるはずなので
    ## そこから計算するとよいかも
    __OK_COUNT=`expr ${__OK_COUNT} + 1`
    __report_ok "${__CLASS}" "${__t}" "1.0" \
      "`cat ${__TEST_OUT}`" "`cat ${__TEST_ERR}`"
  else
    ## NG のカウント、レポート。テスト時間計測は同じく未実装
    __NG_COUNT=`expr ${__NG_COUNT} + 1`
    echo "  ${__t}  NG."
    echo "  expected:${__EXPECTED}"
    echo "  actual  :${__ACTUAL}"
    echo ""
    __report_ng "${__CLASS}" "${__t}" "1.0" \
      "expected:${__EXPECTED}, actual:${__ACTUAL}" "shunit.assert.error" "" \
      "`cat ${__TEST_OUT}`" "`cat ${__TEST_ERR}`"
  fi

  ## after が定義されていれば実行
  test ! -z "${__AFTER}" && after

  rm -f ${__TEST_OUT} ${__TEST_ERR}
done

## after_shell が定義されていれば実行
test ! -z "${__AFTER_SHELL}" && after_shell

## report.
__total=`expr ${__OK_COUNT} + ${__NG_COUNT}`
__report "${__CLASS}" "${__total}.0" > __shunit_report.xml
mv __shunit_report.xml TEST-shunit.${__TEST_SCRIPT}.xml

## summary
echo "done test. total : ${__total}, OK : ${__OK_COUNT}, NG : ${__NG_COUNT}"
test ${__NG_COUNT} -eq 0

テスト結果のレポートファイルはこんな感じ。改行がつぶれてるけど許して。アサーション失敗はfailuresでカウントすべきなのかな。いまはerrorsでカウントされている。

TEST-shunit.test_hoge.sh.xml
<?xml version="1.0" encoding="EUC-JP" ?>
<testsuite errors="1" failures="0" hostname="puppypc" name=" shunit.test_hoge" tests="3" time="3.0" timestamp="2009-04-23T16:07:02">
  <properties>
    <property name="test.runner" value="shunit" />
  </properties>
<testcase classname="shunit.test_hoge" name="test_hoge1" time="1.0" /> <testcase classname="shunit.test_hoge" name="test_hoge2" time="1.0"> <error message="expected:hoge, actual:hoeg" type="shunit.assert.error"></error> </testcase> <testcase classname="shunit.test_hoge" name="test_hoge3" time="1.0" />
  <system-out><![CDATA[foo : hoge bar : hoeg]]></system-out>
  <system-err><![CDATA[
./test_hoge.sh: line 17: hogehoge: command not found]]></system-err>
</testsuite>