I have been struggling a lot with a seemingly trivially simple problem. I like doing stuff in TDD, and this time I don't wanna half-ass it, I wanna carry on with it properly - which means, I'm basically stuck at the very beginning.
In my mental model, the application would be structured like this:
- a library for common functionality
- a binary for CLI
- a binary for web
I'd like to focus on the common library in this post. In case it has any relevance, I'm using sea-orm
for database interactions and mockall
for mocking. For the latter one, I'm very much open to other suggestions if there's anything better than mockall
(I haven't found any).
By principles of TDD, I wanted to make it simple and not overcomplicate it, so at first I created a repository:
pub struct BookRepo<'a>
{
db: &'a DatabaseConnection,
}
impl<'a> BookRepo<'a>
{
pub fn new(db: &'a DatabaseConnection) -> BookRepo<'a>
{
BookRepo { db }
}
async fn create(&self, book: book::ActiveModel) -> Result<book::Model, DbErr>
{
book.insert(self.db).await
}
async fn read<T>(&self, id: Uuid) -> Result<Option<T>, DbErr>
where T: PartialModelTrait
{
book::Entity::find().filter(book::Column::DeletedAt.is_null()).filter(book::Column::Id.eq(id)).into_partial_model::<T>().one(self.db).await
}
// there are more methods, just shortened for clarity...
}
So far so good; sea-orm
provides an excellent mocking interface for DatabaseConnection
, we happy.
Next, I wanted to add a service layer that would potentially use multiple repositories in the future, but for now, only BookRepo
, as nothing else is implemented yet.
A few (im)possibilities I discarded right away:
- Accepting a
&DatabaseConnection
when initializing the service is a no-no, because that way the service would have to initialize theBookRepo
, which leads to a few problems:- Service and repository are strongly coupled.
- Impossible to mock
BookRepo
. Yes,sea-orm
's fancy mocking interface is still available, but it's quite limited - I only can specify what it should return from a query, but I have no way peeking into what it receives as parameters to the query fromBookRepo
. Also, at this level I want to test the interaction between the service and the repository, so there's no need to always propagate down to the database level in every single test of the entire library.
- Accepting
&BookRepo
when initializing the service - this is a move to the right direction, butmockall
cannot mock structs. It can only mock trait implementations. Which leads to... - ...accepting a trait instead of a concrete type. So I created a
BookRepoTrait
. Dynamic dispatch is off the table, because apparently my trait implementation is not dyn-compatible because of the generic parameter of theread
method. This leaves one option: the service receives a type parameter for theBookRepo
implementation, but incredibly enough, this still cannot be mocked.
For clarity, here's the current version of the code:
pub struct BookRepo<'a>
{
db: &'a DatabaseConnection,
}
pub trait BookRepoTrait
{
async fn create(&self, book: book::ActiveModel) -> Result<book::Model, DbErr>;
async fn read<T>(&self, id: Uuid) -> Result<Option<T>, DbErr> where T: PartialModelTrait;
// there are more methods, just shortened for clarity...
}
impl<'a> BookRepo<'a>
{
pub fn new(db: &'a DatabaseConnection) -> BookRepo<'a>
{
BookRepo { db }
}
}
impl BookRepoTrait for BookRepo<'_>
{
async fn create(&self, book: book::ActiveModel) -> Result<book::Model, DbErr>
{
book.insert(self.db).await
}
async fn read<T>(&self, id: Uuid) -> Result<Option<T>, DbErr>
where T: PartialModelTrait
{
book::Entity::find().filter(book::Column::DeletedAt.is_null()).filter(book::Column::Id.eq(id)).into_partial_model::<T>().one(self.db).await
}
}
pub struct BookService<'a, R: BookRepoTrait>
{
book_repo: &'a R,
}
impl<'a, R> BookService<'a, R>
where R: BookRepoTrait
{
pub fn new(book_repo: &'a R) -> Self
{
BookService { book_repo }
}
pub fn add_book(&self, book: BookAdd)
{
}
}
In my test, if I do
#[automock]
trait BookRepoTrait {
async fn create(&self, book: book::ActiveModel) -> Result<book::Model, DbErr>;
}
... then I'm getting this:
error[E0277]: the trait bound `MockBookRepoTrait: book_repo::BookRepoTrait` is not satisfied
--> src/services/book_service_test.rs:30:39
|
30 | let service = BookService::new(&mock_book_repo);
| ------------------- ^^^^^^^^^^^^^^^^^^ the trait `book_repo::BookRepoTrait` is not implemented for `MockBookRepoTrait`
| |
| required by a bound introduced by this call
|
= help: the trait `book_repo::BookRepoTrait` is implemented for `BookRepo<'_>`
note: required by a bound in `BookService::<'a, R>::new`
--> src/services/book_service.rs:10:10
|
10 | where R: BookRepoTrait
| ^^^^^^^^^^^^^^^^ required by this bound in `BookService::<'a, R>::new`
11 | {
12 | pub fn new(book_repo: &'a R) -> Self
| --- required by a bound in this associated function
If I do this instead of the #[automock]
block:
mock! {
pub MockBookRepo {}
impl BookRepoTrait for MockBookRepo {
async fn create(&self, book: book::ActiveModel) -> Result<book::Model, DbErr>;
}
}
...then it would expect me to declare all methods in MockBookRepo
, not just the one I wanna mock. At which point, it made me think, why do we need a mocking library here then?
So of course I wanted to just define a whole new implementation for BookRepoTrait
just for the test, and then it would just count the number of invocations of each method, and maybe capture the arguments passed to its methods. But of course that cannot possibly work, since that would require me to do &mut self
instead of &self
in the trait - which I absolutely won't do, as it's kinda ridiculous if I have to make my objects mutable just because I wanna test my code.
And here we are! I gave up. As a last resort I'm posting here, maybe someone reads my struggles and has a convenient idea.