ShellScript
Bash
TDD
shUnit2

シェルスクリプトにxUnitを使ってみる

Shellscrpitでもユニットテスト

Shell Script Advent Calendar 2017 16日目です。
シェルのユニットテストを通して、自動 rm -rf / を防ぎましょう。

シェルスクリプトテスト方法あれこれ

シェルスクリプトはその手軽さからちょちょっと書いてちょちょっと実行、なんてのが多いと思います。
でもそんなシェルスクリプトであっても、

「商用リリースするからテストしないと」
「危ない操作もあるからちゃんと動くか不安」
「ごりごりの複雑なロジックだから何かが起きそう」

なんて場面はあるはず。私はありました。

そうすると機能テストをする流れになると思いますが、ざっと確認してみたところ、以下の方法が考えられそう。

方法 説明
bash -xv 手で一つずつたたいて確認。、昔ながら。
テストのためのシェル テスト対象を実行するシェルを実装する。
BASH Debugger C言語デバッガ(cdb)ライクのデバッグツール
shUnit2 xUnit形式の自動テストフレームワーク
shUnit2 + shcov/kcov shUnit2に加えてshcovまたはkcovを使ってコードカバレッジも計測
Bats TAP形式の自動テストフレームワーク

今回はタイトルの通り、shUnit2について取り上げたいと思います。Batsは気になるから別の機会に。

shunit2とは

shUnit2は前項でも書いたとおり、シェルスクリプト向け自動テストフレームワークです。
もともとはシェルスクリプト版Log4jとなるLog4shのテストのために開発されたものだそうで。

bashだけではなく、bsh、ksh、zshなどにも対応しています。

導入してみよう

導入は2通りあります:

  1. v2.1.6のリリースをダウンロード・展開して使用する。
  2. gitリポジトリをクローンして使用する。

テストに使用する、ということもあり、v2.1.6のリリースを使用しようと思います。
ちなみにgitクローンを利用すると、執筆当時はv2.1.7preを導入できます。

インストール

  1. Githubよりリリースファイルをダウンロード(v2.1.6と表示されているやつ)します。【ダウンロード先】
  2. tarballを展開します。
  3. 展開ファイルの中のファイル ./source/2.1/src/shunit2 をが実行ファイルです。

要は展開して終わりです。必要に応じてPATHを通したり、shunit2のファイルを /usr/local/bin に複製するなりしてください。

使ってみよう

準備が整いました。実際にテストをしてみましょう。

サンプルで使用する実装と仕様

サンプルとして取り扱うディレクトリ構成、シェルスクリプト、テストコードの構成を示します。
flk_testという機能をテストしていきます。

flk_test機能は以下の仕様からなります。

  • 2つの値を受け取る。
  • 引き算と割り算の結果を表示する。
  • 引き算として計算が不可能である場合はその旨を出力する。
  • 割り算として計算が不可能である場合はその旨を出力する。
.
├── src
│   ├── flk_test.func
│   └── flk_test.sh
└── test_flk_test.shunit2

src配下にあるのがテスト対象の実装、 test_flk_test.shunit2 は今回実装するテストコードです。

flk_test.sh
#!/bin/bash

# モジュールロード
. ${APPROOT}/src/flk_test.func

# 実行
flk_test_main "$1" "$2"

flk_test.sh はブートローダ的な役割のコードです。flk_test.funcをロードして、flk_test_main関数を呼び出すだけです。

flk_test.func
#!/bin/bash

#
# flk_testのエントリポイント
#
function flk_test_main() {
    local valA=$1
    local valB=$2

    local minusResult=$(flk_test_minus "${valA}" "${valB}")
    local divideResult=$(flk_test_divide "${valA}" "${valB}")

    if [[ -n "${minusResult}" ]]; then
        echo "引き算の結果は${minusResult}です"
    else
        echo "引き算の結果は不正です"
    fi
    if [[ -n "${divideResult}" ]]; then
        echo "割り算の結果は${divideResult}です"
    else
        echo "割り算の結果は不正です"
    fi
}

#
# 引き算をする
#
function flk_test_minus() {
    local valA=$1
    local valB=$2
    echo $(expr ${valA} - ${valB} 2> /dev/null)
}

#
# 割り算算をする
#
function flk_test_divide() {
    local valA=$1
    local valB=$2
    echo $(expr ${valA} / ${valB} 2> /dev/null)
}

