Rustのカバレッジを極める

R&Dチームの齋藤です。最近モニターアームを(個人的に)買ってその便利さに心底満悦しています。

さて、前回はRust言語そのものを紹介しましたが、 今回はRustにおけるユニットテストの書き方とカバレッジの測り方を、 実際に医療機器ソフトウェアの開発の中で体験した内容を使って紹介します。

f:id:optim-tech:20190219095553p:plain

ユニットテスト

某宇宙の乗り物が発進するときに設備の故障が無いか、進路上に障害物が無いかをチェックして、 そのあと「システムオールグリーン」とか言っているのを聞いたことがあるかもしれません。

ソフトウェアの世界でも戦艦と同じ様なことをやります。 ソフトウェアが意図したとおりに動いていることをチェックし、正しく動作出来るかをチェックします。 これをユニットテストや単体テストと呼びます。

Rustでは言語にユニットテスト機能が組み込まれています。 実装のすぐ近くにテストを書くことができるのでテスト駆動開発にも役立つでしょう。

テストの書き方

Rustのテストは、関数に#[test]という属性が付いている点を除いて通常の関数と全く同じです。 テストの失敗を伝える場合はパニック(エラー)を起こさせます。

#[test]
fn test_function() {
    if false {
        panic!("ここは絶対実行されないので、ここを通ったらテスト失敗!");
    }
}

また、慣習として、テストはソースファイル中のローカルなtestsモジュールにまとめることになっています。 これにより、テスト以外の設定でビルドしたときにテスト用の関数がビルド対象から除外されるメリットがあります。

#[cfg(test)]
mod tests {
    #[test]
    fn test_function() {
        if 1 + 1 == 200 {
            panic!("10倍だぞ10倍");
        }
    }
}

上記ではif文で書いていましたが、面倒なのでassert!マクロやassert_eq!マクロを使いましょう。

#[cfg(test)]
mod tests {
    #[test]
    fn test_function() {
        let answer = "人生、宇宙、そして全ての答え";
        // 意味はほとんど同じだが、所有権の関係でassert_eq!の方が好ましい
        assert!(answer.as_bytes().len() == 42);
        assert_eq!(answer.as_bytes().len(), 42);
    }
}

パニック(エラー)することをテストする場合はtest属性に加えてshould_panic属性を付けましょう。 任意でexpected引数を付ければどんな内容のパニックかを明示でき、 指定した以外のパニックを想定外のパニックとすることが出来ます。

pub fn add(a: i8, b: i8) -> i8 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "attempt to add with overflow")]
    fn test_function() {
        add(100, 100); // オーバーフローでパニック発生
    }
}

テストを実行する

実践に近い形でテストをやってみましょう。

pub struct Battleship {
    fuel_rate: f32,
    fuel_consumption: u8,
    wear_rate: f32,
    defence: u8,
    position: f32,
}

impl Battleship {
    pub fn new() -> Battleship {
        Battleship {
            fuel_rate: 1.,
            fuel_consumption: 100,
            wear_rate: 0.,
            defence: 200,
            position: 0.,
        }
    }

    pub fn fuel_rate(&self) -> f32 {
        self.fuel_rate
    }

    pub fn wear_rate(&self) -> f32 {
        self.wear_rate
    }

    pub fn position(&self) -> f32 {
        self.position
    }

    pub fn advance(&mut self, meter: f32) -> bool {
        let consuming = meter * (1. - self.fuel_consumption as f32 / 255.);
        if self.fuel_rate >= consuming {
            self.fuel_rate -= consuming;
            self.position += meter;
            true
        } else {
            false
        }
    }

    pub fn on_damaged(&mut self, damage: f32) {
        let damage = damage * (1. - self.defence as f32 / 255.);
        self.wear_rate += damage;
        self.defence -= (damage * 255.) as u8;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_fuel() {
        // 燃料に関するテスト

        let mut ship = Battleship::new();

        assert!(ship.fuel_rate() >= 1., "燃料の充填が必要です");

        ship.advance(10.);
        assert!(ship.fuel_rate() >= 0.99, "燃費が悪くなっています");
    }

    #[test]
    fn test_armor() {
        // 装甲に関するテスト

        let mut ship = Battleship::new();

        assert!(ship.wear_rate() <= 0.25, "損耗が激しいです");

        ship.on_damaged(0.5);
        assert!(ship.wear_rate() <= 0.25, "装甲が薄くなっています");
    }
}
$ cargo test
   Compiling rust v0.1.0 (...)
    Finished dev [unoptimized + debuginfo] target(s) in 4.83s
     Running target/debug/deps/rust-5bc76c46a7f8c12f

running 2 tests
test tests::test_fuel ... ok
test tests::test_armor ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

システムオールグリーン!!!

その他

テストに関するより詳細な情報は公式ドキュメントに 大変よくまとまっているので、そちらを参照してください。

ここまででテストが実行出来たので、今度はこのテストのカバレッジを測ってみましょう。

カバレッジを測る

テストを書くだけではそのテストが条件分岐などの複雑なパターンを網羅出来ているかが分かりません。 あくまでその使い方で正しく値が返ってくることを示しているに過ぎません。

そのため、製品をテストする場合などはテストと共にカバレッジの計測を行います。 カバレッジの計測により、そのテストが関数のどこまでをカバー出来ているのかが分かります。

ちなみに、カバレッジの話の中では良くC0/C1/C2という言葉が出てきます。 簡単に言えば、全行実行すれば良いのか、全ての分岐を実行すれば良いのか、全ての条件を評価すれば良いのかということです。

カバレッジツールの比較

Rust標準ではカバレッジを計測することは出来ないので、外部ツールを利用します。 下記のようなツールがあります。

