shellspecで実現するスナップショットテスト: 実践ガイドとベストプラクティス

# 1. 前提
- shellspec 0.28.1

# 2. 概要
スナップショットテストとはテスト実行時の出力をスナップショットとして記録し、
そのスナップショットを次回以降のテストの期待値として使用するというテストの方法です。予期せぬ変更確認を行うテストとして JavaScript の UI レンダリングのテストに広く使われています。Jest の例が有名です。

私はスナップショットテストの考え方はシェルの出力結果のテストにも応用できると考えています。本記事で shellspec によるスナップショットテストの実装例を紹介します。

私は既に shellspec でスナップショットテストを実践していますが、この記事を書くにあたって改めてコードを整理し直しました。より洗練したものになっていると思いますが、逆に動作実績が少なく間違っている部分があるかもしれません。発見した場合は遠慮なく報告してください。

また、私自身のフィードバックをこっそり反映することもあるかもしれません。ご了承ください。

----

# 3. 実装例
## 3.1. ベース
```.shellspec
--require spec_helper
```
```sh:spec/spec_helper.sh
spec_helper_configure() {
  # スナップショット用マッチャーを読み込む。
  import 'support/matcher_be_snapshot'
}
```
```sh:spec/support/matcher_be_snapshot.sh
# スナップショット用マッチャー
#
# ■引数
# should be_snapshot スナップショットファイルパス スナップショットフィルター関数名(省略可)
#
# ■環境変数
# SHELLSPEC_SNAPSHOT_ACTUAL_ROOT: 実スナップショットの出力先ルートディレクトリを指定する。省略時は snapshot。
# SHELLSPEC_SNAPSHOT_EXPECTED_ROOT: 期待値スナップショットの入力元ルートディレクトリを指定する。省略時は snapshot。
# SHELLSPEC_SNAPSHOT_UPDATE: 既に存在している実スナップショットをテスト時の内容で上書きするには 1 を設定する。省略時は上書きしない。
# SHELLSPEC_SNAPSHOT_DEFAULT_FILTER: 引数のスナップショットフィルター関数名に指定がない場合のデフォルト値を指定する。省略可。
#
# ■補足
# 環境変数の設定により２つのスナップショット管理スタイルを選択できます。
#
# ・デフォルトスタイル
# 実スナップショットファイルをそのまま期待値スナップショットファイルとして扱います。
# 既に存在しているスナップショットをテスト時の内容で上書きするには以下のコマンドを実行します。
# 必要に応じて alias やシェルを使って実行しやすくすると便利です。
#
# SHELLSPEC_SNAPSHOT_UPDATE=1 shellspec
#
# 設定例：
# SHELLSPEC_SNAPSHOT_ACTUAL_ROOT=snapshot
# SHELLSPEC_SNAPSHOT_EXPECTED_ROOT=snapshot
# SHELLSPEC_SNAPSHOT_UPDATE=
#
# ・期待値別管理スタイル
# 実スナップショットファイルと期待値スナップショットのディレクトリを分けます。
# 別途 GUI 差分ツールを使い実スナップショットと期待値スナップショットの差分確認やマージによる転記をして使います。
# 
# 設定例：
# SHELLSPEC_SNAPSHOT_ACTUAL_ROOT=snapshot/actual
# SHELLSPEC_SNAPSHOT_EXPECTED_ROOT=snapshot/expected
# SHELLSPEC_SNAPSHOT_UPDATE=1

shellspec_syntax 'shellspec_matcher_be_snapshot'

shellspec_matcher_be_snapshot() {
  shellspec_matcher__match() {
    # shellcheck disable=SC2317
    SHELLSPEC_SNAPSHOT_ACTUAL_FILEPATH="${SHELLSPEC_SNAPSHOT_ACTUAL_ROOT:-snapshot}/$1"
    SHELLSPEC_SNAPSHOT_EXPECTED_FILEPATH="${SHELLSPEC_SNAPSHOT_EXPECTED_ROOT:-snapshot}/$1"
    SHELLSPEC_SNAPSHOT_FILTER=${2:-$SHELLSPEC_SNAPSHOT_DEFAULT_FILTER}

    if [ -n "$SHELLSPEC_SNAPSHOT_FILTER" ]; then
      SHELLSPEC_SNAPSHOT_ACTUAL=$(printf "%s\n" "$SHELLSPEC_SUBJECT" | $SHELLSPEC_SNAPSHOT_FILTER)
    else
      SHELLSPEC_SNAPSHOT_ACTUAL=$(printf "%s\n" "$SHELLSPEC_SUBJECT")
    fi
    if [ -f "$SHELLSPEC_SNAPSHOT_EXPECTED_FILEPATH" ]; then
      # 期待値スナップショットファイルがある場合、内容を比較する。
      SHELLSPEC_SNAPSHOT_EXPECTED=$(cat "$SHELLSPEC_SNAPSHOT_EXPECTED_FILEPATH")
      [ "$SHELLSPEC_SNAPSHOT_EXPECTED" = "$SHELLSPEC_SNAPSHOT_ACTUAL" ] && SHELLSPEC_SNAPSHOT_RESULT=0 || SHELLSPEC_SNAPSHOT_RESULT=1
    else
      # 期待値スナップショットファイルがない場合、失敗とみなす。
      SHELLSPEC_SNAPSHOT_RESULT=1
    fi
    output_actual_snapshot
    return $SHELLSPEC_SNAPSHOT_RESULT
  }

  output_actual_snapshot() {
    SHELLSPEC_SNAPSHOT_ACTUAL_DIRPATH=$(dirname "$SHELLSPEC_SNAPSHOT_ACTUAL_FILEPATH")
    mkdir -p "$SHELLSPEC_SNAPSHOT_ACTUAL_DIRPATH"
    # 強制上書き／テスト失敗／スナップショットファイルが存在しない場合にスナップショットを出力する。
    if [ "$SHELLSPEC_SNAPSHOT_UPDATE" = "1" ] || [ "$SHELLSPEC_SNAPSHOT_RESULT" = "0" ] || [ ! -f "$SHELLSPEC_SNAPSHOT_ACTUAL_FILEPATH" ]; then
      printf "%s\n" "$SHELLSPEC_SNAPSHOT_ACTUAL" > "$SHELLSPEC_SNAPSHOT_ACTUAL_FILEPATH"
    fi
  }

  shellspec_matcher__failure_message() {
    shellspec_putsn "expected: to match snapshot"
    shellspec_putsn "snapshot: $SHELLSPEC_SNAPSHOT_EXPECTED_FILEPATH"
    if [ -n "$SHELLSPEC_SNAPSHOT_FILTER" ]; then shellspec_putsn "filter: $SHELLSPEC_SNAPSHOT_FILTER"; fi
    # 差分を表示する。
    if command -v mktemp > /dev/null 2>&1 && command -v diff > /dev/null; then
      shellspec_putsn "Diff shown as 'expected' vs 'actual':"
      SHELLSPEC_SNAPSHOT_EXPECTED_TEMPFILE=$(mktemp)
      SHELLSPEC_SNAPSHOT_ACTUAL_TEMPFILE=$(mktemp)
      printf "%s" "$SHELLSPEC_SNAPSHOT_EXPECTED" > "$SHELLSPEC_SNAPSHOT_EXPECTED_TEMPFILE"
      printf "%s" "$SHELLSPEC_SNAPSHOT_ACTUAL" > "$SHELLSPEC_SNAPSHOT_ACTUAL_TEMPFILE"
      SHELLSPEC_SNAPSHOT_DIFF_OUTPUT=$(diff "$SHELLSPEC_SNAPSHOT_EXPECTED_TEMPFILE" "$SHELLSPEC_SNAPSHOT_ACTUAL_TEMPFILE") || :
      shellspec_putsn "$SHELLSPEC_SNAPSHOT_DIFF_OUTPUT"
      rm -f "$SHELLSPEC_SNAPSHOT_EXPECTED_TEMPFILE" "$SHELLSPEC_SNAPSHOT_ACTUAL_TEMPFILE"
    fi
  }

  shellspec_matcher__failure_message_when_negated() {
    shellspec_putsn "expected: to differ from the snapshot"
    shellspec_putsn "snapshot: $SHELLSPEC_SNAPSHOT_EXPECTED_FILEPATH"
    if [ -n "$SHELLSPEC_SNAPSHOT_FILTER" ]; then shellspec_putsn "filter: $SHELLSPEC_SNAPSHOT_FILTER"; fi
  }

  shellspec_syntax_param count [ 1 -le $# ] || return 0
  shellspec_syntax_param count [ $# -le 2 ] || return 0

  shellspec_matcher_do_match "$@"
}
```
## 3.2. テストコード
```sh:spec/example_spec.sh
Describe "example.sh"
  It "単純動作を確認する。"
    When run script example.sh
    The status should be success
    # 標準出力に対してスナップショットテストを行う。
    The entire output should be_snapshot example/simple/stdout.txt
  End
End
```
## 3.3. テスト対象コード
```sh:example.sh
#!/bin/sh

echo line1
echo line3
```

