Baut

 

はじめに(Summary)

Baut(Bash Unit test Tool)は、Bashで書かれたユニットテストツールです。シェルスクリプトだけでなくUnix/Linuxプログラムが期待通りの動作をするかを検証するのに有用です。

Bautが実行するテストプログラムは、単なるBashスクリプトです。特別なエディターやエディターモードも必要ありません。使い慣れたいつもの環境で簡単にテストを記述することができます。

Bautのテストプログラムは、以下のようになります。他のテストフレームワークと類似しています。

#: @BeforeEach
function setup() {
  echo "==> $(self)"
  export PATH=/usr/local/bin:"$PATH"
  touch flagfile
}

#: @Test(The usage should be displayed when command line options are invalid)
function parse_cli_options() {
  echo "==> $(self)"
  run ./myshell.sh
  [[ "$result" =~ usage: ]]
}

#: @Test
#: @Ignore
function this_test_is_ignored() {
  echo "This test is ignored"
}

#: @AfterEach
function teardown() {
  echo "==> $(self)"
  /bin/rm flagfile
}

#:で始まる行は、続く記号に何らかの意味を持つ記述が来ることを示すメタコメント行です。メタコメント行には、@で始まる識別コードを記述します。@BeforeEach@AfterEachはそれぞれあるテスト開始前と終了時に呼ばれる関数です。1テストごとに呼ばれます。@Test(...)は、関数がテスト対象であることを示します。変わりに「test_*」にマッチする関数名であれば、@Testがなくてもテスト対象とみなします。functionキーワードはなくても問題ありません。@Ignoreが指定されたテストは無視されます。テスト対象に含まれないので、テストとしてカウントされません。

コマンドの実行が正しいか否かを確認するコードは通常のシェルの条件式です。set -eと同様にエラーを検出します。しかし、BautではERRトラップを使ってエラーを捕捉しているのでテスト自体はすぐに終了となりません。$resultは、runコマンドで実行したコマンドの標準出力及び標準エラー出力の結果が格納されています。

上記を実行した結果は以下のようになります。

1 file, 1 test
[1] /Users/guest/test/test_sample.sh
x The usage should be displayed when command line options are invalid
  ==> setup
  ==> parse_cli_options
  # Error(127) detected at the following:
  #       6 }
  #       7 
  #       8 #: @Test(The usage should be displayed when command line options are invalid)
  #       9 function parse_cli_options() {
  #      10   echo "==> $(self)"
  #>     11    run ./myshell.sh
  #      12   [[ "$result" =~ usage: ]]
  #      13 }
  #      14 
  ==> teardown
1 test, 0 ok, 1 failed, 0 skipped

?  1 file, 1 test, 0 ok, 1 failed, 0 skipped
Time: 0 hour, 0 minute, 0 second

分かりやすいように各関数の始めにechoで関数名を出力しました。上記では、テストファイルの11行目のrunコマンドでエラーになったことを示しています。テスト明細行には、1つのtestを実行し1つが失敗したことを示しています。最終の2行は、テスト全体をとしての明細です。Timeはテスト実行にかかった時間です。

インストール(Install)

インストールは特別な処理は必要ありません。githubからcloneするだけですぐに使用可能です。BautはBash4.2以上が必要です。

$ git clone https://github.com/moritoru81/baut.git
$ source install.sh

install.shは、git cloneしたbautの実行ディレクトリにパスを設定します。実行しなくても問題ありません。

ダウンロード後、以下のコマンドを実行するとhelpが表示されます。

$ ./bin/baut -h
Usage: baut [-v] [-h] [--d[0-4]] [run|<command>] [<args>]

OPTIONS
  -v, --version       Show version.
  -h, --help          Show usage.
  --d[0-4]            Set log level to TRACE(0), DEBUG(1), INFO(2), WARN(3), ERROR(4)

COMMANDS
  compile    Compile a test script file.
  run        Run tests.
  test       Run tests in a file and print its result.
             Ordinally this command is called in 'run' command.

Show more available information about a specific command.
'baut <command> [-h|--help]'

使用方法(Usage)

runコマンド

テスト実行は、テストのためのシェルスクリプトを書きrunコマンドで実行するだけです。Bautは、指定されたテストファイルを読み、テスト実行のための準備を行なった後、テストファイルの各テストを実行していきます。

$ tree test/
test/
└── test_sample.sh

$ cat test/test_sample.sh  
#: @Test(The usage should be displayed when command line options are invalid)
function parse_cli_options() {
  run echo "usage: baut"
  [[ "$result" =~ ^usage:[[:space:]]baut$ ]]
}

なお、テストファイルは以下の規約に従う必要があります。

  • test_*.shにマッチする。

テストの準備ができたらrunコマンドで実行します。