  • Tarpaulin
  • cargo-cov
  • cargo-kcov

Tarpaulin

x86_64なLinuxのみに対応しています。 調べたら真っ先に出てくるツールだと思います。 ソースコードの一部を除外する機能としてRustの属性を使っているのは他のツールと比べて見た目に美しいです。

ただし、当時は実行されている行なのに実行されていない扱いになるバグ?に遭遇したため採用しませんでした *1 *2

f:id:optim-tech:20190124190131p:plain f:id:optim-tech:20190124190135p:plain

cargo-cov

外部ツールに依存しない上に多くのプラットフォームに対応しているなどは大変良いですが、 ソースコードの一部をカバレッジ計測から除外する機能が無いため選択肢から除外しました。

cargo-kcov

上記と同じ作者のツールです。

その名の通りkcovのラッパーであり、インストールに多少手間取る可能性はあるものの、 kcovの豊富な機能がそのまま使えて一番使い勝手の良いカバレッジ測定ツールだと思います。 難点としてはmacでは現実的に利用が出来ないことです *3

今回はこのcargo-kcovを使ってカバレッジの測定をやっていきます。

cargo-kcovのインストール

まずはkcovの依存ライブラリをインストールしましょう。 多くの場合は公式の手順で良いようですが、 最小構成ではいくつかのパッケージが足りませんでした。 Ubuntuの場合は下記でインストールすれば良いようです。

$ sudo apt install -y cmake curl g++ jq python binutils-dev libcurl4-openssl-dev zlib1g-dev libdw-dev libiberty-dev

依存ライブラリがインストール出来たらcargo-kcovをインストールします。 その後、cargo-kcovが提供しているkcovインストールスクリプトを実行してkcovもインストールしましょう。

$ cargo install cargo-kcov
$ cargo kcov --print-install-kcov-sh | sh

以上でセットアップは完了です。

カバレッジを計測する

プロジェクトディレクトリでcargo kcovコマンドを実行するだけでもカバレッジの計測は出来ますが、 ビルド状況が出なくて我慢ならないので先に自分でビルドします。 なお、この際正しいカバレッジを出すために事前にcargo cleanコマンドを実行して一からビルドし直す必要があります。

$ cargo clean
$ RUSTFLAGS="-C link-dead-code" cargo test --no-run

ビルドできたのでユニットテストを実行してカバレッジを計測します。

$ cargo kcov --no-clean-rebuild --verbose --all --no-fail-fast
  • --no-clean-rebuild
    • これを指定しないとせっかく事前にビルドした内容を窓から投げ捨ててしまいます。 でかいプロジェクトでこれを付け忘れると割と泣けます
  • --verbose
    • デフォルトだと全然進捗状況が読めないのでいつもより多くログを回してもらいます
  • --all
    • workspaceの機能を 使わないときは必要無いですが、一応付けておきます
  • --no-fail-test
    • 失敗するユニットテストがあっても実行を継続し、全てのテストを実行するようにします

うまく行くと、target/cov/以下にカバレッジレポートが生成されます。

f:id:optim-tech:20190124190123p:plain

コードを修正した場合、多くはリビルドが必要になりますが、テストコードの微修正などでは リビルドする必要はないためcargo kcov --no-clean-rebuild ...を使って インクリメンタルビルドすると良いでしょう。

コード片を無視する

kcovには--exclude-region=start:stop[,...]--exclude-line=pat[,...]といった引数でコード片を無視する機能があるのでこれを使ってみます。