----

# 4. テストの流れ
初回のスナップショットファイルがないので必ず失敗しますが、以下のファイルができます。
```snapshot/example/simple/stdout.txt
line1
line3
```
これは example.sh を実行した結果です。スナップショットを得たら内容が想定通りであることをレビューします。

余りにも想定外の出力であれば繰り返しスナップショットを出力し直しても構いませんし、手で直せる程度であれば直して期待値としても構いません。

スナップショットを出力し直すには一度ファイルを削除するか以下のコマンドを実行して上書き出力します。以下のコマンドは必要に応じて alias やシェルを使って実行しやすくすると便利です。
```
SHELLSPEC_SNAPSHOT_UPDATE=1 shellspec
```
では、スナップショットの中身の話に戻ります。例えば本当は line2 も出ているはずだったのにと思うなら、スナップショットファイルを以下のように修正します。
```snapshot/example/simple/stdout.txt
line1
line2
line3
```
レビュー＆修正が終わったらテストケースの完成です。テストコードとスナップショットはセットです。後は普通のテストと同じようにテストが通るまでプログラムを直していきます。

上記は１テストケースの流れですが、これを色々なテストケースで繰り返し実施し、色々なテストケースのスナップショットを貯めてテストを構築していきます。

----

# 5. 長所と短所
スナップショットテストはテストの詳細をテストコードからスナップショットに移動する考え方であると私は捉えています。