$ baut run test/test_sample.sh 
1 file, 1 test
[1] /Users/guest/workspace/baut/test/test_sample.sh
o The usage should be displayed when command line options are invalid
1 test, 1 ok, 0 failed, 0 skipped

?  1 file, 1 test, 1 ok, 0 failed, 0 skipped
Time: 0 hour, 0 minute, 0 second

1つのテストファイル、1つのテストを実行し成功しました。

オプション

–stop-on-error

テストで失敗した場合、残りのテストを実行せずテストを終了します。

–dry-run

テストで実行される関数を表示します。テストは実行されません。

–no-color

出力カラーをoffにします。

–no-checksum

コンパイル時と実行時におけるチェックサムの比較検証をスキップします。

–format [oneline|default|tap|cat]

出力フォーマットを指定します。

  • onelineは、テスト名と結果を1行で出力します。テスト内での出力は破棄されます。
  • defaultは、デフォルト動作です。テスト内の出力もそのまま表示します。
  • tapは、tapプロトコルに沿った出力形式です。
  • catは、テスト実行時の加工のない出力です。主にデバッグ時にしか使用しません。

compileコマンド

通常、runコマンドから呼ばれるため明示的に呼ぶ機会はありません。Bautがテスト実行時に読み込むスクリプトを出力します。

testコマンド

通常、runコマンドから呼ばれるため明示的に呼ぶ機会はありません。compileされたファイルを引数にとりテストを実行します。

テストを書く(Writing Scripts)

テストファイルは、単なるBashスクリプトであることは先ほど説明の通りです。ここでは、テストの記述ルールについて説明します。

テストファイル(Test File)

テストファイル名は、test_で始まり、拡張子は.shである必要があります。拡張子が.shであるのは、通常のエディタにおいて特別な設定が不要なことを意味します。ファイルを開けば、シェルモードでインデントや補完などを使ってすぐにテストを記述することができます。

ファイル階層(Directory Structure)

テストファイルは、任意のディレクトリに設置可能です。ディレクトリの階層が深くても問題ありません。テストの種類別にディレクトリを分けてテストファイルを格納することができます。

$ tree .
.
├── command
├── database
├── options
│   └── test_options.sh
└── test_sample.sh

3 directories, 2 files

runコマンドで-rオプションをつけることで、再帰的にテストファイルを探索します。

$ baut run -r .
2 files, 2 tests
[1] /Users/guest/workspace/baut/test/options/test_options.sh
o test_help_options
1 test, 1 ok, 0 failed, 0 skipped
[2] /Users/guest/workspace/baut/test/test_sample.sh
o The usage should be displayed when command line options are invalid
1 test, 1 ok, 0 failed, 0 skipped

?  2 files, 2 tests, 2 ok, 0 failed, 0 skipped
Time: 0 hour, 0 minute, 1 second

1番後のファイルはtest_options.shであり1つのテストを実行しテストが成功したことを示しています。続いて、2番目のテストファイルはtest_sample.shであり1つのテストを実行しテストが成功したことを示しています。

最後に、2つのファイルで合計2つのテストを実行し2つともテストにパスしたことを示しています。テスト実行には1secの時間がかかったとなっています。

テスト(Test)

関数(Function)

テストは、test_で始まる、または後述するAnnotationを使って指定します。テストは、テストファイルに書かれている順に実行されます。

functionキーワードはなくても問題ありません。以下は全てテスト対象となります。

#: @Test
mytest() {
  echo "This is a test with Test annotation"
}

test_func() {
  echo "This is a test of the name which starts with 'test_'"
}

function test_func2() {
  echo "This is a test of the name which starts with 'test_'"
}

アノテーション(Annotation)

テスト関数の先頭に付与することができます。テストに関する補足情報を@に続く識別子で指定します。

@BeforeAll

すべてのテストに先立って実行される処理に指定します。

@BeforeEach

各テストの最初に実行される処理に指定します。

@Test[(…)]

テストに指定します。(…)がある場合は、テスト結果に表示されるテスト名が...の部分で置換されます。

@Ignore

テストを無視します。

@Deprecated[(…)]

テストが非推奨であることを示します。テストの実行結果でDeprecatedという識別子が付加されます。

@TODO[(…)]

テストがTODOテストであることを示します。TAP用に用意されたものです。

@AfterEach

各テストの終了後に呼ばれます。テストに失敗しても実行されます。

@AfterAll

すべてのテストの最後に呼ばれます。

コマンドの実行

run <command>

外部コマンドやスクリプトの実行は、runコマンドを利用できます。runコマンドの実行結果(標準出力とエラー出力)は、$resultという変数に格納されます。また終了ステータスは、$statusという変数で参照可能です。また、出力の各行は$linesという変数で配列として参照できます。$linesには、出力が改行(\n)区切りで格納されています。

