ある型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
型が自動で作成されます.
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
)は以下です.
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
を実装するリポジトリ)に依存しています.
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
ではメソッドの返り値を決め与えています.
#[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
という型が自動で作られますが,モック型にはコンストラクタを含めていないことに注意してください.テストの際にモック型独自のコンストラクタを使うためです.
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.rs
でmockall_double::double
マクロを以下のように使います.
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
とすればテストの時でももとの型としてコンパイルされます.
具象型に依存するハンドラを以下とします.具象型を用いているのでもちろん型パラメーターはありません.
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)
}
}
このハンドラは以下のようにテストできます.トレイトを用いた例とほぼ同じですが,モック型の名前がもとの型と同じ名前となっています.しかし,もとの型のコンストラクタを使っていないことに注意してください(引数がありません).
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
のセッターを用意しています.フィールドを直接書き換えてもかまいません.
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;
}
}
このハンドラは以下のようにテストできます.具象型の構造体を渡す場合とほぼ同じですが,モック型はセッターを用いて与えています.
#[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
に自身のクレート名を追加することでテストでない部分のみが読み込まれるようになります.