シェルスクリプト用 BDD テスティングフレームワーク ShellSpec の強力なモック機能について


はじめに

ShellSpec は私が開発しているシェルスクリプト用 BDD テスティングフレームワークです。今回は ShellSpec のモック機能がどれだけシンプルで強力であるかを紹介したいと思います。

注意 この記事の内容を手元で試す場合は、最新の master か 0.23.0 以降を使用して下さい。print コマンドの再定義が想定されておらず ShellSpec が正しく動作しない問題がありました。

テスト対象の関数について

まず前提知識として。シェルスクリプトには文字列を(エスケープ文字を解釈せずに)そのまま出力するコマンドとして printf コマンドがあることはご存知だと思います。printf はほとんどのシェルでビルトインコマンドなのですが、mksh ではビルトインコマンドではありません。外部コマンドの printf が使われるので問題なく動くのですが遅くなってしまいます。(Linux 上ではさほど遅くないのですが WSL 上だと出力する量によっては致命的なレベルで遅くなります。)

mksh には代わりに print コマンドがあり、こちらを使っても文字をそのまま出力することができます。そこで bash では printf、mksh では print を使用する putsn 関数を実装します。(この関数は、./lib.sh に定義されているものとします。)

./lib.sh
putsn() {
  if [ "${KSH_VERSION:-}" ]; then
    putsn() {
      print -r -- "$1"
    }
  else
    putsn() {
      printf '%s\n' "$1"
    }
  fi
  putsn "$@"
}

なんの変哲もない・・・と言いたいところですが、人によってはこれが動くのか?と思うかもしれないですね。シェルスクリプトでは関数の中に関数を書くこともできますし、関数を再定義することもできます。それを利用して最初に putsn が呼ばれたときにシェルを判定し適切な関数を定義して、二度目以降はチェックを行わないようにしています。

テストコード

さて、ShellSpec を使って、この関数のテストコードを書きましょう。一番シンプルなテストコードです。「putsn 関数は文字列を出力する。」という文章のテストで、テスト内容は関数実行の出力を調べているだけの明快なテストコードですね。(疑問が浮かぶ人がいるかも知れないので説明しておくと、このテストコードはシェルスクリプトと互換性がある文法でシェルスクリプトコードを埋め込むこともできますが、そのままシェルで動かしているのではなく ShellSpec で変換してから実行しています。文法チェックをしてもエラーにはなりませんが bash でそのまま実行することはできません。あと大文字で始まる関数名が気になるかもしれませんが、他のシェル関数と名前がかぶらないようにするためと英文は大文字で始めるものです。と言っておきます。)

spec/putsn_spec.sh
Include ./lib.sh

Describe "putsn()"
  It "puts string"
    When call putsn "test"
    The output should eq "test"
  End
End

このテストコードは特に問題なくこのままでも十分なのですが ShellSpec はカバレッジを計測することができます。しかし残念ながらカバレッジは bash でしか計測できません。mksh 版の putsn 関数は bash では実行されないので、カバレッジレポートには記録されません。これをなんとかしたいと思います。(もちろん テストとしては mksh を使ってテストコードを実行すれば十分です。ここではカバレッジレポートが気になるというだけの問題です。)

さて bash で putsn() { print -r -- "$1"; } のコードを実行させるにはどうすればよいでしょうか? 課題は二つあります。一つは bash では KSH_VERSION 変数が定義されていないこと、もう一つは print コマンドが存在しないことです。

まずは簡単な KSH_VERSION 変数の方から。これは BeforeKSH_VERSION に値を設定するだけです。(正確にはコードを実行しています。mksh では KSH_VERSION は読み取り専用なので値が入ってないときだけ代入しています。)変数はすぐに設定されるのではなく(Describe に複数のItがある場合に)It 実行の直後で設定されます。また It のブロックを抜けると変数は元に戻ります。It 等のブロックは内部的にサブシェル内で実行されるのでこのような動きになります。

spec/putsn_spec.sh
Describe "putsn()"
  Before ': "${KSH_VERSION:=MIRBSD}"'

  It "puts string"
    When call putsn "test"
    The output should eq "test"
  End
End

これで bash で print コマンドを使用する mksh 版が呼ばれることになりますが、そのままでは print コマンドはないのでエラーになります。そのためモック関数として print シェル関数を定義します。モック関数を定義する専用のコマンドがあるわけではなく単純にシェル関数を定義するだけです。ついでにItの後のテスト内容の文章も適切なものに変更しましょう。