以下が私の思う本記事で紹介したスナップショットテストの長所です。

- 実際に動かした結果を元に期待値を作るのでテスト作成が簡単にできる。
- 出力全体の観察ができ成否判断や期待値整理がしやすい。
- テストコードの期待値確認の実装がシンプルになる。

特に 2 の長所が可読な出力結果の確認と相性が良いです。
実装例は標準出力の確認を対象にしていましたが、標準エラー出力（The error）や出力ファイル（The file）の内容確認などにも使えます。

また 3 の長所はパラメタライズドテストをやりやすくします。テストコードをコンパクトに保つのに役立つと思います。

一方で以下の短所があります。

- 仕様が不明瞭になる。
  - テストコードを見ても具体的な確認内容が書いていない。
  - スナップショットを見ても出力全体が期待値となるので具体的な確認内容がはっきりしない。
- １つの文言修正がスナップショット全体に影響する。

1 の短所を私は受け入れています。
それよりも長所の 3 を活かしてテストコードをコンパクトに保つことを優先しています。

2 の短所は単純に変更をしたら影響を受けた全てのスナップショットの差分を目視して対処しています。全てのスナップショットの差分を一言一句細かく確認する必要はないので余程の数にならなければ対処できると思います。影響確認にもなります。

----

# X. 補足
## X.1. 好みのスナップショット管理スタイルを採用しよう。
本記事で紹介したスナップショット用マッチャーは環境変数の設定の仕方により２つのスナップショット管理スタイルを選択できます。

|スタイル|概要|
|----|----|
|デフォルトスタイル|実スナップショットファイルをそのまま期待値スナップショットファイルとして扱う。|
|期待値別管理スタイル|実スナップショットファイルと期待値スナップショットのディレクトリを分ける。|

デフォルトスタイルは広く知られた Jest のスナップショットテストの考え方を参考にしたものです。

一方で期待値別管理スタイルは Jest やスナップショットテストという単語を知る以前から私が好んで使っている方法です。期待値として使用するファイルを明確に分ける狙いがあります。また、GUI 差分マージツールでの取り扱いのしやすさもあり、私はこちらが好きです。

今回、記事にまとめるにあたって両方のスタイルをサポートする選択をしました。.shellspec で以下のように設定すると良いでしょう。

