LoginSignup
5
1

[Rust] mockallで単体テスト

Last updated at Posted at 2022-11-04

ある型Aが別の型Bに依存する場合,型Aの単体テストにおいて型Bをモック化することが考えられます.今回はmockallクレートを使って型Bのモック化をしてみます.

今回説明で使うディレクトリは以下の構造だとします.repository.rsでは型B(今回はリポジトリ)で実装するトレイトを定義し,repository_impl.rsでは型B(リポジトリ)を定義しています.
handlers.rsでは型Bに依存する型A(今回はハンドラ)を定義し,単体テストを行います.コード全体はこちらにあります.

./
├ src
│ ├ lib.rs
│ ├ entity.rs
│ ├ repository.rs
│ ├ repository_impl.rs
│ └ handlers.rs
└ Cargo.toml

トレイトとジェネリクスを利用する方法

DI(依存性の注入)として自然な方法です.トレイトを定義し,型A(ハンドラ)は型B(リポジトリ)をジェネリックなフィールドとして保持します.モック型がそのトレイトを実装することで,型Aの単体テストが行えます.

定義するトレイトを以下とします.cfg_attrを用いてtestのときのみautomockマクロを適用します.このmockall::aoutomockマクロによってClientRepositoryトレイトを実装したMockClientRepository型が自動で作成されます.

repository.rs
use crate::entity::Client;
use uuid::Uuid;

#[cfg(test)]
use mockall::automock;

#[cfg_attr(test, automock)]
pub trait ClientRepository {
    fn by_id(&self, id: Uuid) -> Result<Client, String>; // idからClientを取得
    fn save(&self, client: Client); // Clientを保存
    fn next_identity(&self) -> Uuid; // 次のidを作成 
}

ClientRepositoryトレイトを実装したリポジトリ(InMemoryClientRepository)は以下です.

repository_impl.rs
use crate::entity::Client;
use crate::repository::ClientRepository;
use std::cell::RefCell;
use std::collections::HashMap;
use uuid::Uuid;

pub struct InMemoryClientRepository {
    clients: RefCell<HashMap<Uuid, Client>>,
}

impl InMemoryClientRepository {
    pub fn new() -> Self {
        Self {
            clients: RefCell::new(HashMap::new()),
        }
    }
}

impl ClientRepository for InMemoryClientRepository {
    fn by_id(&self, id: Uuid) -> Result<Client, String> {
        match self.clients.borrow().get(&id) {
            Some(client) => Ok(client.clone()),
            None => Err("No client found for geven ID".to_string()),
        }
    }
    fn save(&self, client: Client) {
        self.clients.borrow_mut().insert(client.id(), client);
    }
    fn next_identity(&self) -> Uuid {
        Uuid::new_v4()
    }
}

ClientRepositoryを実装するリポジトリに依存するハンドラを以下とします.ジェネリックなリポジトリをフィールドに持ち,コンストラクタでリポジトリを渡しています.executeではリポジトリのby_idメソッドを通じてClientを取得しており,T(ClientRepositoryを実装するリポジトリ)に依存しています.

handlers.rs
use crate::entity::Client;
use crate::repository::ClientRepository;
use std::rc::Rc;
use uuid::Uuid;

pub struct GetClientHandler<T: ClientRepository> {
    client_repo: Rc<T>,
}

impl<T: ClientRepository> GetClientHandler<T> {
    pub fn new(client_repo: Rc<T>) -> Self {
        Self { client_repo }
    }
    pub fn execute(&self, id: Uuid) -> Result<Client, String> {
        let client = self.client_repo.by_id(id)?;
        Ok(client)
    }
}

このハンドラは以下のようにテストできます.トレイトを定義したモジュールcrate::repositoryからモック型をインポートし,メソッドに対して引数の条件や返り値を設定しています.withではby_idメソッドで想定される引数にマッチするマッチャーを与え,timesでは想定される呼び出し回数を与えます.モックがその想定と異なる使い方をされた場合パニックします.return_constではメソッドの返り値を決め与えています.