flk_test.funcflk_test 機能の核となるファイルです。main関数である flk_test_main 関数、引き算をする flk_test_minus 、割り算をする flk_test_divide が実装されています。

テストコードを書いてみよう

では flk_test 機能に対してテストコードを書いていきますが、まずは固有のテストコードを含まないソースを見て、shUnit2が提供する関数を確認しましょう。

#!/bin/bash

readonly TESTEE='XXXXXX'

# 全てのテストに対する共通の環境を準備する
function oneTimeSetUp() {

    cat <<__EOC__
--------------------------------------------------------------------------------
shUnit2 開始  バージョン:  ${SHUNIT_VERSION}   一時領域:   ${SHUNIT_TMPDIR}
テスト対象:  ${TESTEE}
--------------------------------------------------------------------------------
__EOC__

}

# 全てのテストが完了した後、環境をきれいにする
function oneTimeTearDown() {
    cat <<__EOC__
--------------------------------------------------------------------------------
shUnit2 終了
--------------------------------------------------------------------------------
__EOC__

}


# 各テストの前に環境を再設定する
function setUp() {
    :
}

# 各テストの後に環境をきれいにする
function tearDown() {
    :
}

function suite() {
    suite_addTest testFunc
}


# テスト関数

function testFunc() {
    assertTrue "0"
}


# shUnit2のロード
. shunit2

原則的にそれぞれの関数はJUnitと同じものです。

oneTimeSetUp 関数はテストコード実行時に最初の一回呼び出されるものです。テスト前の環境整備や、テスト出力のヘッダを表示させるのに使用できます。
oneTimeTearDown関数はテストコード実行時に最後に一回呼びだされます。テスト終了後の環境リフレッシュ、テスト出力のフッタを表示させるのに使用できます。
setUp 関数と tearDown 関数は、テスト関数のそれぞれ前と後に実行される処理を記述します。

肝心のテスト関数、実際に機能テストを行う処理は2通りの書き方があります。

  • テスト関数のテスト名をtestで始める。testA、testBと記述するとshUnit2が自動的にテスト関数と判断します。
  • suite 関数の使用。 suite 関数の中で suite_addTest 関数を使用してテスト関数とするテスト関数名を定義します。

そして最後の行にある . shunit2 でshUnit2をロードし、テストを開始します。

今回は、oneTimeSetUpとoneTimeTearDownでヘッダ・フッタを出力し、oneTimeSetUpでは flk_test.funcを読み込ませます。

# 全てのテストに対する共通の環境を準備する
function oneTimeSetUp() {

    cat <<__EOC__
--------------------------------------------------------------------------------
shUnit2 開始  バージョン:  ${SHUNIT_VERSION}   一時領域:   ${SHUNIT_TMPDIR}
テスト対象:  ${TESTEE}
--------------------------------------------------------------------------------
__EOC__

    . ./src/flk_test.func
}

# 全てのテストが完了した後、環境をきれいにする
function oneTimeTearDown() {
    cat <<__EOC__
--------------------------------------------------------------------------------
shUnit2 終了
--------------------------------------------------------------------------------
__EOC__

}

テスト関数はボトムアップで

テストコードの下準備が済んだので、テスト関数を実装していきます。
flk_test.func に定義されている関数は3つ。

  • flk_test_main
  • flk_test_minus
  • flk_test_divide

ここでそれぞれの関数の依存関係を考慮します。依存性の小さい関数からテストをすることで、依存されている側(flt_testに於いては fla_test_main)の確認観点を絞ることができるためです。

整理すると、テスト観点はざっくり以下の通りになります。

  • flk_test_main
    • 引き算が成功した時のメッセージが出力されること
    • 割り算が成功した時のメッセージが出力されること
    • 引き算が失敗した時のメッセージが出力されること
    • 割り算が失敗した時のメッセージが出力されること
  • flk_test_minus
    • 引き算が成功すること
    • 引き算が失敗した時、NULLとなること
  • flk_test_divide
    • 割り算が成功すること
    • 割り算が失敗した時、NULLとなること

一部をかいつまんで、テスト関数の作りについて説明します。

function 引き算が成功すること() {
    local val_1="1"
    local val_2="3"
    local expected="-2"

    local result=$(flk_test_minus ${val_1} ${val_2})

    assertEquals "計算値誤り" "${expected}" "${result}"
}

function 引き算が失敗した時NULLとなること() {
    # 数値でなく文字が渡されるパターン
    local val_1="あ"
    local val_2="3"
    local result=0

    result=$(flk_test_minus ${val_1} ${val_2})
    assertNull "Nullでない" "${result}"

    result=$(flk_test_minus ${val_2} ${val_1})
    assertNull "Nullでない" "${result}"
}

