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:
User
public, or make the the new()
constructor public.
User
changes#[cfg(test)]
to make the fields only public during testing
Is there some best practice how to mock and test such scenarios?
// 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());
}
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)"]);
}
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()
}
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 }
}
// ...
}
}
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 }
}
// ...
}
}