シェルスクリプト用のBDDテスティングフレームワークを作りました


ShellSpec - シェルスクリプト用のBDDテスティングフレームワークを作りました

この記事を補完・更新する記事として以下の記事を書きました 2020-09-17 追記
ShellSpec - シェルスクリプト用のフル機能のBDDユニットテストフレームワーク

シェルスクリプトのための BDD スタイルのテスティングフレームワーク ShellSpec をリリースしました。以下のようなスペックファイルでテストを記述することができます。

Describe 'sample'
  Describe 'add()'
    add() { echo "$1 + $2" | bc; }

    Example 'perform addition'
      When call add 2 2 # Evaluation
      The output should eq 4 # Expectation
    End
  End
End

次のような特徴があります

  • POSIX 準拠のシェルで動作します。 (dash, bash, ksh, busybox 等)
  • BDD スタイルの スペックファイル
  • シェルスクリプトと互換性があるスペックファイルのシンタックス
  • シェルスクリプトによる実装
  • 依存関係が少ない(僅かな POSIX コマンドしか使用しません)
  • レキシカルスコープ風のスコープをもったネスト可能なテストグループ
  • Before / After フック
  • Skip / Pending
  • モック / スタブ (一時的な関数の置き換え)
  • ビルトインのシンプルなタスクランナー
  • モダンなレポーティング (色や失敗したテストの行番号表示)
  • 拡張可能なアーキテクチャ (カスタムマッチャー、カスタムフォーマッター等)
  • ShellSpec は ShellSpec でテストしています

ShellSpec の使い方は README.md に任せるとして、ここでは開発の意図や設計などを書いていこうと思います。

作った動機

もともとは Ansible の大量のモジュールと設定内容を YAML で書き直す面倒さが嫌で、それに変わるツールをシェルスクリプトで開発しようとしていたときに必要になった副産物です。

いくつも言語がある中、わざわざシェルスクリプト?と思うかもしれませんが、シェルはユーザーインターフェースとして Linux や Unix や macOS などで標準でインストールされているため、多くの環境で特定の準備なしに動作するスクリプト言語であるという特徴があります。

シェルスクリプトを実行するために言語の実行環境やランタイムをインストール必要がありませんし、組み込み系で使われる BusyBox や Docker の軽量イメージとしてよく使われる Alpine でも動きます。コンパイルも不要なので簡単にメンテナンス可能です。

そういった特定の環境に依存しないツールの開発には適した言語だと思います。

既存のテスティングフレームワークではだめだったのか?

シェルスクリプトのテスティングフレームワークは shUnit2Bats が有名ですが、それまで Ruby で RSpec でテストを書いていた経験から BDD スタイルのテスティングフレームワークが欲しいと思ったのが発端です。shUnit2 も Bats も BDD スタイルではなく、また dash や BusyBox (内蔵の ash)もターゲットとしたかったため候補は大きく絞られました。

BDD スタイルのテスティングフレームワーク探した所、shpecshspec というのを見つけたのですが、before / afterフックや skip 機能が無いなど機能的に不足していると感じました。

私がテスティングフレームワークに特に望んでいたものは、ネスト可能なテストのグループ化とテストの独立性(あるテスト結果が別のテスト結果に影響を与えないこと)です。残念ながらこれらを備えたものは見つかりませんでした。

対応シェルについて

多くの POSIX 準拠のシェルに対応しています。具体的には、dash, bash, zsh, ksh88, ksh93, mksh, pdksh, yash, posh, busybox (ash) です。バージョンもかなり古いバージョンまで対応しています。(古いバージョンはそのうち切り捨てようとは思いますが)

POSIX 準拠のシェルといっても完全に互換性があるわけではなく、実装によってバグや微妙な差異があります。特に zsh の shwordsplit による非互換性と、posh のバグに苦しめられました。基本的にどのシェルでも動く書き方をしているのですが、どうしても回避できなかった場所は zsh の shwordsplit を一時的に有効にして処理を行い戻すということをしています。

poshのバグとは、foo "$@" のようなコードで引数($@)が一つもない場合、foo として扱われるのが正しい仕様ですが、set -u を行ってる状態では parameter not set が出てしまうという問題です。

最初は以下のようなコードで対応していましたが

case $# in
  0) foo ;;
  *) foo "$@" ;;
esac

長いので試行錯誤した結果、最終的に以下のようなコードになりました。

eval foo ${1+'"$@"'}

ちなみに、以下のようなワークアラウンドが有名らしいのですが、古い zsh で期待した動作をしませんでした。

foo ${1+"$@"}