引き算関数のテスト関数です。ポイントは2つです。

  1. 関数名を確認観点にする
  2. assertEqualとassertNull

関数名を確認観点にすると、実際にテストを実行した時の見た目で何を確認したのが分かるようになります。

shunittest$ ./test_flk_test.shunit2
--------------------------------------------------------------------------------
shUnit2 開始  バージョン:  2.1.6   一時領域:   /tmp/shunit.RhWiR1/tmp
テスト対象:  flk_test
--------------------------------------------------------------------------------
引き算が成功すること
引き算が失敗した時NULLとなること
割り算が成功すること
割り算が失敗した時NULLとなること
引き算が成功した時のメッセージが出力されること
割り算が成功した時のメッセージが出力されること
引き算が失敗した時のメッセージが出力されること
割り算が失敗した時のメッセージが出力されること
ローダシェルから呼び出せること
--------------------------------------------------------------------------------
shUnit2 終了
--------------------------------------------------------------------------------

Ran 9 tests.

OK
shunittest$

assertEqualsassertNull はshUnit2で用意されているアサーション関数です。こちらは他にもいろいろと関数があるのでマニュアルを参照してもらえればと思いますが、概ねJUnitと同じ考えです。

assertEquals <<アサーション失敗時のメッセージ>> <<想定値>> <<実際の値>>
assertNull <<アサーション失敗時のメッセージ>> <<実際の値>>

<<アサーション失敗時のメッセージ>>はアサーションが失敗した時に標準出力に出力されるので、被疑箇所の特定がしやすくなります。

--------------------------------------------------------------------------------
shUnit2 開始  バージョン:  2.1.6   一時領域:   /tmp/shunit.OOwg7d/tmp
テスト対象:  flk_test
--------------------------------------------------------------------------------
引き算が成功すること
引き算が失敗した時NULLとなること
割り算が成功すること
割り算が失敗した時NULLとなること
ASSERT:【文字が引数】Nullでない
ASSERT:【文字が引数】Nullでない
引き算が成功した時のメッセージが出力されること
割り算が成功した時のメッセージが出力されること
引き算が失敗した時のメッセージが出力されること
割り算が失敗した時のメッセージが出力されること
ローダシェルから呼び出せること
--------------------------------------------------------------------------------
shUnit2 終了
--------------------------------------------------------------------------------

Ran 9 tests.

FAILED (failures=2)

ASSERT:【文字が引数】Nullでない
ASSERT:【文字が引数】Nullでない

この通り出力します。

完成形テストコード

今回実装したテストコードの全量はこんなところです。

test_flk_test.shunit2
#!/bin/bash

readonly TESTEE='flk_test'

# 全てのテストに対する共通の環境を準備する
function oneTimeSetUp() {

    cat <<__EOC__
--------------------------------------------------------------------------------
shUnit2 開始  バージョン:  ${SHUNIT_VERSION}   一時領域:   ${SHUNIT_TMPDIR}
テスト対象:  ${TESTEE}
--------------------------------------------------------------------------------
__EOC__

    . ./src/flk_test.func
}

# 全てのテストが完了した後、環境をきれいにする
function oneTimeTearDown() {
    cat <<__EOC__
--------------------------------------------------------------------------------
shUnit2 終了
--------------------------------------------------------------------------------
__EOC__

}


# 各テストの前に環境を再設定する
function setUp() {
    :
}

# 各テストの後に環境をきれいにする
function tearDown() {
    :
}

function suite() {
    suite_addTest 引き算が成功すること
    suite_addTest 引き算が失敗した時NULLとなること
    suite_addTest 割り算が成功すること
    suite_addTest 割り算が失敗した時NULLとなること
    suite_addTest 引き算が成功した時のメッセージが出力されること
    suite_addTest 割り算が成功した時のメッセージが出力されること
    suite_addTest 引き算が失敗した時のメッセージが出力されること
    suite_addTest 割り算が失敗した時のメッセージが出力されること
    suite_addTest ローダシェルから呼び出せること

}


# テスト関数

function testFunc() {
    assertTrue "0"
}

function 引き算が成功すること() {
    local val_1="1"
    local val_2="3"
    local expected="-2"

    local result=$(flk_test_minus ${val_1} ${val_2})

    assertEquals "計算値誤り" "${expected}" "${result}"
}