handlers.rs
#[cfg(test)]
mod test {
    use std::rc::Rc;
    use super::{GetClientHandler};
    use crate::entity::Client;
    use crate::repository::MockClientRepository;
    use fake::{Fake, Faker};
    use mockall::predicate;

    #[test]
    fn get_client_handler() {
        let client = Faker.fake::<Client>(); // ニセの値を作成
        let id = client.id();

        let mut mock_repo = MockClientRepository::new();
        mock_repo
            .expect_by_id()
            .with(predicate::eq(id))
            .times(1)
            .return_const(Ok(client.clone()));

        let get_handler = GetClientHandler::new(Rc::new(mock_repo));
        let client2 = get_handler.execute(id).unwrap();
        assert_eq!(client, client2); // executeで取得したclientと一致するか判定
    }
}

ジェネリックなフィールドとして持つことで,今後ClientRepositoryトレイトを実装した型を新しく定義してもこのハンドラを使うことができます.一方で型Aと型Bが一対一に決まる場合,テストのためだけに型パラメーターを持つことは混乱を引き起こす可能性があります.そこで以降では特定の型(具象型)についてモック化を行います.

具象型をモック化する

具象型の構造体を渡す場合

先ほどのトレイトの例のように,ハンドラのコンストラクタなどでリポジトリの具象型を渡す場合を考えます.automockはトレイトだけでなく特定の型にもモック型を作成できますが,
ハンドラでは特定の型を用いるためテストの際にMockという接頭辞のつくモック型で置き換えることはできません.もとのリポジトリの型とモック型を同じ名前とし,テストの時とそうでない時で切り替える必要があります.それを行うのがmockall_doubleクレートです.

まず,リポジトリは以下とします.先ほどのInMemoryClientRepositoryとほとんど同じですが,indexmap::IndexMapで指定したサイズを超えると保存時に古いものから削除するようにしています.トレイトと同じようにautomockによってMockLimitInMemoryClientRepositoryという型が自動で作られますが,モック型にはコンストラクタを含めていないことに注意してください.テストの際にモック型独自のコンストラクタを使うためです.

repository_impl.rs
use indexmap::IndexMap;

#[cfg(test)]
use mockall::automock;

pub struct LimitInMemoryClientRepository {
    clients: RefCell<IndexMap<Uuid, Client>>,
    limit: usize,
}
impl LimitInMemoryClientRepository {
    pub fn new(limit: usize) -> Self {
        Self {
            clients: RefCell::new(IndexMap::with_capacity(limit)),
            limit,
        }
    }
}

#[cfg_attr(test, automock)]
impl LimitInMemoryClientRepository {
    pub fn by_id(&self, id: Uuid) -> Result<Client, String> {
        match self.clients.borrow().get(&id) {
            Some(client) => Ok(client.clone()),
            None => Err("No client found for geven ID".to_string()),
        }
    }
    pub fn save(&self, client: Client) {
        let mut clients = self.clients.borrow_mut();
        let clients_length = clients.len();
        if clients_length > self.limit {
            clients.shift_remove_index(0);
        }
        clients.insert(client.id(), client);
    }
    pub fn next_identity(&self) -> Uuid {
        Uuid::new_v4()
    }
}

モック化したい型で対応していないderiveマクロ例えば#[derive(Clone)]などを使いたい場合はautomockが使えません.mock!マクロで実装を囲うことになります.

次にlib.rsmockall_double::doubleマクロを以下のように使います.

lib.rs
pub mod repository_impl;

use mockall_double::double;
  
#[double]
pub use repository_impl::LimitInMemoryClientRepository;

doubleマクロは以下のように展開されます.

#[cfg(not(test))]
pub use repository_impl::LimitInMemoryClientRepository;
#[cfg(test)]
pub use repository_impl::MockLimitInMemoryClientRepository as LimitInMemoryClientRepository;

よってuse crate::LimitInMemoryClientRepositoryとすることでテスト時にはモック型として,そうでないときはもとの型としてコンパイルされます.use crate::repository_impl::LimitInMemoryClientRepositoryとすればテストの時でももとの型としてコンパイルされます.

具象型に依存するハンドラを以下とします.具象型を用いているのでもちろん型パラメーターはありません.

