unit-testingtestingruststructprivate

How to mock private structs from other mod during testing in rust


I have an app with a few domains with different responsibilities. I added a simplified working example below.

The User struct is owned by the auth domain. Its fields and the new() constructor are private to this domain. Other domains may only use the domain service as interface to get a user.

Functions of the blog domain expect User as input parameter (get_posts_by_user() in the example), or they need to call functions of the auth domain (get_posts_by_user_id() in the example). To test these functions I need to create instances of User without using the real AuthService implementation that would need a database. Instead, I would like to mock AuthService and let the mock return any edge case of User I need for testing.

User and Blog are just examples. In practice I will have possibly dozens of such cases with domain-private types. I am searching for a similar solution for all of them.

I thought about a few ways to solve this, but none of them convinced me, yet:

Is there some best practice how to mock and test such scenarios?

Rust Playground

// Authentication domain
pub mod auth {
    // The User struct is constructed by this domain when a user is successfully
    // authenticated
    // private fields, used in production
    #[cfg(not(test))]
    #[derive(Clone, Debug)]
    pub struct User {
        user_id: i32,
        username: String,
    }

    // all fields public, used only in tests
    #[cfg(test)]
    #[derive(Clone, Debug)]
    pub struct User {
        pub user_id: i32,
        pub username: String,
    }

    impl User {
        // new() is private because no other domain should be able to construct a User.
        fn new(user_id: i32, username: String) -> User {
            User { user_id, username }
        }

        // Public read-only access to user id
        pub fn user_id(&self) -> i32 {
            self.user_id
        }

        // Public read-only access to username
        pub fn username(&self) -> &str {
            &self.username
        }
    }

    // Abstraction via trait to decouple services and to allow creation of mocks
    // for easier unit testing
    pub trait AuthService {
        fn get_user(&self, user_id: i32) -> User;
    }

    #[derive(Clone, Debug)]
    pub struct AuthImpl;

    // Implementation of AuthService
    impl AuthService for AuthImpl {
        fn get_user(&self, user_id: i32) -> User {
            // DB access and complex calculations to authenticae user
            std::thread::sleep(std::time::Duration::from_millis(2000));
            User::new(
                user_id,
                vec!["Alice", "Bob", "Claire", "Daniel"]
                    .get(user_id as usize % 4)
                    .unwrap()
                    .to_string(),
            )
        }
    }

    #[cfg(test)]
    mod tests {
        // omitted
    }
}

// Blog domain
pub mod blog {
    use crate::auth::{AuthService, User};

    // Trait for blog service that needs access to the user or to the auth domain
    pub trait BlogService {
        fn get_posts_by_user(&self, user: &User) -> Vec<String>;
        fn get_posts_by_user_id(&self, auth: impl AuthService, user_id: i32) -> Vec<String>;
    }

    #[derive(Clone, Debug)]
    pub struct BlogImpl;

    impl BlogService for BlogImpl {
        // The User from the auth domain is needed as input here.
        // During testing this should be moked
        fn get_posts_by_user(&self, user: &User) -> Vec<String> {
            // this would be some call to the data storage
            vec![format!(
                "Hi, my name is {} (id: {})",
                user.username(),
                user.user_id()
            )]
        }

        // A call to AuthService is needed here to authenticate the user.
        // During testing this should be mocked
        fn get_posts_by_user_id(&self, auth: impl AuthService, user_id: i32) -> Vec<String> {
            let user = auth.get_user(user_id);
            // this would be some call to the data storage
            vec![format!(
                "Hi, my name is {} (id: {})",
                user.username(),
                user.user_id()
            )]
        }
    }

    // Unittest the service
    #[cfg(test)]
    mod tests {
        use crate::auth::{AuthService, User};

        use super::*;

        // Mock of AuthService to test BlogService
        #[derive(Clone, Debug)]
        struct AuthMock {
            // Mocked value for User. But user is private in auth domain. How to mock it here?
            pub user: User,
        }

        impl AuthService for AuthMock {
            fn get_user(&self, _user_id: i32) -> User {
                self.user.clone()
            }
        }

