15
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Rustで構造体をモックする方法を調べる

Last updated at Posted at 2020-04-11

はじめに

こちらは社内技術勉強会用の資料として作成したものです。
Rustで記述されているサンプルプログラムにおいて、ジェネリクスによる方法と、mockallを使用する方法の、2種類で構造体のモックをしてみました。

サンプルプログラム

GitLabの指定のプロジェクトから、GitLab APIを使用し、Issueの一覧を取得します。Issueに設置されているラベルの種類とそれぞれの件数を集計して、集計結果をSlackに通知します。

ソースコードは こちら

モックなし

lib.rsrun関数を記述します。以下のような内容です。この関数を、main関数から呼び出します。

src/lib.rs
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(())
}

GitlabApiSlackNotifierは自前で定義している構造体です。HTTPリクエストを送信して、それぞれのサービスと通信します。

cargo run コマンドによってプログラムを普通に実行すると、以下のようなメッセージがSlackの指定のチャンネルに届きます。「バグ」やら「テスト」やらが、Issueに設置されているラベルで、数字はそのラベルの件数です。

Demo User アプリ  18:21
バグ: 18
テスト: 22
開発: 2
設計: 2

このrun関数を実行し、正常に終了するかどうかを確認するテストコードを書きます。

src/lib.rs
# [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通信を行うオブジェクトが与えられています。これを、テスト実行時とそれ以外の時で与えるオブジェクトを切り替えられるようにしてみます。

src/lib.rs
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 クライアント、という意味です。

LshClientRequestSenderトレイトを実装しています。

src/request_sender.rs
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は、実際に通信を行います。ではここで、テスト用に、同じトレイトを実装している、通信は行わない構造体を作成します。

src/mock_lsh_client.rs
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も、通信を行わず、空文字列を返却します。

これをテストで使うことにしましょう。

src/lib.rs
# [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構造体に狙いを定めます。

src/lsh_client.rs
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が定義されます。これを、テスト実行時には使用します。

ただし、あくまでLshClientMockLshClientは異なる構造体なので、LshClient型の変数にMockLshClient型のオブジェクトを代入することはできません。

mockallでは、コンパイル時に、どちらの構造体の定義を使用するのかを条件分岐で記述することで、この問題を解決しています。

src/lib.rs
cfg_if::cfg_if! {
    if #[cfg(test)] {
        pub use lsh_client::MockLshClient as LshClient;
    } else {
        pub use lsh_client::LshClient;
    }
}

run関数の引数の型は、LshClientとしておきます。

src/lib.rs
pub fn run(lsh_client: &LshClient) -> Result<(), error::Error> {
    // (以下省略)

では、テストを書きます。

src/lib.rs
# [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()としていますが、テスト実行時はLshClientMockLshClientのことを表すことになっているので、MockLshClientのオブジェクトが生成されます。default()メソッドも、#[automock]により自動的に定義されたものです。

expect_request_getreturningで、request_getが呼び出された時に返却する値を指定しています。expect_request_postも同様です。

cargo testを実行して、Slackにメッセージが届かないことが確認できました。

おわりに

Rust初心者として、テストコードを書くためにはなんらかのモックの仕組みが必要だろうということで、その方法を調べてみました。

テストのためだけにジェネリクスを使うのは、コードが複雑になりすぎるように思うので、世の中のライブラリを活用するなどして、速やかにテストコードが書けるようになっていきたい所存です。

15
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?