対話型コマンドを実行するShellScriptのテスト用ツールを作った話

オンプレミスのインフラエンジニアをやっていると、運用スクリプトを作ることがあります。
監視やバックアップなどの運用を自動化するものです。
ぜひ自動化して運用で楽をしたいものですが、品質担保には思った以上の工数がかかってしまいます。
そこで私は***単体テストを実施したい！***と思ったのですが、JUnitのようにツールを入れれば行けるというわけでもなさそうでした。

## 対話型コマンドが絡むシェルのテストのやりづらさ
運用スクリプトの中には、実行したら必ず0を返すような何も工夫しなくても書けるスタブでコマンドを置き換えることでテスト出来る場合もありますが、DBのバックアップなどでスクリプト内でミドルウェア特有のコマンドをたたいているような場合、スタブ化するために少し工夫がいります。

# ShellTestStarterの紹介
というわけで作りました。
無駄にREADMEもきれいにしてみました。

https://github.com/tokku5552/shell-test-starter

releaseからzipをダウンロードするもよし、git cloneするもよし、コードを除いて必要なところだけパクるもよし。好きに使ってください。
READMEにも書きましたが、Linuxのみで動作確認しています。確認環境はCentOS 7 ですので、RHEL系だとそのまま動くと思います。(Ubuntuでも多分動く)

## Quick Start - サンプルの動かし方

Dockerでの動作が楽なので、

```shell:
docker-compose run c_dev_env /bin/bash
```

としてコンテナを起動し、シェルに入ってから`sample`に移動して`setup.sh`を実行します。


```shell
[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`を実行します。

```shell
[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`を実行することで、生成したファイルたちをクリーンアップできます。

```shell
[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`に記載されている値を読み取って、そのまま返却します。
引数を与えて実行すると、それをそのままログに吐き出します。

```shell:template_simple_stub.sh
#!/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`ファイルを読み込み最終的な返却値とします。
引数を与えた時はバッファとして読み込み、引数なしで実行した場合は対話モードで実行できます。
いずれにせよ、読み込んだ文字列はただそのまま吐き出すだけです。

```c:template_communicate_stub.c
#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`が出力するエラーを検知するように作った方が良いと思います。あくまで参考としてください。

```shell:sample_backup_script.sh
#!/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`は汎用的に作っていますので、変数宣言の部分だけ変えて流用いただいてもいいと思います。

```shell: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!"
```

変数は以下のようになっています

```shell
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`のいわゆる[テストドライバ](https://www.itmedia.co.jp/im/articles/1111/07/news183.html#:~:text=test%20driver%20%2F%20%E3%83%89%E3%83%A9%E3%82%A4%E3%83%90&text=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%E3%83%86%E3%82%B9%E3%83%88%E3%82%92%E8%A1%8C%E3%81%86%E3%81%A8%E3%81%8D,%E5%AE%9F%E6%96%BD%E3%81%99%E3%82%8B%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%A0%E3%81%AE%E3%81%93%E3%81%A8%E3%80%82&text=%E9%80%9A%E5%B8%B8%E3%80%81%E3%83%86%E3%82%B9%E3%83%88%E5%AF%BE%E8%B1%A1%E3%81%94%E3%81%A8%E3%81%AB,%E3%81%A6%E3%81%84%E3%81%8F%E3%81%93%E3%81%A8%E3%81%AB%E3%81%AA%E3%82%8B%E3%80%82)です。スタブコマンドの返却値を制御する各`.dat`ファイルを書き換えながら、テストを実行⇒観点ごとに合否を判断しています。
その他注意点としては`export PATH=./:$PATH`として、スタブコマンドにパスを通しています。もともとのパスよりも前にスタブコマンドにパスを通しておくことで、実際のコマンドではなくスタブコマンドの方を実行するようになります。

```shell:test_sample_backup_script.sh
#!/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`であるかどうかを見て、試験の合否を判別しています。


```shell
# 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行だけ読み込み、出力されるはずのメッセージがあるかどうか確認して、あれば合格としています。

```shell:
# 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`というログを直下に吐き出す仕様となっています。
ですので、そのログを見ることで、コマンドに正しい引数が渡されているかを確認することが出来ます。

```shell
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`を返却するようにしていますので、想定している動作となるかを確認します。

```shell
# 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のテスト自動化ツールを公開した説明記事でした。
プルリクもお待ちしておりますのでよろしくお願いします:bow:

https://github.com/tokku5552/shell-test-starter