handlers.rs
use crate::LimitInMemoryClientRepository;

pub struct LimitGetClientHanderV1 {
    client_repo: Rc<LimitInMemoryClientRepository>,
}

impl LimitGetClientHanderV1 {
    pub fn new(client_repo: Rc<LimitInMemoryClientRepository>) -> Self {
        Self { client_repo }
    }
    pub fn execute(&self, id: Uuid) -> Result<Client, String> {
        let client = self.client_repo.by_id(id)?;
        Ok(client)
    }
}

このハンドラは以下のようにテストできます.トレイトを用いた例とほぼ同じですが,モック型の名前がもとの型と同じ名前となっています.しかし,もとの型のコンストラクタを使っていないことに注意してください(引数がありません).

handlers.rs
use super::LimitGetClientHanderV1;
use crate::LimitInMemoryClientRepository;

#[test]
fn limit_get_client_handler_v1() {
	let client = Faker.fake::<Client>();
	let id = client.id();

	let mut mock_repo = LimitInMemoryClientRepository::new();
	mock_repo
		.expect_by_id()
		.with(predicate::eq(id))
		.times(1)
		.return_const(Ok(client.clone()));

	let get_handler = LimitGetClientHanderV1::new(Rc::new(mock_repo));
	let client2 = get_handler.execute(id).unwrap();
	assert_eq!(client, client2);
}

内部で具象型のコンストラクタを使う場合

もしハンドラにリポジトリの具象型を渡さずに内部で具象型のコンストラクタを使いたい場合は,モック化した型のコンストラクタがテストの時とそうでない時に別物になることに注意しなければいけません.

毎回新しい空のリポジトリを作るため例としては適切ではありませんが,内部で具象型のコンストラクタを使うハンドラは以下となります.具象型のコンストラクタを利用する部分をテストの時とそうでないときの二つ分用意しています.またモック型の設定は外部で行うことになるため,テストの時のみモジュール内でのみ使えるclient_repoのセッターを用意しています.フィールドを直接書き換えてもかまいません.

handler.rs
pub struct LimitGetClientHanderV2 {
    client_repo: Rc<LimitInMemoryClientRepository>,
}

impl LimitGetClientHanderV2 {
    pub fn new() -> Self {
        #[cfg(not(test))]
        let client_repo = Rc::new(LimitInMemoryClientRepository::new(10_usize));

        #[cfg(test)]
        let client_repo = Rc::new(LimitInMemoryClientRepository::new());

        Self { client_repo }
    }
    pub fn execute(&self, id: Uuid) -> Result<Client, String> {
        let client = self.client_repo.by_id(id)?;
        Ok(client)
    }
}

#[cfg(test)]
impl LimitGetClientHanderV2 {
    fn set_client_repo(&mut self, new_client_repo: Rc<LimitInMemoryClientRepository>) {
        self.client_repo = new_client_repo;
    }
}

このハンドラは以下のようにテストできます.具象型の構造体を渡す場合とほぼ同じですが,モック型はセッターを用いて与えています.

handlers.rs
#[test]
fn limit_get_client_handler_v2() {
	let client = Faker.fake::<Client>();
	let id = client.id();

	let mut mock_repo = LimitInMemoryClientRepository::new();
	mock_repo
		.expect_by_id()
		.with(predicate::eq(id))
		.times(1)
		.return_const(Err("No client found for geven ID".to_string()));

	let mut get_handler = LimitGetClientHanderV2::new();
	get_handler.set_client_repo(Rc::new(mock_repo));
	let err = get_handler.execute(id);
	assert_eq!(Err("No client found for geven ID".to_string()), err);
}

rust-analyzerの設定

rust-analyzerでは通常,テストの部分(#[cfg(test)])が読み込まれテストでない部分(#[cfg(not(test))])は読み込まれません.そのためモック化した型を使う場合にもとの型のコンストラクタを利用しようとするとエラーがでます(シグネチャが異なる場合).
拡張機能の設定からRust-analyzer > Cargo: Unset Testに自身のクレート名を追加することでテストでない部分のみが読み込まれるようになります.

5
1
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
5
1