Rustの新しいコードカバレッジ/Source-based code coverage


2022/4/8 追記

Rust 1.60.0にてSource-based code coverageが安定化しました。また、cargoからSource-based code coverageを扱えるツールとしては cargo-llvm-cov があり、以下のように簡単に使えるようになっています。

$ cargo install cargo-llvm-cov
$ rustup component add llvm-tools-preview
$ cd RUST_PROJECT
$ cargo llvm-cov

はじめに

この記事はRust2 Advent Calendar 2020の2日目です。

RustのコードカバレッジはこれまでGCC互換のDebugInfoを用いたものが広く使われてきました。これを使ってコードカバレッジを取るツールとしてcargo-tarpaulincargo-kcovcargo-covといったものがあります。

実はRustがコンパイラバックエンドとして用いているLLVMは、Source-based code coverageというもう一つのコードカバレッジ取得方式をサポートしています。これをRustでも利用したいというリクエストは2016年頃からあったものの、なかなか実装まで進まないという状況が続いていました。しかし2020年の春ごろから実装が開始され、先日(2020/11/7)ついに"feature complete"(つまり実装完)が宣言されました。これによりnightly限定ではありますが、このSource-based code coverageを試すことができるようになっています。

この記事ではSource-based code coverageが既存のカバレッジと比べてどのように優れているのかという点と、実際にnightlyコンパイラを使ってカバレッジを取得する方法について説明します。

Source-based code coverage

Source-based code coverageが既存の方式に比べてどのように優れているか、という点については以下のカバレッジレポートを見れば分かりやすいです。

これは一番左が行番号、次がその行の実行回数、というよくあるカバレッジレポートですが、注目すべきは赤くハイライトされた部分です。これは赤い部分が未実行であることを示しています。例えば?演算子が軒並み赤くなっていますが、これは「?演算子によるエラー時のearly returnが実行されていない」という意味です。従来のカバレッジは行単位で実行・未実行を取得するものでしたが、Source-based code coverageではこのように行内の特定の領域の実行・未実行まで取得することができます。(これをリージョンカバレッジといいます)

なぜこのようなことができるかというと、コンパイルのかなり早い段階でカバレッジ取得用の命令を挿入しているためです。
具体的にはMIRというコンパイラ内の中間表現で行っています。この段階ではまだRustの文脈が保たれており、文脈に応じた命令挿入が可能です(その後のLLVM-IRまでいくとほぼ機械語なので、ソースコード上どうであったかはほとんど分かりません)。LLVMはカバレッジを取るための疑似命令を提供しているので、その命令をMIRの段階で挿入しておけば、最終的にLLVMが適切なカバレッジ取得コードを生成します。

また、この方式のもう一つの利点はプラットフォーム依存が少ないという点です。従来のカバレッジ取得ツールは生成されたバイナリに対して操作を行う性質上、どうしても特定のプラットフォームに限定されることがありました。それに対してこの方式はLLVMが対応するプラットフォームであれば原理的には何でも対応可能と思われます。RustはTier3まで含めると非常に幅広いプラットフォームをサポートしていますし、Tier1にaarch64が入るなど、x86_64/Linuxだけを考えていればいいというわけではありません。そのためこのような性質は今後重要になってくると思われます。

カバレッジの取り方

この新しいカバレッジを試すにはnightlyコンパイラが必要です。Rustのインストールにrustupを使っていれば以下のようにnightlyコンパイラをインストールすることができます。

$ rustup install nightly

すでにnightlyコンパイラを使っている方はrustup updateでアップデートしてください。(2020/11/8以降のnightlyであれば動くと思います)

さらに追加で必要なツール類をインストールします。
カバレッジデータの加工やレポート生成を行うためのLLVMコマンドllvm-cov, llvm-profdataと、レポートでRustの関数名を読みやすくするためのツールrustfiltです。

$ rustup component add --toolchain nightly llvm-tools-preview
$ cargo install rustfilt

あとはcargoに以下のようにオプションを渡してビルドします。

$ RUSTFLAGS="-Zinstrument-coverage" cargo +nightly build