#: @Test
function test_run() {
  run echo "usage: baut"
  [ $status -eq 0 ]
  [[ "$result" =~ ^usage:[[:space:]]baut$ ]]
}

run2 <command>

こちらもrunと同様です。run2は、標準出力とエラー出力がそれぞれ別々の変数で参照できます。変数は、$stdout$stderrです。また各行は、$stdout_lines$stderr_linesで参照できます。

#: @Test
function test_run2() {
  run2 echo "usage: baut"
  [ $status -eq 0 ]
  [[ "$stdout" =~ ^usage:[[:space:]]baut$ ]]
}

eval2 <command>

コマンドをevalで実行します。run2と同様に標準出力とエラー出力結果を別々の変数で参照することができます。

#: @Test
function test_eval2() {
  eval2 'echo "usage: baut" >&2'
  [ $status -eq 0 ]
  [[ "${stderr_lines[0]}" =~ ^usage:[[:space:]]baut$ ]]
}

テストのスキップ

skip [<message>]

関数の処理内で以降の処理をスキップする場合に使用します。以下では、最終行のechoは実行されません。

#: @Test
function test_skip() {
  eval2 'echo "usage: baut" >&2'
  skip "bye bye"
  echo "this message does not be printed"
}

実行すると以下のようになります。

$ baut   run test_sample2.sh 
1 file, 4 tests
[1] /Users/guest/workspace/baut/test/test_sample2.sh
o test_run
o test_run2
o test_eval2
~ test_skip # skip: bye bye
4 tests, 3 ok, 0 failed, 1 skipped

?  1 file, 4 tests, 3 ok, 0 failed, 1 skipped
Time: 0 hour, 0 minute, 0 second

テストの失敗

fail [<message>]

終了ステータスコード1でそのテストを終了します。

function test_fail() {
  fail "not implementation"
}

ファイルのロード

load <file> [<arg>…]

ファイルをsourceコマンドでインクルードします。一度読み込まれたファイルも再度ロード可能です。ファイルが存在しない場合は、エラー終了(exit)します。

load_if_exists <file> [<arg> …]

ファイルをsourceコマンドでインクルードします。一度読み込まれたファイルも再度ロード可能です。ファイルが存在しない場合は、終了ステータスコード以外を返します。exitしません。

require <file> [<arg> …]

ファイルをsourceコマンドでインクルードします。一度読み込まれたファイルは、重複ロードされません。ファイルが存在しない場合は、エラー終了(exit)します。

その他API

ヘルパー(Helpers)

追加機能のことを指します。

diff-helper

run_diffrun_diffxといったコマンドを追加します。runコマンドでは、通常、コマンドの実行結果を$result$lines$statusといった変数を用いて期待結果を比較しますが、このヘルパーではコマンドの実行結果をファイルに出力し、あらかじめ作成しておいた期待結果ファイルと比較を行ないます。

以下は、PostgreSQLにおけるリグレッションテストを模したテストになります。run_diffxは、コマンド実行結果で差分があれば、そこでテストがエラーとなります。run_diffは、テストはエラーとならず継続します。diffに失敗したか否かは$statusで確認できます。

load "diff-helper"

#: @BeforeEach
setup() {
  export PGDATABASE=postgres
  psql -c "create table foo (id int, name text);"
  psql -c "insert into foo (id, name) values (1, 'bar');"
}

test_diff() {
  run_diffx psql -c "SELECT id FROM foo WHERE name = 'bar';"
}

#: @AfterEach
teardown() {
 psql -c "drop table foo;"
}

ここでテストファイルが格納されるディレクトリとして、resultsexpectedというディレクトリが作成されます。expectedには、テスト関数名+.outという名前で期待結果ファイルを作成します。

$ tree expected results/
expected
└── test_diff.out
results/
└── test_diff.out

0 directories, 2 files

差分が検出された場合は、テストエラーになります。続いて、resultsディレクトリ下に.diffファイルが作成されます。差分を確認して誤りを修正します。

内部構造(Baut Internal)

run〜レポート出力(From run to print report)

Bautはrunコマンドが実行されると、引数で指定されたテストファイルを読み込み、テストで実行される関数情報を収集します(compile)。この時、Annotationなどの情報を解釈します。続いて、読み込んだ内容からテスト実行ファイル(.bautファイル)を生成します。テスト実行ファイルは、Bautが一連のテストを実行するのに必要な構造で出力します。そして、このファイルもまた単なるBashスクリプトです。このテスト実行ファイルは、runコマンド実行時に一時ディレクトリに出力されテストの終了時に削除されます。

テスト実行における出力は、すべてレポートのためのスクリプトにパイプで送信されます。レポートスクリプトは、送信されてきた内容を解釈し、テストレポートを構築します。