function 引き算が失敗した時NULLとなること() {
    # 数値でなく文字が渡されるパターン
    local val_1="あ"
    local val_2="3"
    local result=0

    result=$(flk_test_minus ${val_1} ${val_2})
    assertNull "Nullでない" "${result}"

    result=$(flk_test_minus ${val_2} ${val_1})
    assertNull "Nullでない" "${result}"
}

function 割り算が成功すること() {
    local val_1="4"
    local val_2="2"
    local expected="2"

    local result=$(flk_test_divide ${val_1} ${val_2})

    assertEquals "計算値誤り" "${expected}" "${result}"
}

function 割り算が失敗した時NULLとなること() {
    # 数値でなく文字が渡されるパターン
    local val_1="あ"
    local val_2="3"
    local result=0

    result=$(flk_test_divide ${val_1} ${val_2})
    assertNull "【文字が引数】Nullでない" "${result}"

    result=$(flk_test_divide ${val_2} ${val_1})
    assertNull "【文字が引数】Nullでない" "${result}"

    # 0割
    val_1="3"
    val_2="0"
    result=$(flk_test_divide ${val_1} ${val_2})
    assertNull "【0割】Nullでない" "${result}"
}

function 引き算が成功した時のメッセージが出力されること() {
    local expected="引き算の結果は0です"
    local unexpected="引き算の結果は不正です"

    local result=$(flk_test_main 1 1)

    assertEquals "メッセージが出ていない" "1" "$(echo ${result} | grep ${expected} | wc -l)"
    assertEquals "メッセージが出てる" "0" "$(echo ${result} | grep ${unexpected} | wc -l)"
}

function 割り算が成功した時のメッセージが出力されること() {
    local expected="割り算の結果は1です"
    local unexpected="割り算の結果は不正です"

    local result=$(flk_test_main 1 1)

    assertEquals "メッセージが出ていない" "1" "$(echo ${result} | grep ${expected} | wc -l)"
    assertEquals "メッセージが出てる" "0" "$(echo ${result} | grep ${unexpected} | wc -l)"
}

function 引き算が失敗した時のメッセージが出力されること() {
    local expected="引き算の結果は不正です"
    local unexpected="引き算の結果は[.+-][0-9]+です"

    local result=$(flk_test_main あ 1)

    assertEquals "メッセージが出ていない" "1" "$(echo ${result} | grep ${expected} | wc -l)"
    assertEquals "メッセージが出てる" "0" "$(echo ${result} | grep -E ${unexpected} | wc -l)"
}

function 割り算が失敗した時のメッセージが出力されること() {
    local expected="割り算の結果は不正です"
    local unexpected="割り算の結果は[.+-][0-9]+です"

    local result=$(flk_test_main あ 1)

    assertEquals "メッセージが出ていない" "1" "$(echo ${result} | grep ${expected} | wc -l)"
    assertEquals "メッセージが出てる" "0" "$(echo ${result} | grep -E ${unexpected} | wc -l)"

}

function ローダシェルから呼び出せること() {
    local expected_1="引き算の結果は0です"
    local expected_2="割り算の結果は1です"

    local result=$(./src/flk_test.sh 1 1)

    assertEquals "メッセージが出ていない" "1" "$(echo ${result} | grep ${expected_1} | wc -l)"
    assertEquals "メッセージが出ていない" "1" "$(echo ${result} | grep ${expected_2} | wc -l)"
}



# shUnit2のロード
. shunit2

shUnit2を前提としたスクリプティングをしよう

シェルスクリプトは性質上、上から下に処理が流れてしまうため、shUnit2を使用する場合は以下の構成にするのがよいと思います。

  • 1つのシェル(機能)を実現するために、ローダ + ロジックの2本セットにする。
  • ローダはロジックのロードとメイン関数の呼び出し だけ を実装する。
  • ロジックには関数 だけ を実装する。
  • ロジックの中で徹底的に関数分割を行う。なるべく小さい関数を実装する。
  • 小さい関数に対してテストコードを用意することで、狭い範囲でのテストをできるようにする。

shUnit2はブラックボックステストになるので、ブラックボックスをなるべく小さくして、それぞれを検証していくスタイルのほうがテストの堅牢性を守りつつ、ミスの少なスクリプトが作れると思います。

関連リンク集

BASH Debugger
shunit2
shUnit2 2.1.x nドキュメント
shcov
kcov
Batsを使って手軽にCLIプログラムのテストをする