ShellSpecのカバレッジ機能がzsh,kshに対応（Kcovをbash以外に対応させる方法）

## はじめに

[Kcov](https://github.com/SimonKagstrom/kcov) は Bash（と Python）に対応しているカバレッジ測定ツールです。Kcov が本来対応しているのは bash だけなのですが bash と同様の出力を行うことで（Kcov と統合している）[ShellSpec](https://github.com/shellspec/shellspec) のカバレッジ測定機能を zsh と ksh に対応させることができたのでその情報を共有します。

なおこの記事の内容は開発者向けの技術情報です。この記事を読んだだけでは素の Kcov で zsh と ksh のカバレッジは取れません。対応にはある程度コードを書く必要があるのでご了承ください。（さくっとカバレッジを取りたいなら ShellSpec をご利用ください。zsh と ksh のカバレッジ測定に対応してるバージョンは現時点では master のみです。特に問題がなければ 0.25.0 で正式にリリースする予定です。）

## きっかけ

ShellSpec は POSIX 準拠のシェル（bash 以外、dashやzshも含みます）に対応しているテスティングフレームワークですが Kcov と統合しており簡単にテスト対象のシェルスクリプトのカバレッジを測定する事ができます。Kcov が公式に対応しているのが bash だけなので ShellSpec でもカバレッジの測定は bash のみのサポートでしたが、殆どの場合シェルスクリプトは POSIX 準拠か bash 用として記述されるのでそれで十分だろうと考えていました。

対応しようと思ったきっかけはついこの間 reddit に投稿された bash テストフレームワークの比較記事「[Bash test framework comparison 2020](https://www.reddit.com/r/bash/comments/gdf96t/bash_test_framework_comparison_2020/)」です。この記事で ShellSpec を取り上げてもらい、そのコメントで関連する話題として zsh の方がより正確にカバレッジを測定できることを知りました。bash が正確じゃないことには気づいていたのですが、カバレッジはテストしてない箇所を見つけるための目安だと思っているので概ね正しければ問題ないと特に対応を考えてはいませんでした。しかし zsh は macOS のデフォルトのユーザーシェルなので利用者もそれなりにいるでしょうし、より正確な方がいいに越したことはありません。（なお ksh の対応はおまけです。笑）

元々 ShellSpec は 拡張性を重視した設計になっており、カバレッジも Kcov 以外に対応可能な設計にしていました。それが功を奏して3日ほどで対応することができました。（どちらかというと ksh のバグのワークアラウンドを探すのに一番時間がかかった気がします・・・）


## Kcov のカバレッジ測定の仕組み

Kcov が対応しているカバレッジは「ステートメントカバレッジ（C0、命令網羅）」で実行可能な命令をどれだけ実行したかという単純な測定です。カバレッジを測定するためには、どの行（ファイル名と行番号）を実行したかの情報が必要になりますのでどうにかしてこの情報を取得する必要があります。実はその方法は簡単で bash（および zsh と ksh）にはそのための機能が搭載されています。

### DEBUG トラップと PS4

カバレッジ測定に使える機能は DEBUG トラップと PS4 の 2 通りの方法があります。DEBUG トラップは `trap` を使って疑似シグナル DEBUGをトラップすることにより命令実行前に、実行する命令の行番号などを取得することが出来ます。PS4 は `set -x` によるトレース出力のフォーマットを変更するための環境変数で、カスタマイズすることで実行する命令の行番号などの情報を追加することが出来ます。

どちらを使ってもカバレッジ測定を実現することができます。Kcov は両方に対応しておりデフォルトでは PS4 を使用します。（ただし ShellSpec では後述する理由により DEBUG トラップを使用しています。）

### BASH_ENV

Kcov 経由でシェルスクリプトを実行すると自動的に DEBUG トラップ または PS4 (`set -x`) が有効になります。Kcov ではこれを実現するために `BASH_ENV` という環境変数を使用しています。環境変数 `BASH_ENV` にシェルスクリプトのパスが入っていると、該当のシェルスクリプト実行前に `BASH_ENV` で指定されたシェルスクリプトが自動的に実行されます。Kcov によって実行されるコードを以下に示します。（PS4 用のコードが少ないのは環境変数 `PS4` は Kcov 側で用意しているからです。）

PS4 版 [bash-helper.sh](https://github.com/SimonKagstrom/kcov/blob/master/src/engines/bash-helper.sh)

```sh:bash-helper.sh
#!/bin/sh

# Turn on tracing
set -x
```

DEBUG トラップ版 [bash-helper-debug-trap.sh](https://github.com/SimonKagstrom/kcov/blob/master/src/engines/bash-helper-debug-trap.sh)

```sh:bash-helper-debug-trap.sh
 #!/bin/sh

set -o functrace
trap 'echo "kcov@${BASH_SOURCE}@${LINENO}@" >&$KCOV_BASH_XTRACEFD' DEBUG
unset BASH_ENV
```

### カバレッジ測定データ

PS4 版はこれだけみても何をやってるのかわかりませんが DEBUG トラップ版から読み取ることが出来ます。DEBUG トラップで`kcov@${BASH_SOURCE}@${LINENO}@` というフォーマットで環境変数 `KCOV_BASH_XTRACEFD` に指定されている番号のファイルディスクリプタに出力しています。これがカバレッジ測定を行うためのデータとなります。PS4 版ではこれと同等のことを行うために 環境変数 `PS4` に `kcov@${BASH_SOURCE}@${LINENO}@`、環境変数 `BASH_XTRACEFD` に出力先のファイルディスクリプタが設定されています。

DEBUG トラップ版 と PS4 版の違いですが、まず一つ目は DEBUG トラップ版では `kcov@${BASH_SOURCE}@${LINENO}@` （ソースコードと行番号）のみが出力されるのに対して PS4 版はトレース出力のプレフィックスになるため以下のように `kcov@${BASH_SOURCE}@${LINENO}@` に続いて実行命令が出力されます。

```
kcov@/tmp/script1.sh@3@echo test
```

もう一つは 環境変数 `BASH_XTRACEFD` を使用しているという所です。変数名からもわかるようにこれは bash の環境変数で `set -x` のトレースの出力先をデフォルトの標準エラー出力から任意のファイルディスクリプタに変更するためのものです。

### まとめ

Kcov のカバレッジ測定の仕組みは実はこれだけです。もちろんこの後、取得した測定データとソースコードを突き合わせてレポートを作成したりしているわけですが、測定そのものは bash の機能を使い実行する行番号を特別なファイルディスクリプタに出力するだけというシンプルな仕組みです。

## ShellSpec と Kcov の統合の仕組み

単純なシェルスクリプトであれば Kcov 経由で実行するだけでカバレッジを取れるのですが ShellSpec は複数のプロセスを組み合わせて処理を行っているため単純に Kcov 経由で実行してもうまくいきません。そのため正しくカバレッジが取れるように ShellSpec と Kcov をうまく統合させています。またカバレッジ測定時間を短くするため（意味がない）フレームワーク部分のカバレッジを計測をしないように最適化を行っています。

私見ですが世の中にあるテスティングフレームワークの多くはカバレッジ機能を標準でサポートしてないものが多いと思います。ですがこのような最適化処理をフレームワークに手を入れずに行うのは難しいので、カバレッジ機能はテスティングフレームワークに統合させたほうが良いと思います。テスティングフレームワークとカバレッジは別の機能であるというのは承知していますが、内部を知らない利用者がうまく統合させるのはかなり骨が折れる作業です。

### DEBUG トラップを使ってる理由

ShellSpec ではカバレッジ測定に PS4 版ではなく DEBUG トラップ版を使用しています。1 つ目の理由は PS4 版を使用できる条件の一つである  `BASH_XTRACEFD` が bash 4.2 以上でないとサポートされていないからです。（正確には `BASH_XTRACEFD` が搭載されたのは 4.1 ですがバグがあるらしいです。）macOS では bash のバージョンが 3.2 なのでサポートできないことになります。（もっとも Kcov は `BASH_XTRACEFD` が使えない場合は標準エラー出力を使うようにフォールバックするのですが、意図的に標準エラーに出力したものと内容が混ざってしまうので正しく処理するのが難しくなります。）

2 つ目の理由は、当初使っていた Kcov v36 に PS4 を正しくパース出来てないバグが有ったからです。PS4 の場合ソースコードと行番号に続いて実行命令が出力されるのですが、例えば1ステートメントの中に改行などが含まれている場合に出力が複雑になります。例えば以下の行を実行すると出力の内容は以下のようになります。文字列の中にクォート文字が含まれるとさらに複雑になります。

```sh
PS4='kcov@${BASH_SOURCE}@${LINENO}@'
set -x
echo "line1
line2"
```

```sh:標準エラー出力のみ
kcov@/tmp/script1.sh@4@echo 'line1
line2'
```

このバグについては、[このPR](https://github.com/SimonKagstrom/kcov/pull/304) で修正したのですが、そもそもカバレッジで必要となるのは、ソースコードのファイル名と行番号のみです。PS4 で出力される実行命令はそもそも不要であり、逆に余計な行の出力とパースで遅くなるだけです。というパフォーマンス上の理由が DEBUG トラップ版を使用している 3 つ目の理由です。

## Kcov を zsh と ksh に対応させる方法

ここまでの説明でなんとなく想像はつくと思いますが、Kcov に zsh と ksh のカバレッジを測定させるには `kcov@${BASH_SOURCE}@${LINENO}@` というフォーマットで出力すればいいだけです。zsh と ksh でも DEBUG トラップ版を使用します。（そもそも zsh と ksh には `BASH_XTRACEFD` に相当する機能がありません。[ZSH_XTRACEFD](http://www.zsh.org/mla/workers/2019/msg00353.html)は現在実装中らしいです。）

### DEBUG トラップの zsh 版と ksh 版

では DEBUG トラップの zsh 版と ksh 版 です。zsh 版は長いですが素直です。ksh 版はバグとの戦いでした・・・。

zsh版

```sh
trap '
  if [ ${#funcsourcetrace[@]} -gt 0 ]; then
    echo kcov@${funcsourcetrace[1]%:*}@$((${funcsourcetrace[1]##*:}+LINENO))@
  else
    echo kcov@${0}@${LINENO}@
  fi >&$KCOV_BASH_XTRACEFD
' DEBUG   
```

ksh版

```sh
trap '(echo "kcov@${.sh.file}@${LINENO}@" >/dev/fd/$KCOV_BASH_XTRACEFD); IFS=$IFS' DEBUG
```

zsh 版は実行命令が関数の中にある外にあるかで行番号の取得の仕方が異なるのでそれに合わせて処理を変えているだけです。ksh 版は まず `>&$KCOV_BASH_XTRACEFD` という書き方ではファイルディスクリプタは `9` までしか使えないので代わりに ` >/dev/fd/$KCOV_BASH_XTRACEFD` を使います。（POSIX 準拠ではないようですが殆どの環境で使えるらしいです。未確認）

他、ksh のバグとワークアラウンドを軽く紹介します。

```sh
trap 'echo trap $LINENO >/dev/tty' DEBUG # リダイレクトするとなぜか DEBUG トラップが無効になる
var=$(echo) 
echo foo
echo bar
echo baz

# ワークアラウンド
# trap '(echo trap $LINENO >/dev/tty)' DEBUG 
```

```sh
trap ':' DEBUG
(
  trap ':' DEBUG # サブシェルで再定義してはいけない
)
echo test

# $ LANG=C ksh script.sh  # 実行すると以下のような意味不明なエラーが出る
# ksh.sh[5]: ?@P(文字化け): not found [No such file or directory]
# ワークアラウンド 再定義しないように工夫する
```

```sh

trap '(: $$)' DEBUG # トラップの中で変数を参照するとIFSが機能しなくなる
A="a b c"
set -- $A
printf '%s\n' "$@" # => a b c

# ワークアラウンド
# IFS の内部状態が混乱しているようなので同じ値を再設定して状態を回復させる。（という理屈なのだと思う。）
# trap '(: $$); IFS=$IFS' DEBUG

# 以下のワークアラウンドでも対応できるが、本物のサブシェルを生成するため遅くなる
# trap '(ulimit -t unlimited; : $$)' DEBUG
# ulimit -t unlimited は ksh のサブシェル最適化を無効にするためのテクニック
```

### BASH_ENV 相当の機能

Kcov 経由でシェルスクリプトを起動すると `BASH_ENV` のスクリプトが実行され自動的に DEBUG トラップが有効になります。zsh と ksh でも `BASH_ENV` 相当の機能があり zsh では `$ZDOTDIR/.zshenv` が読み込まれるので任意の場所に `.zshenv` を配置して環境変数 `ZDOTDIR` を設定することで読み込ませています。ksh では環境変数 `ENV` にスクリプトのパスを設定し ksh を `-E` オプション付きで起動すると読み込まれます。

なお実はこれらの機能は（スクリプト開始時には）使っていません。遅くなるのでフレームワークの部分のカバレッジを計測させたくないからです。そのため bash の場合でも起動したらすぐに DEBUG トラップを無効にしており本当に必要な部分でだけ改めてに有効にしています。ただし`BASH_ENV` および `BASH_ENV` 相当の機能を全く使っていないわけではなく、外部スクリプト（テストコードの中から更にシェルスクリプトを実行する場合）のカバレッジを計測するために使っています。

### Kcov バージョンの注意点

特にこだわりがないのであれば、Kcov のバージョンは v37 以降をおすすめします。それ以前だと ksh で実行した時に `version         sh (AT&T Research) 93u+ 2012-08-01` というメッセージが標準エラー出力に出力されます。これは Kcov が bash のバージョンチェックのために `bash --version` （ksh を使った場合は `ksh --version`）を実行しているからで一般的にはバージョン番号は標準出力に出力されるべきものですが ksh ではなぜか標準エラー出力に出力されます。

Kcov v37 ではこのバグが[修正](https://github.com/SimonKagstrom/kcov/pull/291)されています。元々はこのバグは日本語環境において `bash --version` で出力されるメッセージが日本語化されているために正しくバージョン判定が出来なかったものを修正したものなのですが、想定外の所で役に立ちました。ちなみに ShellSpec では別のバグのワークアラウンドとしてこのような想定外の標準エラー出力を消せるようになっているため Kcov v35 以上に対応しています。

## さいごに

ということで ShellSpec のカバレッジ測定機能を zsh と ksh に対応させた記念として Kcov の技術情報の記事でした。zsh に対応したテスティングフレームワークには [zunit](https://github.com/zunit-zsh/zunit) がありますがカバレッジ対応は [Issue](https://github.com/zunit-zsh/zunit/issues/9) があるだけですし、なにげに zsh と ksh のカバレッジ測定に対応したのは世界初なんじゃないか？って思っています。今は カバレッジ測定に Kcov を利用していますが将来的にはシェルスクリプトネイティブで実装したいですね。カバレッジ測定部分のコードは今のものが流用できますし技術的な課題はないと思いますが、レポート作成の処理やCIに対応させるためのカバレッジデータの作成が面倒なのでやったとしても相当あとになるでしょう。テストと違いカバレッジは実機ではなく開発マシンで実行しても同じ結果が得られるはずなので Kcov に依存しても環境によってはインストールが面倒になることぐらいでさほど困ることはないと思いますし。