オンプレミスのインフラエンジニアをやっていると、運用スクリプトを作ることがあります。
監視やバックアップなどの運用を自動化するものです。
ぜひ自動化して運用で楽をしたいものですが、品質担保には思った以上の工数がかかってしまいます。
そこで私は***単体テストを実施したい!***と思ったのですが、JUnitのようにツールを入れれば行けるというわけでもなさそうでした。
対話型コマンドが絡むシェルのテストのやりづらさ
運用スクリプトの中には、実行したら必ず0を返すような何も工夫しなくても書けるスタブでコマンドを置き換えることでテスト出来る場合もありますが、DBのバックアップなどでスクリプト内でミドルウェア特有のコマンドをたたいているような場合、スタブ化するために少し工夫がいります。
ShellTestStarterの紹介
というわけで作りました。
無駄にREADMEもきれいにしてみました。
releaseからzipをダウンロードするもよし、git cloneするもよし、コードを除いて必要なところだけパクるもよし。好きに使ってください。
READMEにも書きましたが、Linuxのみで動作確認しています。確認環境はCentOS 7 ですので、RHEL系だとそのまま動くと思います。(Ubuntuでも多分動く)
Quick Start - サンプルの動かし方
Dockerでの動作が楽なので、
docker-compose run c_dev_env /bin/bash
としてコンテナを起動し、シェルに入ってからsample
に移動してsetup.sh
を実行します。
[root@<containerid> workdir]# cd sample/
[root@<containerid> sample]# ./setup.sh
INFO: prepare stub script
gcc -o template_communicate_stub template_communicate_stub.c
done prepare!
makeでのコンパイル、スタブスクリプトの作成が行われるので、試しにテストスクリプトを実行してみましょう。
test_sample_backup_script.sh
を実行します。
[root@<containerid> sample]# ./test_sample_backup_script.sh
begin sample backup script test
OK: test case 1
OK: test case 2
OK: test case 3
OK: test case 4
OK: test case 5
OK: test case 6
OK: test case 7
OK: test case 8
OK: test case 9
complete database backup test
全てOKとなっているので、全件テスト通過です。
完了したらsetup.sh clean
を実行することで、生成したファイルたちをクリーンアップできます。
[root@<containerid> sample]# ./setup.sh clean
INFO: clean all scripts generated by this script
INFO: remove sqlplus
INFO: remove sqlplus.dat
INFO: remove rman
INFO: remove rman.dat
INFO: remove db_check.sh
INFO: remove db_check.dat
INFO: all done!
テンプレートスクリプトの解説
テンプレートはsrc
の中にtemplate_
というプレフィックスで格納されています。
src
│ Makefile
│ template_communicate_stub.c
│ template_communicate_stub.dat
│ template_simple_stub.dat
│ template_simple_stub.sh
template_simple_stub.sh
シンプルなスタブシェルです。template_simple_stub.dat
に記載されている値を読み取って、そのまま返却します。
引数を与えて実行すると、それをそのままログに吐き出します。
#!/bin/bash
script_name=$(basename $0)
script_logname=test_$script_name.log
echo "run $script_name" >>$script_logname
if [ $# -eq 0 ]; then
echo "no argument" >>$script_logname
else
for arg in "$*"; do
echo "argument: $arg" >>$script_logname
done
fi
exit $(cat ${script_name%.*}.dat)
template_communicate_stub.c
対話式コマンド用のスタブです。同名の.dat
ファイルを読み込み最終的な返却値とします。
引数を与えた時はバッファとして読み込み、引数なしで実行した場合は対話モードで実行できます。
いずれにせよ、読み込んだ文字列はただそのまま吐き出すだけです。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
// Function to get the path of the execution command
// Only available on Linux
char *getfilepath()
{
static char buf[1024] = {"\0"};
readlink("/proc/self/exe", buf, sizeof(buf) - 1);
return buf;
}
int main(int argc, char *argv[])
{
// If the beginning of argv[0] (execution command) is ./, remove it.
char cmd_name[64];
sscanf(argv[0], "%s", &cmd_name);
char t[64];
if (cmd_name[0] == '.' && cmd_name[1] == '/')
{
// included
strncpy(t, cmd_name + 2, strlen(cmd_name) - 2);
t[strlen(cmd_name) - 2] = '\0';
}
else
{
// not included
strncpy(t, cmd_name, strlen(cmd_name));
t[strlen(cmd_name)] = '\0';
}
// Reading here documents
int i;
printf("run command: %s", t);
for (i = 1; i < argc; i++)
{
printf(" %s", argv[i]);
}
char buffer[256] = "";
printf("\n---heredoc recieved from here---\n");
// Loop part that accepts input from here documents and terminals
while (1)
{
if (scanf("%255[^\n]%*[^\n]", buffer) == EOF)
{
break;
}
// Exit the loop when exit is entered
if (strstr(buffer, "exit") != NULL)
{
break;
}
// Abnormal termination when a specific character is entered
if (strstr(buffer, "special keyword") != NULL)
{
return 1;
}
scanf("%*c");
printf("> %s\n", buffer);
}
printf("---heredoc end---\n");
// File reading
char fname[64];
sscanf(getfilepath(), "%s", &fname);
strcat(fname, ".dat");
FILE *fp;
fp = fopen(fname, "r");
// Get the value from the file and return it as it is
int ret;
while (fscanf(fp, "%d", &ret) != EOF)
{
}
fclose(fp);
return ret;
}
サンプルテストスクリプトの説明
サンプルとして、以下のようなスクリプトを用意しています。
src/sample/
sample_backup_script.sh
setup.sh
test_sample_backup_script.sh
sample_backup_script.sh
バックアップスクリプトのサンプルです。このサンプルスクリプトのテストを行う例でsetup.sh
とtest_sample_backup_script.sh
を作っています。
このスクリプトはOracleでのバックアップを模したものになっており、sqlplus
、rman
を使ってバックアップ処理を行い、db_check.sh
という架空のチェックスクリプトを実行する処理となっています。
サンプルですので、こちらを実行しても正しくバックアップが取れないでし、エラーハンドリングもかなりお粗末で実際はsqlplus
やrman
が出力するエラーを検知するように作った方が良いと思います。あくまで参考としてください。
#!/bin/bash
# Oracle backup script example
logname="sample_backup_script.log"
echo "INFO: sample backup script" >>$logname
# Check the startup status of the instance
sqlplus target / 2>&1 >/dev/null <<EOF
select instance_name,status from v\$instance;
EOF
ret=$?
if [ ! $ret -eq 0 ]; then
echo "ERROR: instance state incorrect" >>$logname
exit 100
fi
# Backup process
rman target / 2>&1 >/dev/null <<EOF
backup database;
EOF
ret=$?
if [ ! $ret -eq 0 ]; then
echo "ERROR: backup failure" >>$logname
exit 101
fi
# Check processing
./db_check.sh full
ret=$?
if [ ! $ret -eq 0 ]; then
echo "ERROR: check failed" >>$logname
exit 102
fi
echo "INFO: complete database backup" >>$logname
setup.sh
このsample_backup_script.sh
をテスト実行するために、必要なスタブを準備するスクリプトです。
setup.sh
は汎用的に作っていますので、変数宣言の部分だけ変えて流用いただいてもいいと思います。
#!/bin/bash
# variable declaration
array_communicate_stub=(sqlplus rman)
array_simple_stub=(db_check)
tmp_com_stub=template_communicate_stub
tmp_simple_stub=template_simple_stub
# mode to delete the script created by setup.sh
# execute only when clean is given as an argument
if [ "$1" == "clean" ]; then
echo "INFO: clean all scripts generated by this script"
# remove stubs based on template_communicate_stub
for item in ${array_communicate_stub[@]}; do
if [ -e ./${item} ]; then
rm ./${item}
echo "INFO: remove ${item}"
fi
if [ -e ./${item}.dat ]; then
rm ./${item}.dat
echo "INFO: remove ${item}.dat"
fi
done
# creating a stub based on template_simple_stub
for item in ${array_simple_stub[@]}; do
if [ -e ./${item}.sh ]; then
rm ./${item}.sh
echo "INFO: remove ${item}.sh"
fi
if [ -e ./${item}.dat ]; then
rm ./${item}.dat
echo "INFO: remove ${item}.dat"
fi
done
cd ..
rm ./${tmp_com_stub}
echo "INFO: all done!"
exit 0
fi
# If there are no arguments, do the following
# Describe the preparation process when executing the test
echo "INFO: prepare stub script"
if [ ! -e ../${tmp_com_stub} ]; then
current_dir=$(pwd)
cd ..
make
cd $current_dir
fi
# Creating a stub based on template_communicate_stub
for item in ${array_communicate_stub[@]}; do
cp -pr ../${tmp_com_stub} ./${item}
cp -pr ../${tmp_com_stub}.dat ./${item}.dat
done
# Creating a stub based on template_simple_stub
for item in ${array_simple_stub[@]}; do
cp -pr ../${tmp_simple_stub}.sh ./${item}.sh
cp -pr ../${tmp_simple_stub}.dat ./${item}.dat
done
echo "done prepare!"
変数は以下のようになっています
array_communicate_stub=(sqlplus rman) # template_communicate_stubをもとに作成するスタブのリスト
array_simple_stub=(db_check) # template_simple_stub.shをもとに作成するスタブのリスト
tmp_com_stub=template_communicate_stub # 対話式コマンドのスタブスクリプトテンプレート名
tmp_simple_stub=template_simple_stub # 単純なスタブスクリプトテンプレート名
test_sample_backup_script.sh
sample_backup_script.sh
のいわゆるテストドライバです。スタブコマンドの返却値を制御する各.dat
ファイルを書き換えながら、テストを実行⇒観点ごとに合否を判断しています。
その他注意点としてはexport PATH=./:$PATH
として、スタブコマンドにパスを通しています。もともとのパスよりも前にスタブコマンドにパスを通しておくことで、実際のコマンドではなくスタブコマンドの方を実行するようになります。
#!/bin/bash
# this is sample test script
logname="sample_backup_script.log"
# function that returns all stubs with 0
function allStateClear() {
echo 0 >./sqlplus.dat
echo 0 >./db_check.dat
echo 0 >./rman.dat
}
echo "begin sample backup script test"
export PATH=./:$PATH
# test case 1 : When completed, the return value must be 0
allStateClear
./sample_backup_script.sh
ret=$?
if [ $ret -eq 0 ]; then
echo "OK: test case 1"
else
echo "NG: test case 1 : [ret : ${ret}]"
fi
# test case 2 : INFO: complete database backup is output to the log
allStateClear
./sample_backup_script.sh
ret=$(tail -n 1 $logname | grep "INFO: complete database backup" | wc -l)
if [ $ret -eq 1 ]; then
echo "OK: test case 2"
else
echo "NG: test case 2"
fi
# test case 3 : The argument full is passed to db_check
allStateClear
./sample_backup_script.sh
ret=$(tail -n 2 ./test_db_check.sh.log | grep "argument: full" | wc -l)
if [ $ret -eq 1 ]; then
echo "OK: test case 3"
else
echo "NG: test case 3"
fi
# test case 4 : If the confirmation of the instance startup status fails,
# the return value must be 100.
allStateClear
echo 1 >./sqlplus.dat
./sample_backup_script.sh
ret=$?
if [ $ret -eq 100 ]; then
echo "OK: test case 4"
else
echo "NG: test case 4 : [ret : ${ret}]"
fi
# test case 5 : If the confirmation of the instance startup status fails,
# an error has been output to the log.
allStateClear
echo 1 >./sqlplus.dat
./sample_backup_script.sh
ret=$(tail -n 1 $logname | grep "ERROR: instance state incorrect" | wc -l)
if [ $ret -eq 1 ]; then
echo "OK: test case 5"
else
echo "NG: test case 5"
fi
# test case 6 : If the backup process fails, the return value must be 200
allStateClear
echo 1 >./rman.dat
./sample_backup_script.sh
ret=$?
if [ $ret -eq 101 ]; then
echo "OK: test case 6"
else
echo "NG: test case 6 : [ret : ${ret}]"
fi
# test case 7 : If the backup process fails, an error has been output to the log.
allStateClear
echo 1 >./rman.dat
./sample_backup_script.sh
ret=$(tail -n 1 $logname | grep "ERROR: backup failure" | wc -l)
if [ $ret -eq 1 ]; then
echo "OK: test case 7"
else
echo "NG: test case 7"
fi
# test case 8 : If the check process fails, the return value must be 300
allStateClear
echo 1 >./db_check.dat
./sample_backup_script.sh
ret=$?
if [ $ret -eq 102 ]; then
echo "OK: test case 8"
else
echo "NG: test case 8 : [ret : ${ret}]"
fi
# test case 9 : If the check process fails, an error has been output to the log.
allStateClear
echo 1 >./db_check.dat
./sample_backup_script.sh
ret=$(tail -n 1 $logname | grep "ERROR: check failed" | wc -l)
if [ $ret -eq 1 ]; then
echo "OK: test case 9"
else
echo "NG: test case 9"
fi
echo "complete database backup test"
Shell Scriptのテストの書き方
Shell Scriptで作られた運用ツールって、監視だったりバックアップだったり、システム的にはかなり重要な役割を担っていたりするのに、システムテストはやるにしてもスクリプト単体でのテストってあまり行われていない気がします。
理由の一つはスクリプト自体が短いものが多く、あまり単体でテストをやる意味がないと思われているように感じます。ですが、自動で単体テストをやるように仕組みを作っておくことで、品質の担保ももちろんですが仕様変更に強くなるというメリットがあります。
ここではShellTestStarterのサンプルを例にどのようにテストを実現しているか解説します。
単純な正常系
allStateClear
という各.dat
ファイルの値を0に上書きする処理で返却値をリセットし、sample_backup_script.sh
を実行しています。返却値が0
であるかどうかを見て、試験の合否を判別しています。
# test case 1 : When completed, the return value must be 0
allStateClear
./sample_backup_script.sh
ret=$?
if [ $ret -eq 0 ]; then
echo "OK: test case 1"
else
echo "NG: test case 1 : [ret : ${ret}]"
fi
サンプルではケースを分けていますが、正常終了したというログを出力するような場合は、合否判定にそこまで含めても良いと思います。
ログを後半1行だけ読み込み、出力されるはずのメッセージがあるかどうか確認して、あれば合格としています。
# test case 2 : INFO: complete database backup is output to the log
allStateClear
./sample_backup_script.sh
ret=$(tail -n 1 $logname | grep "INFO: complete database backup" | wc -l)
if [ $ret -eq 1 ]; then
echo "OK: test case 2"
else
echo "NG: test case 2"
fi
コマンドに正しい引数が渡されているかどうかを確認
スタブコマンドはtest_<コピー後のスクリプト名>.log
というログを直下に吐き出す仕様となっています。
ですので、そのログを見ることで、コマンドに正しい引数が渡されているかを確認することが出来ます。
allStateClear
./sample_backup_script.sh
ret=$(tail -n 2 ./test_db_check.sh.log | grep "argument: full" | wc -l)
if [ $ret -eq 1 ]; then
echo "OK: test case 3"
else
echo "NG: test case 3"
fi
オプション等で処理を変えて、子スクリプトに別々の引数を渡すようなスクリプトの場合に役立つと思います。
異常系
異常系に関しても判定方法は同じですが、エラーを意図的に発生させています。
allStateClear
を実行してクリアした後、echo 1 >./sqlplus.dat
のように、直接返却値を書き込むことで、同名のコマンドが書き込んだ返却値を返すようになります。
この例ではsqlplus
が0
以外を返した場合は、ログにERROR
を出力し、100
を返却するようにしていますので、想定している動作となるかを確認します。
# test case 4 : If the confirmation of the instance startup status fails,
# the return value must be 100.
allStateClear
echo 1 >./sqlplus.dat
./sample_backup_script.sh
ret=$?
if [ $ret -eq 100 ]; then
echo "OK: test case 4"
else
echo "NG: test case 4 : [ret : ${ret}]"
fi
まとめ
今回はShellScriptのテスト自動化ツールを公開した説明記事でした。
プルリクもお待ちしておりますのでよろしくお願いします