見出し画像

Rust 小プロジェクトの大雑把な作り方(2) テストを書く

昨日の投稿の続きです。

全投稿の予告通り、「先にテストを書く」という内容から書き出してみたいと思います。

ソースコード全体はこちら


試しにシェルスクリプトで素朴にテストを書いてみる

題材にしている csvprint というプログラムは、CSV を標準入力から読んで形式を変えて標準出力に吐き出すだけの単純なプログラムです。

この要件に対して素朴にシェルスクリプトでテストを書いてみると例えばこんな感じになります。

test-script-sample.sh

#! /bin/bash
unalias -a

# note 執筆用に作成したテスト
# やっている内容は tests/cli.rs > test_stdinout と同じ

cargo build
mkdir -p ./tests/temporary
input=./tests/input/testin.csv # 事前に準備したインプット
expected=./tests/expected/testout.txt # 事前に準備した結果(期待)
result=./tests/temporary/output.txt # 実際の実行結果を保存するファイル

# ビルドしたプログラムを使って処理を行った結果をファイルに保存
<$input ./target/debug/csvprint.exe >$result

echo "期待値 '$expected' と実行結果 '$result' を比較します。"
if diff $expected $result
then
    echo "OK"
else
    echo "FAIL"
fi

このようなアドホック(特定の目的のための、その場限りの、取ってつけたような)なシェルスクリプトも役に立つことがあります。

まず、「自分がなにをしようしているのか」を非常に明確にしてくれます。この後、Rust のテスト機構を使った書き方を示しますが、その前提としてテストの仕様や要件を固めておくのに役に立ちます。

それから、アドホックなものとは言え、今回題材にしている程度の小プロジェクトであればこれで十分ということもよくあります。

最後に、なにもテストを書かないよりはずっとマシです。
Rust のテストの書き方を知らないからと言って「先にテストを書く」というベターな手法を捨てるのはもったいないです。

ユニットテストの例

外部の入出力(標準入出力、ファイルなど)が介在しない関数のテストであればユニットテストというシンプルな形で書くことができます。

ユニットテストの例を示します。

fn get_10() -> i32 {
    10
}

#[test]
fn test_get_10() {
    assert_eq!(10, get_10());
}

Rust Playground

上記の例では引数なしで呼ばれたら 10 を返す関数 get_10() を定義し、それを呼んだ結果と、数字の 10 を比較しています。

get_10() は、呼び出し時の引数が同じなら結果も同じことが保証される = 副作用のない関数です。このような関数の場合はテストを環境に依存せず、また、get_10() のみにターゲットを絞って単体&独立して書くことができます。

ある意味、ユニットテストの書き方・考え方は単純です。

プロジェクトが大きくなるほど、プロジェクトを構成する個々の関数はなるべくユニットテストが可能なように実装し、副作用のある関数のグループを絞り込んでいくような制作手順を踏んでいくと、コードベース全体のスパゲッティ化を未然に防ぐことができます。

今回題材にしている csvprint プロジェクトではユニットテストを書いてません。今後改善や機能追加していく過程で書いていくかもしれませんが、Version 0.1.0 の段階ではユニットテストは書きませんでした。

というわけで、ユニットテストについては上記の get_10() の例でサラッと済まし、外部からの入出力の関係する = 副作用が絡むテストについて書きたいと思います。

冒頭の方で示した test-script-sample.sh と同様のことを Rust でテストする方法についてです。

assert_cmd を使用したテスト

Rust コードの中で標準出力をキャプチャし、期待値として事前に用意したファイルの内容と比較できれば、Rust のコード内でテストが完結するのでわざわざ別途シェルスクリプトを用意したりする必要がなくなりますよね。

この需要を満たしてくれるのが assert_cmd というライブラリです。

標準ライブラリではないので依存関係に追記する必要がありますが、ちょっと考えてみると、これってテストに必要なだけで csvprint そのものの動作にはなくてもいいライブラリになりますよね?

なので、通常の [dependencies] とは別に 

[dev-dependencies]

というのを作ってそっちに書きます。

Cargo.toml

# -- snip --
[dependencies]
csv = "1.1"

[dev-dependencies]
assert_cmd = "2"

その上で、一度 cargo buildcargo run もしくは cargo test してみます。→ 前投稿参照

この投稿を頭から読んでいれば背景を全部知ったことになるので、コメントなどは全く書いてませんが、意図をそのままコード化したテストがこちらになります。

tests/cli.rs

use assert_cmd::Command;
use std::fs;

type TestResult = Result<(), Box<dyn std::error::Error>>;

#[test]
fn test_stdinout() -> TestResult {
    let input = fs::read_to_string("tests/input/testin.csv")?;
    let expected = fs::read_to_string("tests/expected/testout.txt")?;
    let mut cmd = Command::cargo_bin("csvprint")?;
    cmd.write_stdin(input).assert().success().stdout(expected);
    Ok(())
}

test-script-sample.sh をほとんどそのまま Rust でやっているのがわかるでしょうか?

test-script-sample.sh を再掲します。

#! /bin/bash
unalias -a

# note 執筆用に作成したテスト
# やっている内容は tests/cli.rs > test_stdinout と同じ

cargo build
mkdir -p ./tests/temporary
input=./tests/input/testin.csv # 事前に準備したインプット
expected=./tests/expected/testout.txt # 事前に準備した結果(期待)
result=./tests/temporary/output.txt # 実際の実行結果を保存するファイル

# ビルドしたプログラムを使って処理を行った結果をファイルに保存
<$input ./target/debug/csvprint.exe >$result

echo "期待値 '$expected' と実行結果 '$result' を比較します。"
if diff $expected $result
then
    echo "OK"
else
    echo "FAIL"
fi

シェルスクリプトのほうで

# ビルドしたプログラムを使って処理を行った結果をファイルに保存
<$input ./target/debug/csvprint.exe >$result

して

echo "期待値 '$expected' と実行結果 '$result' を比較します。"
if diff $expected $result
then
    echo "OK"
else
    echo "FAIL"
fi

しているところを、Rust では($result に相当する一時ファイルを作らずに)下記のコードの部分で実現しています。

let mut cmd = Command::cargo_bin("csvprint")?;
cmd.write_stdin(input).assert().success().stdout(expected);

このテストコードを走らせるには cargo test をコマンドラインで実行します。

実行コードが実装されていない時点の段階ではテストは失敗します。
失敗しないとおかしいです。

この最初は失敗に終わったテストが成功するようにコードを実装していきます。

このように先にテストを書いて、そのテストを満足させるようにコーディングを進めていく手法をテスト駆動開発(TDD)と言います。

***

次回は Rust のコードの中身に入っていければと思います。

SN

参考図書


この記事が気に入ったらサポートをしてみませんか?