こんにちは、SaaS Product Team の Ryo33 です。
この記事では Rust でモックオブジェクトを作ることを通してRefCell
やMutex
、Rc
、Arc
の使い方やSend
やSync
について学びます。
この記事を読むことで Rust でモックオブジェクトを自作できるようになります。
サンプルプログラム
まず書いていきたいコードとして以下のようなcreate_user_with_name
というユースケースを考えます。
もし、名前が空文字でなければUserPort.store
を呼ぶというコードです。
#[derive(PartialEq, Eq, Debug, Clone)] pub struct Name(pub String); impl Name { pub fn is_valid(&self) -> bool { self.0 != "" } } #[derive(PartialEq, Eq, Debug, Clone)] pub struct User { pub name: Name, } mod use_case { use super::*; pub trait UserPort { fn store(&self, user: User) -> Result<(), String>; } pub struct UserUseCase<T: UserPort> { pub user_port: T, } impl<T: UserPort> UserUseCase<T> { pub fn create_user_with_name(&self, name: Name) -> Result<(), String> { if name.is_valid() { return self.user_port.store(User { name }); } Err("not created".to_string()) } } }
もし呼び出すとしたら、以下のようにUserPort
を実装して
mod gateway { use crate::use_case::UserPort; pub struct UserGateway; impl UserPort for UserGateway { fn store(&self, user: crate::User) -> Result<(), String> { println!("stored {}.", user.name.0); Ok(()) } } }
以下のように呼び出します。
fn main() { use gateway::UserGateway; use use_case::UserUseCase; let use_case = UserUseCase { user_port: UserGateway, }; let result = use_case.create_user_with_name(Name("ryo33".to_string())); println!("{:?}", result); }
テストを書いてみる
SaaS Product Team では TDD で開発しているため、TDD のサイクルを回しながら設計と実装を進めていきます。
したがって上のcreate_user_with_name
の実装はまだ私たちの頭の中にしかないものとして、まず成功系のテストを考えてみます。
// in the use_case module #[cfg(test)] mod test { use super::*; #[test] fn test_create_user_with_name() { // prepare mock let user_port_mock = UserPortMock { store_mocks: vec![( // args User { name: Name("ryo33".to_string()), }, // return value Ok(()), )], ..Default::default() }; let target = UserUseCase { user_port: user_port_mock, }; assert_eq!( target.create_user_with_name(Name("ryo33".to_string())), Ok(()) ); // verify mock assert_eq!( target.user_port.store_calls, vec![User { name: Name("ryo33".to_string()), }] ) } }
このような感じで良さそうです。
最初のセクションでstore_mocks
にuser_port.store
の引数と返り値の組*1を登録してモックを定義した後、
次のセクションでcreate_user_with_name
を実行して返り値をassertして
最後にstore_calls
を使ってuser_port.store
が正しい引数で呼ばれたことをverifyしています。
とはいえUserPortMock
をまだ作っていないのでまだ動きません。
モックオブジェクトをつくる
UserPortMockは以下のように定義してみます
struct UserPortMock { store_calls: Vec<User>, store_mocks: Vec<(User, Result<(), String>)>, } impl Default for UserPortMock { fn default() -> Self { Self { store_calls: Vec::new(), store_mocks: Vec::new(), } } } impl UserPort for UserPortMock { fn store(&self, user: User) -> Result<(), String> { // 呼び出しの記録 self.store_calls.push(user); // <--------------- // 返り値の検索 self.store_mocks .iter() .find(|(args, _return_value)| *args == user) .unwrap() .1 .clone() } }
こんな感じでしょうか。しかし、上記の矢印のところでpush
するためにmutableな参照が取れないのでコンパイルエラーになります。
&self
を&mut self
にすれば、問題は解決しますが、テストの都合でUserPort
のインターフェースを変えたくありません。
RefCell
インターフェースは不変参照にしたいが可変参照が欲しいといったような上記のようなニーズに応えるのがRefCellです*2。
RefCellを使うと不変参照しかないところに可変参照を生やすことができます。
store
の型をVec<User>
からRefCell<Vec<User>>
に変え、push(user)
の前にborrow_mut()
することで先程の例のコンパイルが通るようになります。
borrow_mut
の箇所はこんなかんじ
self.store_calls.borrow_mut().push(user.clone());
verifyの箇所はこんな感じ
// verify mock assert_eq!( *target.user_port.store_calls.borrow(), vec![User { name: Name("ryo33".to_string()), }] );
これでUserUseCase
を実装すれば、ちゃんとテストが通る状態になりました。
非同期
もしuser_port.store
を非同期な処理にしたい場合はどうでしょうか。
async-trait クレートを使って以下のような感じです。
#[async_trait] pub trait UserPort { async fn store(&self, user: User) -> Result<(), String>; }
ユースケース側は単に関数をasync
にしてuser_port.store
の呼び出しの後に.await
をつける予定で考えます。
impl<T: UserPort> UserUseCase<T> { pub async fn create_user_with_name(&self, name: Name) -> Result<(), String> { if name.is_valid() { return self.user_port.store(User { name }).await; } Err("not stored".to_string()) } }
テストは tokio クレートなどのテスト用ランタイムを使って以下のような感じになります。
#[tokio::test] async fn test_create_user_with_name() { /* 前略 */ let target = UserUseCase { user_port: user_port_mock, }; assert_eq!( target .create_user_with_name(Name("ryo33".to_string())) .await, // <------ await が必要 Ok(()) ); /* 攻略 */ }
あとはUserPortMock
の実装を変える必要がありますが、単に下のようにするだけではコンパイルエラーになります。
#[async_trait] impl UserPort for UserPortMock { async fn store(&self, user: User) -> Result<(), String> { /* 中略 */ } }
async関数は別スレッドで実行される可能性があるため&self
がSend
である(スレッド間で安全に値を移動できる)必要があり*3、
参照(&self
)を別スレッドに持っていくということはスレッド間で値を共有するということなのでUserPortMock
(self
)はSync
である(スレッド間で安全に値を共有できる)必要があります。
しかしRefCell
はSync
トレイトを実装していないのでUserPortMock
全体で!Sync
となり、&self
は!Send
になってしまいます。
安全に&self
を別スレッドに移動できないのでコンパイルエラーです。
そこでMutex
を使います*4。Mutex
はRefCell
と似たような使い方ができ、Sync
が実装されています。
RefCell<Vec<User>>
をMutex<Vec<User>>
に型を変えます。
また、borrow_mut()
やborrow()
をlock()
にします。
#[async_trait] impl UserPort for UserPortMock { async fn store(&self, user: User) -> Result<(), String> { self.store_calls.lock().unwrap().push(user.clone()); /* 中略 */ } } #[tokio::test] async fn test_create_user_with_name() { /* 中略 */ // verify mock assert_eq!( *target.user_port.store_calls.lock().unwrap(), vec![User { name: Name("ryo33".to_string()), }] ); }
これでコンパイルが通りちゃんとテストできるようになります。
複数の所有権
今回の例であるuser_port.store
では若干違和感がありますが、&self
ではなくself
な関数をモックしたいこともあります。
ためしに、UserPort
のstore
とUserUseCase
のcreate_user_with_name
の&self
から&
を外してみると、問題が発生します。
なぜなら、テストコード内でtarget.create_user_with_name
を呼び出すとtarget
の所有権が奪われてtarget.user_port.store_calls
を借用できなくなるからです。
#[tokio::test] async fn test_create_user_with_name() { let user_port_mock = UserPortMock { store_mocks: vec![( // args User { name: Name("ryo33".to_string()), }, // return value Ok(()), )], ..Default::default() }; let target = UserUseCase { user_port: user_port_mock, }; assert_eq!( target .create_user_with_name(Name("ryo33".to_string())) // ここで所有権が奪われる .await, Ok(()) ); // verify mock assert_eq!( *target.user_port.store_calls.lock().unwrap(), // ここがコンパイルエラーになる vec![User { name: Name("ryo33".to_string()), }] ); }
そこでRc
やArc
を使って複数の所有権を持てるようにします。
同期の例ではRc
を使ってRefCell<Vec<User>>
をRc<RefCell<Vec<User>>>
に、
非同期の例ではRc
が!Send
なのでArc
を使ってMutex<Vec<User>>
をArc<Mutex<Vec<User>>>
に変えます。
以下のように先にstore_calls
をclone
しておき、それを使うことでコンパイルが通るようになります。
#[tokio::test] async fn test_create_user_with_name() { let user_port_mock = UserPortMock { store_mocks: vec![( // args User { name: Name("ryo33".to_string()), }, // return value Ok(()), )], ..Default::default() }; let store_calls = user_port_mock.store_calls.clone(); // 先にcloneしておく let target = UserUseCase { user_port: user_port_mock, }; assert_eq!( target .create_user_with_name(Name("ryo33".to_string())) // targetの所有権は奪われる .await, Ok(()) ); // verify mock assert_eq!( *store_calls.lock().unwrap(), // store_callsの所有権を先に取得していたので使っても大丈夫 vec![User { name: Name("ryo33".to_string()), }] ); }
最後に
と、頑張ってモックオブジェクトを作りましたが、最近になってmock-itというクレートを見つけました。 内部実装を見るとほとんど同じことをやっています。 こちらを使うほうが便利で書く量も少なくて済むので、徐々にこちらに移行しているところです。