このように+nightlyを付けることで、一時的にnightlyコンパイラに切り替えることができます。
この状態でビルドしたバイナリを実行すると、カレントディレクトリにdefault.profrawというカバレッジデータを格納したファイルが生成されます。もし出力するファイル名を指定したい場合はLLVM_PROFILE_FILE環境変数で指定可能です。
例えば、いろいろな入力ファイルを渡した時のデータを取りたい場合は以下のようにします。

$ LLVM_PROFILE_FILE="test1.profraw" target/debug/binary test1.txt
$ LLVM_PROFILE_FILE="test2.profraw" target/debug/binary test2.txt
$ LLVM_PROFILE_FILE="test3.profraw" target/debug/binary test3.txt

次にカバレッジデータのマージを行います。上記の例では3つのデータが生成されたので、それを1つにまとめます。
profrawprofdataはフォーマットが違うので、データ1つの場合でもこの工程は必要です)

$ llvm-profdata merge -sparse test1.profraw test2.profraw test3.profraw -o merged.profdata

このllvm-profdata(と次で使うllvm-cov)がインストールされる場所はデフォルトではパスが通っていないので、~/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/bin/にパスを通すか直接指定しましょう。

最後にレポート生成です。以下のコマンドで冒頭に示したようなソースコード表示ができます。

$ llvm-cov show target/debug/binary \
    -Xdemangler=rustfilt \
    -instr-profile=merged.profdata \
    -show-line-counts-or-regions \
    -show-instantiations \
    --ignore-filename-regex="(.cargo|rustc)"

引数がたくさんありますが、必須なのはバイナリの指定と-Xdemangler及び-instr-profileです。また、デフォルトでは依存するクレートの結果も含めてすべて出てしまうので出す範囲を制限するのがいいでしょう。例えば--ignore-filename-regex="(.cargo|rustc)"ならファイルパスに.cargorustcが含まれるものは除外されるので、自分のクレートの結果だけが表示されます。-nameを使って特定の関数だけを表示することも可能です。

他にもllvm-cov reportで「カバー率何%」というようなサマリを出すこともできます。

cargo-tarpaulinとの比較

実際のプロジェクトでcargo-tarpaulinと比較してみました。

cargo-tarpaulinの結果は以下のようになっています。

$ cargo tarpaulin
...

Nov 13 12:32:06.547  INFO cargo_tarpaulin::report: Coverage Results:
|| Tested/Total Lines:
|| src/exporter.rs: 0/137
|| src/main.rs: 0/8
|| src/softether_reader.rs: 160/214
||
44.57% coverage, 160/359 lines covered

これに対してSource-based code coverageの結果は以下です。

$ llvm-cov report -Xdemangler=rustfilt target/debug/deps/softether_exporter-82559dc25edd0b97 -instr-profile=default.profdata --ignore-filename-regex="(.cargo|rustc)"
Filename                      Regions    Missed Regions     Cover   Functions  Missed Functions  Executed       Lines      Missed Lines     Cover
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
exporter.rs                        60                60     0.00%           3                 3     0.00%         128               128     0.00%
main.rs                            20                19     5.00%           6                 5    16.67%          15                14     6.67%
softether_reader.rs               377               193    48.81%          12                 2    83.33%         361                61    83.10%
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
TOTAL                             457               272    40.48%          21                10    52.38%         504               203    59.72%

ラインカバレッジについては、ラインの数え方が違うものの、概ね同じような結果となっています。それに対して、Source-based code coverageが出すリージョンカバレッジは大きく下がっており、これは主に?演算子によるエラーハンドリング系のテストがないことが原因です。

また、この比較のためにいろいろなプロジェクトでcargo-tarpaulinを試行しましたが、FFIや外部コマンド呼び出しなどで失敗するケースがいくつかありました。Source-based code coverageはそれらのケースでも問題なく実行できており、この点でも有用ではないかと思います。

今後について

現時点では特に安定化の見込みなどは立っていないと思われます。特にカバレッジデータのフォーマットがLLVMのバージョン依存らしく、早期の安定化は難しそうです。一方cargo-tarpaulinの作者xd009642氏はこれに対応したい旨のIssueを立てており、近いうちにcargoサブコマンドから簡単に使えるようになるかもしれません。