spec/putsn_spec.sh
Describe "putsn()"
  Before ': "${KSH_VERSION:=MIRBSD}"'
  print() { echo "print command:" "$@"; }

  It "puts string using print command"
    When call putsn "test"
    The output should eq "print command: -r -- test"
  End
End

さて最初のテストと合わせて完全なテストコードはこのようになります。

spec/putsn_spec.sh
Include ./lib.sh

Describe "putsn()"
  It "puts string"
    When call putsn "test"
    The output should eq "test"
  End

  Context "when shell is mksh"
    Before ': "${KSH_VERSION:=MIRBSD}"'
    print() { echo "print command:" "$@"; }

    It "puts string using print command"
      When call putsn "test"
      The output should eq "print command: -r -- test"
    End
  End
End

ここまでで説明してないものとして Context が登場しました。Rspec をご存知な方は想像つくと思いますが ContextDescribe の別名です。Describe はテストの対象、Context は特定の状況、を記述しテストコードを文章として読みやすくするためだけに使い分けます。mksh 版のテストは「putsn() はシェルが mksh の時、print コマンドを使用して文字列を出力する。」という文章のテストになります。

さて話をさかのぼります。冒頭で「putsn は最初に呼ばれたときに適切な関数を定義する」と書きました。mksh 版のテストは「最初にputsnを使う時ではない」と思いませんか?

上の方で「It のブロックを抜けると KSH_VERSION 変数は元に戻る」と書きましたが元に戻るのは変数だけではありません。サブシェル(≒別プロセス)で実行されているので定義した関数なども元に戻ります。(正確にはサブシェルで行った処理は呼び出し元には影響を与えません。)そのため最初の putsn のテストで定義された関数は存在せず、きれいな環境でテストが実行されます。

このようにブロック構造から容易に推測可能な「スコープ」があるかのように動作します。モック関数として定義した print 関数も Context を抜けると消えます。つまりモックを解除するための専用のコマンドもいらないということです。ブロック構造に従うだけで他の部分のテストでの副作用に影響されずに、クリーンな状態でテストを実行することができます。(補足ですが DescriptContext ブロックはいくつでもネスト可能ですのでテスト内容を構造化するにに役立ちます。)

さいごに

この記事は ShellSpec のモック機能の紹介なのですが、実のところモックを実現するための"機能"はありません。モック関数は普通にシェル関数を定義するだけです。あるのはブロック構造とそれをサブシェル実行に変換する仕組みです。これだけでシンプルで強力なモック機能を実現しています。

コマンドベースモック機能 (2020-08-21追記)

「さいごに」でモックを実現するための機能はありませんと書きましたが、バージョン 0.26.0 でモックを実現するための機能が追加されました。上記の内容はシェル関数を定義することでモックを実現するのですが、実はこれでは対応できない場合がいくつか存在します。

  • シェル関数では使えない文字が使われているコマンドのモック
  • テスト対象が外部コマンド(別プロセス)の場合
  • モックから元のコマンドを呼び出したい場合

こういった場合に使えるのがコマンドベースモック機能です。その名の通りシェル関数の代わりに(一時的な)外部コマンドを定義します。使い方は簡単で関数定義の代わりに Mock ヘルパーを使用して定義するだけです。

Describe 'mock example'
  get_next_day() { echo $(($(date +%s) + 86400)); }

  Mock date
    echo 1546268400
  End

  It 'runs the mocked date command'
    When call get_next_day
    The stdout should eq 1546354800
    The variable called should eq 1
  End
End

実装的には ShellSpec 起動時に PATH にモックコマンド用のパスを優先されるように登録し適宜モックコマンドを作成して優先的に呼び出されるようにしています。シェル関数と同様にブロックスコープと連動しているので内部のブロックで一時的に上書きすることが可能でブロックを抜けたら元に戻るようになっています。

また正確にはモック機能とは独立した機能なのですが、モック関数から元のコマンドを呼び出したい場合、頭に @ をつけて呼び出すことができます。例えば date コマンドの場合 @date です。この @ 付きのコマンドをサポートコマンドと呼んでおり使用する場合は shellspec --gen-bin でサポートコマンドを生成する必要があります。サポートコマンドはただの外部コマンドなのでシェル関数ベースのモックでも使うことができます。