  • --exclude-region=start:stop[,...]
    • startが書かれた部分からstopが書かれた部分までを無視する。カンマ区切りで複数のパターンを指定出来る
  • --exclude-line=pat[,...]
    • patを含む行を無視する。カンマ区切りで複数のパターンを指定出来る

cargo kcovコマンドに渡した引数の--以降がkcovコマンドにそのまま渡されるので、下記のように実行します。

$ cargo kcov --no-clean-rebuild --verbose --all --no-fail-fast -- \
    --exclude-region=kcov-ignore-begin:kcov-ignore-end --exclude-line=kcov-ignore-line

上記コマンドで、下記のような感じでコード片が無視出来るようになります*4

use std::num::NonZeroUsize;

fn add_one_nonzero(a: NonZeroUsize) -> NonZeroUsize {
    // kcov-ignore-begin: unreachable
    if a.get() == 0 {
        unreachable!();
    }
    // kcov-ignore-end

    debug!("{:?}", a); // kcov-ignore-line: debug code

    NonZeroUsize::new(a.get() + 1).unwrap()
}

f:id:optim-tech:20190124190127p:plain

ファイルを無視する

--include-path--exclude-pathでカバレッジを計測するファイルを指定出来ます。

デフォルトでは依存ライブラリまでカバレッジを計測してしまうため、プロジェクト以下を対象にしましょう。

$ cargo kcov --no-clean-rebuild --verbose --all --no-fail-fast -- \
    --include-path=. --exclude-path=./target,/path/to/directory

カバレッジ結果を標準出力に出す

最終的なカバレッジの各種データがtarget/cov/kcov-merged/coverage.jsonに出力されるので、 jqコマンドを使って抜き出します。

$ cat target/cov/kcov-merged/coverage.json | \
    jq -r '[.percent_covered, .covered_lines, .total_lines] | @sh' | \
    tr -d \' | awk '{ printf "coverage: %s%% (%d / %d)\n", $1, $2, $3 }'
coverage: 100.00% (7 / 7)

Dockerの中で実行する

cargo-kcovに限らず多くのカバレッジ測定ツールは、Dockerで実行するには引数に--security-opt seccomp=unconfinedが必要です。 例えばこんな感じです。

$ docker run -it --rm rust --security-opt seccomp=unconfined -v $PWD:/tmp/project -w /tmp/project my-image cargo kcov ...

ちなみに、上記では-vを使ってカレントディレクトリをボリュームでマウントしていますが、 これだとビルドに非常に時間が掛かるのでCOPYしてからビルドすると良いでしょう。

CIで勝手にカバレッジ

Dockerで実行出来るようになったので、CIで実行出来るようにしましょう。

オプティムの実際のプロジェクトでは、GitLab CIを使って下記のように同時にrustfmtを実行するようにしていました *5。 なお、rustfmtが正式版になった現在ではcargo fmtのコマンド部分をcargo fmt --all -- --checkに修正する必要があります。

image: rust:1.27
before_script:
- rustup component add rustfmt-preview
- apt-get update && apt-get install --no-install-recommends -y
    cmake jq python binutils-dev libcurl4-openssl-dev zlib1g-dev libdw-dev libiberty-dev
- cargo install cargo-kcov
- cargo kcov --print-install-kcov-sh | sh
test+coverage:
  script:
  - rustup --version && rustc --version && cargo --version
  - cargo fmt --all -- --write-mode=diff
  - RUSTFLAGS="-C link-dead-code" cargo test --no-run
  - cargo kcov --no-clean-rebuild --verbose --all --no-fail-fast --output build/ --
    --verify --include-path=. --exclude-path=./target
    --exclude-region=kcov-ignore-begin:kcov-ignore-end --exclude-line=kcov-ignore-line
  - >-
      cat build/kcov-merged/coverage.json |
      jq -r '[.percent_covered, .covered_lines, .total_lines] | @sh' |
      tr -d \' | awk '{ printf "coverage: %s%% (%d / %d)\n", $1, $2, $3 }'
  coverage: '/coverage: (\d{1,3}\.\d{2})%/'
  artifacts:
    paths:
    - build

さいごに

テストを書くことでアルゴリズムの間違いに気づけるだけでなく、カバレッジによってテストの妥当性も評価出来るようになります。 皆さんもRustで安心安全なプログラムを書きませんか?

オプティムではRustが大好きなエンジニアに加え、テストが大好きなエンジニアも募集しています。

ライセンス表記

  • 冒頭の画像中にはRust公式サイトで配布されているロゴを使用しており、 このロゴはMozillaによってCC-BYの下で配布されています

*1:恐らくhttps://github.com/xd009642/tarpaulin/issues/136の件

*2:Rust 1.32、Tarpaulin 0.7.0 現在は下記コードでは再現しないようです

*3:READMEに「Be aware that macOS performance is significantly slower when compared with Linux.」との記述があります

*4:無視する理由を記述したかったのでコロンで区切って理由を記述しています

*5:Rustが1.27と若干古く、歴史を感じますね :)