他にもいくつか非互換性は存在し、それらを吸収するコードの大半は lib/general.sh に記述しています。また ShellSpec の実装で特に問題になったものに関しては検証ツールを作成しています。(contrib/bugs.sh

基本設計

スペックファイルでは、ネスト可能な Describe (Context) ~ End ブロックでテストグループを構成し、Example (Specify) ~ End ブロック内にテストを記述します。このブロックはテストの独立性を実現するためにそれぞれ個別のサブシェルで実行されます。

ShellSpec の文法はシェルスクリプトと互換性があり、ShellCheck でチェックできるようになっていますが、シェルスクリプトにはブロックというものは存在しません。このブロックを実現するために簡単なコード変換を行っています。例えば冒頭のスペックファイルは以下のようなコードに変換されます。(実際には行番号などの情報を付加するためもう少し複雑です。)

(
  SHELLSPEC_BLOCK_NO=1
  shellspec_block1() {
    shellspec_example_group 'sample'
    # ↑内部で shellspec_yield${SHELLSPEC_BLOCK_NO} を呼び出します。
  }
  shellspec_yield1() {
    (
      SHELLSPEC_BLOCK_NO=2
      shellspec_block2() { shellspec_example_group 'add()'; }
      shellspec_yield2() {
        add() { echo "$1 + $2" | bc; }
        (
          SHELLSPEC_BLOCK_NO=3
          shellspec_block3() { shellspec_example 'perform addition'; }
          shellspec_yield3() {
            shellspec_statement when call add 2 2
            shellspec_statement the output should eq 4
          }
        )
      }
      shellspec_block2
    )
  }
  shellspec_block1
)

ブロックをサブシェルで実行することで、テストの影響範囲を小さくし、ローカル変数やローカル関数相当の機能を実現しています。

ところでサブシェルを使用すると一つ問題がでてきます。テスト結果(何件成功して何件失敗したか等)をどうやって回収するかです。一般的には成功・失敗した件数を変数に加算していけば良いのですが、サブシェルで実行しているためにブロックを抜けると変数の値は戻ってしまいます。

そのため ShellSpec ではプロセスを複数に分けて連携させています。

  1. shellspec
    1. ユーザーが最初に実行するコマンドでオプションの解析が主な内容です。
    2. shellspec-runner.shに引き渡します。
  2. shellspec-runner.sh
    1. specファイルを shellspec-translator.sh で変換します。
    2. 変換したスクリプトを実行しテスト実行結果を特殊なフォーマットで標準出力に出力します。
    3. その出力を shellspec-reporter.sh に引き渡します。
  3. shellspec-reporter.sh
    1. テスト実行結果を整形し、テスト結果をまとめて出力します。

スペックファイルの変換、テスト実行、レポート処理をそれぞれ別プロセスで並列で動作するように実装しているため、実行速度の向上も期待できます。またテスト結果の出力はブロックされることなく実行と同時に随時出力されていくので素早いフィードバックを行えます。(つまりテストの実行が全て終わってから出力されるわけではありません。)

DSLについて

もともと RSpec と同等の DSL を実装しようとしていたのですが途中で方針を変更し独自の DSL にしました。Given-When-Then スタイルに近い形にしています。

独自で DSL を作るとなると BDD の思想を理解する必要あるので(今でも理解した自信はないですが) RSpec を真似したかったのですが、実装しているうちにシェルスクリプトの用途にうまく適合しないなと感じました。その理由はおそらく Ruby はオブジェクト指向であるが、シェルスクリプトは手続き型だからなのではないかと考えています。

RSpec ではテスト対象はオブジェクト自身またはメソッドの戻り値のオブジェクトであり、オブジェクトを効率よくテストするマッチャーが発達(悪く言えば複雑)していますが、シェルスクリプトでは戻り値(標準出力、標準エラー出力、終了ステータスコード)は単純なテキストまたは数値です。そのため複雑なマッチャーは必要ありません。(複雑なマッチャーの代わりにテスト対象を加工する modifier という機能があります)

また私自身は RSpec の subjectit のワンライナー構文は結構好きだったのですが、【翻訳】RSpecのリードメンテナだけど何か質問ある? で追加して後悔した機能として挙げられているため ShellSpec でもワンライナー構文を追加するのをやめました。

ここまでくると RSpec の DSL にこだわる理由もないので、Given-When-Then スタイルを参考に独自の DSL にしています。When がShellSpec の When ステートメント、Given が When ステートメントに至るまでの処理(Before 等)、Then が ShellSpec の The ステートメントに相当します。

余談ですが RSpec のドキュメントにある以下のような 文章 も参考にしています。(このような書き方で仕様を説明してるなら、それを DSL にすればいいじゃん?的なノリです。)

Equality matchers - Built in matchers - RSpec Expectations - RSpec - Relish より

When I run rspec compare_using_eq.rb
Then the output should contain "3 examples, 0 failures"

実装方針

多くのシェルに対応するために外部コマンドをほとんど使用していません。外部コマンドは POSIX の範囲のコマンドであっても実装に差異があり、特に POSIX 準拠を目指しているわけではない BusyBox では限られたオプションしか使えず仕様が異なるものも存在します。それらの差異を吸収するのは大変なので、外部コマンドの使用は必要最小限に留めテスト実行のためのコードは原則としてシェルスクリプト言語とビルトインコマンドのみで処理しています。

この方針のもう一つのメリットは外部コマンドの呼び出しを行わないため、高速に実行できると言う点です。ShellSpec 自体のテストケースは300 項目以上ありますが、Linux 上の dash でわずか1秒で完了します。テストを高速に実行できることは CI をやるにあたって重要なことの一つです。

またローカル変数を使用せずに実装しています。シェルスクリプトでローカル変数は使用できないことはないのですが、dash や bash だと local、kshだと typeset と使い分ける必要があります。これを吸収する方法はなくはないのですが、それをやると複雑になってしまうためローカル変数の使用自体を止めました。代わりに少数のグローバルな変数と引数を使用しています。

グローバル変数は再帰的な処理で困るため、パフォーマンスとわかりやすさの観点からどうしてもグローバル変数を使ったほうが良いと思う場合にのみ使用しています。それ以外は引数を上手く使用しています。引数はすべてのシェルでローカル変数のように局所化されます。例えばループ変数が必要になるループ処理のようなものは再帰で実装して引数を利用することで変数を使わずに実装しています。

グローバルな変数を使用した場合の問題の一つは変数を使ってる最中に別の用途で同じ名前の変数を使ってしまう場合です。これに関しては「処理の中で変数を使用するが、もうその変数は使い終わったので後はどう使っても構わない」という状態を作ることで変数の再利用を避けることが出来ます。

それを実現するための設計レベルでの工夫がコードの流れを一方向に整えることです。テスト実行の処理の流れは上から下、左から右へと一方向に流れるようになっています。テストコードは上から下へ実行され、以下のようなステートメントの場合は

The line 2 of stdout should equal "test"

内部的に次のように並び替えて実行します。

The stdout line 2 should equal "test"

このステートメントを実行する時に、shellspec_the(), shellspec_subject_stdout(), shellspec_modifier_line(), shellspec_verb_should(), shellspec_matcher_equal() といった関数を実行しているのですが、関数の末尾で次の関数を呼び出すようにすることで(実際には関数呼び出しはネストしてるのですが)次の関数へ処理を引き継ぐような形にしています。

これにより変数を使用した場合でも該当の処理が終わればもうその変数は不要になるため、次に同じ名前の変数を使用しても安全に処理を実行することができます。

レポート機能

RSpec の出力結果を真似して色付きで読みやすいレポートを出力します。テストに失敗した場合はその行番号も出力されます。出力フォーマットはTAP形式にも対応しており、拡張可能な設計にしているのでユーザーが定義したレポートを出力するようにもできます。

簡易タスクランナー機能

別のツールに分離すべきなのかもしれませんが、テストを実行するに当たっての準備スクリプトはテスト自体に含まれているべきだと考えたので、簡易タスクランナー機能を実装しています。

ShellSpec は ShellSpec でテストしています

テスティングフレームワークを作る上でやりたかったことです。ただ自分自身でテストして、それで信頼性を保つことができるのだろうか?と悩みました。

最初は別プロセスで ShellSpec を実行してその出力をテストすべきかとも思ったのですが、最終的に Evaluation だけ気をつければ問題ないことに気づきました。

結局の所、テスト対象のものは Evaluation 実行の結果を格納した変数であり、Evaluation さえ実行し終われば、あとは気にすることではないからです。

ただしテストにあたってモック(関数の再定義)が必要な場合は、Example 内(もしくは外)で関数の再定義をしてしまうと該当のブロックを抜けるまで元に戻らないため、Expectation が関数の再定義の影響を受けてしまいます。そのため Evaluation でのみ関数の再定義を行えるように(サブシェルで実行する) When invoke ~ という機能を追加しました。

この ShellSpec 自身のテストは最新のシェルに関しては CI でテストを行っています。古いシェルに関しては Docker を使用してテストするためのスクリプトを用意しています。(contrib/test_in_docker.sh)

おわりに

以上、ShellSpec の開発で書きたかったことをざっと書いてみました。

細かいことも含めれば、ポータビリティなecho相当の関数shellspec_puts()、標準出力と標準エラー出力の両方に色を付ける方法、古いシェル対応のバッドノウハウ、外部コマンドを使えば簡単にできることをわざわざ実装してるようなものなど、いろいろとあるのですが長くなるので割愛します。興味がある方はソースコードを眺めてみると面白いかもしれません。

また ShellSpec 自体もテスティングフレームワークとして実用レベルのものになったのでないかと思います。こちらも試していただければ幸いです。