<-- mermaid -->

Rustでモックオブジェクトを自作してみる

こんにちは、SaaS Product Team の Ryo33 です。

この記事では Rust でモックオブジェクトを作ることを通してRefCellMutexRcArcの使い方やSendSyncについて学びます。

この記事を読むことで 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_mocksuser_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関数は別スレッドで実行される可能性があるため&selfSendである(スレッド間で安全に値を移動できる)必要があり*3、 参照(&self)を別スレッドに持っていくということはスレッド間で値を共有するということなのでUserPortMockself)はSyncである(スレッド間で安全に値を共有できる)必要があります。 しかしRefCellSyncトレイトを実装していないのでUserPortMock全体で!Syncとなり、&self!Sendになってしまいます。 安全に&selfを別スレッドに移動できないのでコンパイルエラーです。

そこでMutexを使います*4MutexRefCellと似たような使い方ができ、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な関数をモックしたいこともあります。

ためしに、UserPortstoreUserUseCasecreate_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()),
        }]
    );
}

そこでRcArcを使って複数の所有権を持てるようにします。 同期の例ではRcを使ってRefCell<Vec<User>>Rc<RefCell<Vec<User>>>に、 非同期の例ではRc!SendなのでArcを使ってMutex<Vec<User>>Arc<Mutex<Vec<User>>>に変えます。

以下のように先にstore_callscloneしておき、それを使うことでコンパイルが通るようになります。

#[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というクレートを見つけました。 内部実装を見るとほとんど同じことをやっています。 こちらを使うほうが便利で書く量も少なくて済むので、徐々にこちらに移行しているところです。

参考文献

*1: HashMapを使っても良い

*2:実はMutexでもRwLockでもできる

*3:userも同様だがすでにSend

*4:RwLockでもよい

Page top