```.shellspec
## デフォルトスタイルの場合（デフォルト設定なので省略可）
# SHELLSPEC_SNAPSHOT_ACTUAL_ROOT = SHELLSPEC_SNAPSHOT_EXPECTED_ROOT かつ SHELLSPEC_SNAPSHOT_UPDATE != 1 に設定する。
--require spec_helper
--env SHELLSPEC_SNAPSHOT_ACTUAL_ROOT=snapshot
--env SHELLSPEC_SNAPSHOT_EXPECTED_ROOT=snapshot
--env SHELLSPEC_SNAPSHOT_UPDATE=

## 期待値別管理スタイルの場合
# SHELLSPEC_SNAPSHOT_ACTUAL_ROOT != SHELLSPEC_SNAPSHOT_EXPECTED_ROOT かつ SHELLSPEC_SNAPSHOT_UPDATE = 1 に設定する。
--require spec_helper
--env SHELLSPEC_SNAPSHOT_ACTUAL_ROOT=snapshot/actual
--env SHELLSPEC_SNAPSHOT_EXPECTED_ROOT=snapshot/expeceted
--env SHELLSPEC_SNAPSHOT_UPDATE=1
```

## X.2. バージョン管理システムや GUI 差分マージツールを有効活用しよう。
バージョン管理システムや GUI 差分マージツールはスナップショットテストには欠かせません。

期待値として確定したスナップショットをバージョン管理システムにコミットしましょう。そうすることでテスト結果とは別にコミット内容との差分で期待値との乖離を見ることができるようになります。

また、期待値別管理スタイルでは実スナップショットの出力先ルートディレクトリと期待値スナップショットの入力元ディレクトリの２つを GUI 差分マージツールに掛けることができます。

私は期待値との乖離を細かく確認するために WinMerge を使っています。また、乖離の許容・拒否を WinMerge のマージ操作で行います。調整が済んだらバージョン管理システムにコミットします。

## X.3. 可変出力にフィルターで対応する。
日付やランダムな値など、再現性に影響のある出力への対応が必要になることがあります。

その解決方法の１つにスナップショットに保存する前に出力をフィルターして可変出力を抑制するという方法があります。１つ事例を紹介します。
```sh:spec/example_with_date_spec.sh
Describe "example_with_date.sh"
  It "単純動作を確認する。"
    filter_date() {
      sed 's/[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\} [0-9]\{2\}:[0-9]\{2\}:[0-9]\{2\}/DATETIME/g'
    }

    When run script example_with_date.sh
    The status should be success
    The entire output should be_snapshot example_with_date/simple/stdout.txt filter_date
  End
End
```
```sh:example_with_date.sh
#!/bin/sh

echo "[$(date '+%Y-%m-%d %H:%M:%S')] line1"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] line2"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] line3"
```
filter_date で日時の出力を DATETIME という固定文字列に置き換えてスナップショットにします。以下は上記の実行で得られるスナップショットです。
```
[DATETIME] line1
[DATETIME] line2
[DATETIME] line3
```
この方法が全てではないですが本体に手を入れずに使える方法として非常に強力です。

本事例ではテストコードの It の中にフィルターを書きましたが、ファイルの先頭に書けばテストコード内共通フィルターになりますし、spec_helper.sh を通じて定義すればテスト全体共通フィルターも実装できます。

## X.4. オプションの動作確認をパラメタライズドテストとして実装する。
スナップショットテストでテストコードの期待値確認の実装がシンプルになり、
パラメタライズドテストの実装がしやすくなるということの事例としてオプションのパラメタライズドテストの例を紹介します。
```sh:spec/example_with_options.sh
Describe "example_with_options.sh"
  Parameters
    "--option1" success "option/option1"
    "--option2" failure "option/option2"
    "--option1 --option3" success "option/option1_3"
  End

  It "$1 オプションを確認する。"
    When run script example_with_options.sh $1
    The status should be $2
    The entire output should be_snapshot example_with_options/$3/stdout.txt
  End
End
```
標準出力の確認内容詳細がスナップショットファイルに移動しているため上記のようにシンプルに書ける可能性があります。

私はオプションを使った一通りの正常系を上記の単純な記述で通し、異常系の細かな確認を分けて通しています。

オプションの扱いについて上記は単純な場合の例ですが「--option3="a b c"」のようなオプションがある場合、上記はうまく働きません。その場合は、以下のように書きます。
```sh:spec/example_with_options.sh
Describe "example_with_options.sh"
  Parameters
    "--option1" success "option/option1"
    "--option2" failure "option/option2"
    "--option1|--option3=a b c" success "option/option1_3"
  End

  It "$1 オプションを確認する。"
    IFS='|' read -r -a OPTIONS <<< "$1"  # | で $1 を配列に変換する。
    When run script example_with_options.sh "${OPTIONS[@]}"
    The status should be $2
    The entire output should be_snapshot example_with_date/$3/stdout.txt
  End
End
```

以上です。