実際にどのような形の実行ファイルが生成されているかを確認するには、compileコマンドを実行します。生成される結果は標準出力に書き出されます。以下出力例になります。

$ baut compile test_sample.sh
#!/usr/bin/env bash
# This source was generated by Baut 0.1.0-beta at 2017-09-27 22:32:54
#:@filepath=/Users/guest/workspace/baut/test/test_sample.sh
#:@checksum=79c5fd255e186952f9434f2826319604
#:@testcount=1
readonly BAUT_TEST_FILE="/Users/guest/workspace/baut/test/test_sample.sh"
readonly BAUT_TEST_FUNCTIONS=(parse_cli_options)
readonly BAUT_LIBEXEC=${BAUT_LIBEXEC:-"/Users/guest/workspace/github/baut/libexec"}
[ -e "$BAUT_TEST_FILE" ] || abort "There is something wrong, file is not found: $BAUT_TEST_FILE"
source "$BAUT_TEST_FILE" || abort "There is something wrong, failed to source '$BAUT_TEST_FILE'"
require "baut--test-behavior" && load_if_exists "local--test-behavior" ||:
baut_run_test_suit() {
  declare -ar before_all_functions=()
  declare -ar before_each_functions=()
  declare -ar after_all_functions=()
  declare -ar after_each_functions=()
  local BAUT_TEST_FUNCTION_NAME=
  baut_before_all
  # => 1: parse_cli_options
  BAUT_TEST_FUNCTION_NAME='parse_cli_options'
  (readonly BAUT_TEST_FUNCTION_NAME;baut_run_test "The usage should be displayed when command line options are invalid")
  BAUT_TEST_FUNCTION_NAME=
  baut_after_all
}
baut_start_test "baut_run_test_suit"

runコマンドでは、この生成された実行ファイルをsourceでインクルードし実行します。source "$BAUT_TEST_FILE"という行がありますが、この時点でテストファイルが読み込みされます。その後は、Bautがテストファイルに書かれた順番で逐次テストを実行していきます。

コンパイル及びテスト実行の単位は、ファイル単位です。テストファイルにおける環境変数やその他設定は、そのテストファイルのみで有効です。いずれのテストファイルでも共有で使用する設定がある場合は、共通設定ファイルとして切り出し、各種テストファイルの先頭でloadする必要があります。

レポートフォーマット(Report Format)

Bautでは、デフォルトでいつくかのレポートフォーマットを提供しています。独自でレポートフォーマットを作成したい場合は、以下のようにすることでカスタマイズ可能です。

  • ファイル名「local--test-report」を以下のディレクトリのいずれかに設置します。探索は、1から優先的に行われます。
    1. コマンドを実行したディレクトリ
    2. テスト実行ディレクトリ/helpers
    3. baut/libexec
    4. baut/helpers

例えば、出力行の先頭に時間を挿入したい場合は以下のようにします。

while IFS= read -r line; do
  echo "`date +'%Y-%m-%d %H:%M:%S'` $line"
done

レポートスクリプトは、baut-exec-testからの出力を標準入力として受け取りますので、上記ではループで1行ずつ取り出し出力しています。このままでは、レポートのメタ行と出力が混合された形となるので、レポートスクリプトでは、特別な意味を持つ行を解釈して処理する必要があります。

特別な意味を持つ行とは、通常以下のようなフォーマットとなります。

#:<CODE>;[<arg>][\t<arg>...]

例えば、テストの最初には以下のようなメッセージがレポートスクリプトに渡されます。

#:RDY;1  2

これは、READYの略で、「トータルで、1ファイル、2テストの実行を開始する」という意味となります。このメセージを処理し、正しくレポートを表示する責任はレポートスクリプトにあります。

振る舞い(Behavior)

テスト実行の振る舞いを記述しているのが、baut--test-behaviorになります。こちらは、テストの実行や終了、各種基本的なコマンド(runrun2など)を提供している機能になります。こちらも以下のようにすることでカスタマイズ可能です。

  • ファイル名「local--test-behavior」を以下のディレクトリのいずれかに設置します。探索は、1から優先的に行われます。
    1. コマンドを実行したディレクトリ
    2. テスト実行ディレクトリ/helpers
    3. baut/libexec
    4. baut/helpers

load--test-behaviorは、baut--test-behaviorの後にロードされます。例えば、skipで送られるメッセージは以下のようにオーバーライドしカスタマイズできます。

skip() {
  __baut_test_skip=1
  _baut_disable_debug_trap
  pop_setopt
  baut_after_each
  baut_report_code_skip "${FUNCNAME[1]}" "!!!Skip!!!${1:-}" # !!!Skip!!!を追加
  exit 0
}

ダウンロード

https://github.com/moritoru81/baut

Other Documentation

ライセンス(License)

MIT License

Copyright (c) 2017 haikikyou

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.