        #[test]
        fn test_get_posts_by_user() {
            let blog = BlogImpl;
            // User is private in auth domain. What is the best way to mock it here?
            let user = User {
                user_id: 42,
                username: "Dummy".to_string(),
            };
            let posts = blog.get_posts_by_user(&user);
            assert_eq!(posts, vec!["Hi, my name is Dummy (id: 42)"]);
        }

        #[test]
        fn test_get_posts_by_user_id() {
            let blog = BlogImpl;
            let auth_mock = AuthMock {
                // User is private in auth domain. What is the best way to mock it here?
                user: User {
                    user_id: 1337,
                    username: "Admin".to_string(),
                },
            };
            let posts = blog.get_posts_by_user_id(auth_mock, 1);
            assert_eq!(posts, vec!["Hi, my name is Admin (id: 1337)"]);
        }
    }
}

use crate::auth::{AuthImpl, AuthService};
use crate::blog::{BlogImpl, BlogService};

fn main() {
    let auth = AuthImpl;
    let blog = BlogImpl;

    let now = std::time::SystemTime::now();
    println!("{} ms", now.elapsed().unwrap().as_millis());

    // get user form auth domain
    println!("User: {:?}", auth.get_user(123));
    println!("{} ms", now.elapsed().unwrap().as_millis());

    println!("Posts by user: {:?}", blog.get_posts_by_user(&auth.get_user(1234)));
    println!("{} ms", now.elapsed().unwrap().as_millis());

    println!("Posts by user id: {:?}", blog.get_posts_by_user_id(auth, 12345));
    println!("{} ms", now.elapsed().unwrap().as_millis());
}

Solution

  • What if you expose a test_new function wrapped around the new function in test environment

    that way you don't need to redefine the struct, don't need to redefine the new()

    /// crate::auth
    impl User {
        fn new(user_id: i32, username: String) -> User {
            User { user_id, username }
        }
    
        #[cfg(test)]
        pub fn test_new(user_id: i32, username: String) -> User {
            User::new(user_id, username)
        }
    }
    
    /// crate::blog::test
    #[test]
    fn test_get_posts_by_user() {
        let blog = BlogImpl;
        let user = User::test_new(
            42,
            "Dummy".to_string()
        );
        let posts = blog.get_posts_by_user(&user);
        assert_eq!(posts, vec!["Hi, my name is Dummy (id: 42)"]);
    }
    

    Edit

    Macros

    For a more general approach, use a proc-macro to change the visibility depending on whether it is a test environment.

    like this:

    #[cfg(not(test))]
    fn a(){}
    #[cfg(test)]
    pub fn a(){}
    
    extern crate proc_macro;
    use proc_macro::TokenStream;
    use quote::quote;
    use syn::{parse_macro_input, ItemFn};
    
    #[proc_macro_attribute]
    pub fn pub_on_test(_attr: TokenStream, item: TokenStream) -> TokenStream {
        let ItemFn {
            sig,
            vis,
            block,
            attrs,
        } = parse_macro_input!(item as ItemFn);
    
        quote!(
            #(#attrs)*
            #[cfg(not(test))]
            #vis #sig #block
            #(#attrs)*
            #[cfg(test)]
            pub #sig #block
        )
        .into()
    }
    

    usage

    pub mod auth {
        use my_macros::pub_on_test;
    
        //... 
        impl User {
            #[pub_on_test]
            fn new(user_id: i32, username: String) -> User {
                User { user_id, username }
            }
    
        // ...
        }
    }
    

    Expanded

    cargo expand
    
    mod auth{
        // ...
        impl User {
            #[cfg(not(test))]
            fn new(user_id: i32, username: String) -> User {
                User { user_id, username }
            }
            // ...
        }
    }
    
    cargo expand --tests
    
    mod auth{
        // ...
        impl User{
            #[cfg(test)]
            pub fn new(user_id: i32, username: String) -> User {
                User { user_id, username }
            }
        // ...
        }
    }