はじめに
こちらは社内技術勉強会用の資料として作成したものです。
Rustで記述されているサンプルプログラムにおいて、ジェネリクスによる方法と、mockallを使用する方法の、2種類で構造体のモックをしてみました。
サンプルプログラム
GitLabの指定のプロジェクトから、GitLab APIを使用し、Issueの一覧を取得します。Issueに設置されているラベルの種類とそれぞれの件数を集計して、集計結果をSlackに通知します。
ソースコードは こちら。
モックなし
lib.rs
にrun
関数を記述します。以下のような内容です。この関数を、main
関数から呼び出します。
use reqwest::blocking::Client;
use config::Config;
use gitlab_api::GitlabApi;
use label_list::LabelList;
use slack_notifier::SlackNotifier;
pub fn run(client: &Client) -> Result<(), error::Error> {
// 設定ファイル読み込み
let config = Config::new("./config.yml")?;
// GitLabからプロジェクトのIssue一覧を取得
let gitlab_api = GitlabApi::new(client, config.gitlab());
let issue_list = gitlab_api.issue_all()?;
// Issueの一覧からラベルの一覧を生成、それぞれの件数も集計する
let label_list = LabelList::new(&issue_list);
// 通知内容を生成
let content = label_list.format();
// Slackへ通知
let notifier = SlackNotifier::new(client, config.slack());
let resp = notifier.execute(&content)?;
println!("{}", resp);
Ok(())
}
GitlabApi
やSlackNotifier
は自前で定義している構造体です。HTTPリクエストを送信して、それぞれのサービスと通信します。
cargo run
コマンドによってプログラムを普通に実行すると、以下のようなメッセージがSlackの指定のチャンネルに届きます。「バグ」やら「テスト」やらが、Issueに設置されているラベルで、数字はそのラベルの件数です。
Demo User アプリ 18:21
バグ: 18
テスト: 22
開発: 2
設計: 2
このrun
関数を実行し、正常に終了するかどうかを確認するテストコードを書きます。
# [cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let client = Client::new();
assert_eq!(run(&client).unwrap(), ());
}
}
cargo test
コマンドを実行し、テストが成功することを確認します。
さて、run
関数の内容は何も変更していないので、テストを実行するたびにSlackにメッセージが届き、Windowsアプリもスマホアプリも、びーびーと通知を鳴らしてきます。騒々しいですね。
また、通信タイムアウトなどでテストが失敗する可能性もあります。テスト実行の時は通信が行われないようにしたいです。
まず、世の中のライブラリを使わず、自力でどうにかしてみます。
ジェネリクスで実装する
上記の元のソースではrun
関数の引数でHTTP通信を行うオブジェクトが与えられています。これを、テスト実行時とそれ以外の時で与えるオブジェクトを切り替えられるようにしてみます。
pub use lsh_client::LshClient;
pub fn run<T: RequestSender>(lsh_client: &T) -> Result<(), error::Error> {
// (中略)
// GitLabからプロジェクトのIssue一覧を取得
let gitlab_api = GitlabApi::new(lsh_client, config.gitlab());
let issue_list = gitlab_api.issue_all()?;
// (中略)
// Slackへ通知
let notifier = SlackNotifier::new(lsh_client, config.slack());
let resp = notifier.execute(&content)?;
println!("{}", resp);
Ok(())
}
run
関数の引数の型をジェネリクスとしました。通常、すなわちテスト以外ではLshClient
構造体のインスタンスが与えられるものとします。このオブジェクトが、HTTP通信を行います。ちなみにlshはサンプルプログラムLabel Summary用のHttp クライアント、という意味です。
LshClient
はRequestSender
トレイトを実装しています。
pub trait RequestSender {
fn request_get(&self, url: &str, token: &str) -> Result<LshResponse, Error>;
fn request_post(&self, json: String, token: &str) -> Result<String, Error>;
}
request_get
はGitLab API用、request_post
はSlack API用です。
LshClient
は、実際に通信を行います。ではここで、テスト用に、同じトレイトを実装している、通信は行わない構造体を作成します。
pub struct MockLshClient {
_client: Client,
}
// (中略)
impl<'a> RequestSender for MockLshClient {
fn request_get(&self, _url: &str, _token: &str) -> Result<LshResponse, Error> {
let headers = HeaderMap::new();
let list = Vec::<Issue>::new();
Ok(LshResponse::new(headers, list))
}
fn request_post(&self, _json: String, _token: &str) -> Result<String, Error> {
let resp = String::new();
Ok(resp)
}
}
request_get
は、通信は行わず、Vec::<Issue>::new()
、すなわち空のIssueのリストを返却するようにしています。request_post
も、通信を行わず、空文字列を返却します。
これをテストで使うことにしましょう。
# [cfg(test)]
mod tests {
use super::*;
use mock_lsh_client::MockLshClient;
#[test]
fn it_works() {
let lsh_client = MockLshClient::_new();
assert_eq!(run(&lsh_client).unwrap(), ());
}
}
cargo test
コマンドを実行すると、Slackへのメッセージ送信が行われないようになりました。
しかし、テストのためだけに、関連する構造体をすべてジェネリクスで書くのはなかなか大変です。先人の知恵を借りることにしましょう。
モック用のライブラリを使用してみる
モックの機能を実現するためのクレートがいくつかありますが、ここではmockallを使ってみます。
通信する処理をどうにかしたいので、LshClient
構造体に狙いを定めます。
use mockall::automock;
// (中略)
pub struct LshClient {
client: Client,
}
# [automock]
impl LshClient {
pub fn new() -> LshClient {
LshClient {
client: Client::new(),
}
}
// (中略)
}
mockallの使い方に従って、impl
に#[automock]
を記述します。ジェネリクスによる実装では自前でMockLshClient
構造体を定義しましたが、mackallでは、#[automock]
と書くと、自動的にMockLshCLient
が定義されます。これを、テスト実行時には使用します。
ただし、あくまでLshClient
とMockLshClient
は異なる構造体なので、LshClient
型の変数にMockLshClient
型のオブジェクトを代入することはできません。
mockallでは、コンパイル時に、どちらの構造体の定義を使用するのかを条件分岐で記述することで、この問題を解決しています。
cfg_if::cfg_if! {
if #[cfg(test)] {
pub use lsh_client::MockLshClient as LshClient;
} else {
pub use lsh_client::LshClient;
}
}
run
関数の引数の型は、LshClient
としておきます。
pub fn run(lsh_client: &LshClient) -> Result<(), error::Error> {
// (以下省略)
では、テストを書きます。
# [cfg(test)]
mod tests {
use super::*;
// (中略)
#[test]
fn it_works() {
let mut lsh_client = LshClient::default();
lsh_client.expect_request_get()
.returning(|_, _| {
let resp = LshResponse::new(HeaderMap::new(), Vec::<Issue>::new());
Ok(resp)
});
lsh_client.expect_request_post()
.returning(|_, _| Ok(String::new()));
assert_eq!(run(&lsh_client).unwrap(), ());
}
}
LshClient::default()
としていますが、テスト実行時はLshClient
はMockLshClient
のことを表すことになっているので、MockLshClient
のオブジェクトが生成されます。default()
メソッドも、#[automock]
により自動的に定義されたものです。
expect_request_get
のreturning
で、request_get
が呼び出された時に返却する値を指定しています。expect_request_post
も同様です。
cargo test
を実行して、Slackにメッセージが届かないことが確認できました。
おわりに
Rust初心者として、テストコードを書くためにはなんらかのモックの仕組みが必要だろうということで、その方法を調べてみました。
テストのためだけにジェネリクスを使うのは、コードが複雑になりすぎるように思うので、世の中のライブラリを活用するなどして、速やかにテストコードが書けるようになっていきたい所存です。