Last time I presented an idea for a design pattern for Rust applications, that can help with testing of business logic. In short, we need a way to turn dependencies into inputs when we run tests, so that we can test function bodies as units.
The last blog post presented some workable ideas, which suffered a bit from being too verbose to write by hand. This time
I will write about a macro that I named
entrait
,
which removes this boilerplate. The entrait pattern is a certain code style and design technique
that is used together with the macro.
To start from the beginning: The main problem we would like to tackle is unit testing of business logic:
fn my_function() -> i32 {
your_function() + some_other_function()
}
We would like to be able to write a test for my_function
that does not depend on any
implementation details from your_function
or some_other_function
. Instead we'd like to
treat these functions differently only when we're testing: We want to explicitly specify
what these functions are returning, as another kind of input to the function we are testing.
Entrait
Entrait is just a word that I might have invented (not sure!).
It means to put/enclose something in a trait, and this is just what the macro does.
When you have written a regular
function, you can annotate it with entrait
to automatically generate a single-method trait based on its signature:
#[entrait(pub MyFunction)]
fn my_function() {
}
The arguments to entrait is an optional visibility specifier, then the name of the trait to generate.
The entrait macro operates in append only mode, so the original function is not changed in any way and is outputted verbatim. The generated code that gets appended, includes the trait definition:
trait MyFunction {
fn my_function(&self);
}
along with a generic implementation for implementation::Impl
with a Sync
1 bound for T
:
impl<T> MyFunction for ::implementation::Impl<T>
where T: Sync
{
fn my_function(&self) {
my_function() // invoking our original function
}
}
This is the very basics of entrait.
Specifying dependencies
The basic entrait usage is not that interesting in itself. The pattern becomes more interesting when we hook up different traits, and make one function depend on another set of functions.
Consider a Rust method, which has a special self
-receiver as its first argument. Entraited functions
work in a similar way, but instead of the first parameter being a self-receiver, it specifies dependencies.
The dependencies of an entraited function is a set of traits. We can specify them in the following way:
#[entrait(MyFunction)]
fn my_function(
deps: &(impl YourFunction + SomeOtherFunction),
some_arg: i32
) {
deps.your_function(some_arg);
deps.some_other_function();
}
The macro understands the type syntax of the first parameter, and will adjust its default
implementation bounds (for Impl<T>
) accordingly. The only thing we need to know for now, is that deps
will be
a reference to some type on which we can call the methods your_function
and some_other_function
.
Using the dependency notation, we can easily build up complex directed dependency graphs with very little code:
#[entrait(Foo)]
fn foo(deps: &impl Bar, arg: i32) -> i32 {
deps.bar()
}
#[entrait(Bar)]
fn bar(deps: &(impl Baz + Qux), arg: i32) -> i32 {
deps.baz(arg) + deps.qux(arg)
}
#[entrait(Baz)]
fn baz(deps: &impl Qux, arg: i32) -> i32 {
deps.qux(arg) * 2
}
#[entrait(Qux)]
// this function has no dependency bounds:
fn qux<T>(_: &T, arg: i32) -> i32 {
arg * arg
}
Generics and application state
What we have created so far, is generic on two different levels. First of all, the function
#[entrait(MyFunction)]
fn my_function(deps: &impl YourFunction) {
// ...
}
is generic:
- Because the
deps
parameter can be any type that implementsYourFunction
. - Beacuse the trait implementation provided out of the box,
impl<T> MyFunction for implementation::Impl<T>
, is defined for anyT
.
The first kind of genericness is covered in the next section, and is related to real vs. fake
implementations and mocking.
The second kind of generic parameter, the T
in Impl<T>
, is intended to be a placeholder for
the type that we will choose to represent the state of our concrete application.
An application often needs e.g. configuration parameters, connection pools, caches, various data it needs
to operate correctly. An entraited function with generic dependencies can receive any T
as application state:
let application: Impl<bool> = Impl::new(true);
application.my_function();
Somewhere, usually deep down in our dependency graph, we would like to perform concrete operations on our
chosen application state, for example borrowing data from it. Entrait lets you do that very easily, and
the trick is just to make deps
be a reference to that concrete type:
#[entrait(FetchStuffFromApi)]
// still generic:
fn fetch_stuff_from_api(deps: &impl GetApiUrl) -> Stuff {
some_http_lib::get(deps.get_api_url())
}
struct AppConfig {
api_url: String,
}
#[entrait(GetApiUrl)]
// concrete:
fn get_api_url(config: &AppConfig) -> &str {
&config.api_url
}
What will happen now, is that the trait GetApiUrl
will only be implemented for Impl<AppConfig>
.
This means that fetch_stuff_from_api
, which depends on a type that implements GetApiUrl
, in practice
will inherit that same bound. As soon as we introduced a concrete leaf, our whole application became concretized!
Testing and mocking
So far, we have seen some constructs that enable some degree of abstraction when designing applications.
The way the deps
parameter specifies bounds as a set of traits is a manifestation of the
dependency inversion principle.
The whole point of this in the first place, was to be able to write a unit test for a function.
The function we started with was a my_function(..) -> i32
which sums toghether the outputs of your_function
and some_other_function
.
Let's write this using entrait:
#[entrait(MyFunction)]
fn my_function(
deps: &(impl YourFunction + SomeOtherFunction)
) -> i32 {
deps.your_function() + deps.some_other_function()
}
To test that this function works, we should be able to give it two numbers, e.g. 1
and 2
, and check
that it in fact produces the number 3
. If it did that, we can assume that it performed addition correctly.
We need a way to say that in the test, your_function
should have the output value of 1
, and some_other_function
should have the output value of 2
.
This is where mocking enters the picture. We need mock implementations of the traits YourFunction
and SomeOtherFunction
,
and crucially, it must be one type that implements both.
Unimock
Enter unimock, a new mock crate that is designed to be the testing companion to entrait
. Uni means one,
and the core idea is that unimock exports one struct, Unimock
, which acts as an additional implementation target for
your traits.
In entrait, unimock support is opt-in. The basic entrait usage does not generate a mock implementation:
use entrait::*;
#[entrait(YourFunction)]
fn your_function() -> i32 { todo!() }
The mock implementation is added when entrait is imported from an alternative path:
use entrait::unimock::*;
#[entrait(YourFunction)]
fn your_function() -> i32 { todo!() }
When we write it like that, entrait will generate two implementations of YourFunction
:
- for
implementation::Impl<T>
- for
unimock::Unimock
If we entrait your_function
and some_other_function
like this, with unimock implementations,
we can easily test my_function
:
use unimock::*;
#[test]
fn my_function_should_add_two_numbers() {
let deps = mock([
your_function::Fn::each_call(matching!())
.returns(1)
.in_any_order(),
some_other_function::Fn::each_call(matching!())
.returns(2)
.in_any_order(),
]);
assert_eq!(3, my_function(&deps));
}
Deeper integration tests with entrait and unimock
A testing pattern seen in various OOP languages is mocking out business logic at an arbitrary distance from the direct function being tested. I think in some circles this might be referred to as integration testing. Sometimes a deeper integration test is the best testing strategy for a particular problem.
The real advantage, the crux if you will, about the entrait pattern, is that both unit and integration tests (of arbitrary depth!) become very much a reality.
Recall that our entraited functions are just ordinary, generic functions:
fn my_function(deps: &(impl YourFunction + SomeOtherFunction)) -> i32 {
deps.your_function() + deps.some_other_function()
}
deps
can be a reference to any type that implements the given traits.
Unimock
matches that criteria, and we pass it into deps to unit test the function.
What happens when Unimock::your_function()
is called? Unimock must be configured before it's used.
For example, it can match input patterns in order to find some value to return.
But unimock has another mode, which is called unmocking: Instead of returning a pre-configured value, it can be instructed to not mock, but call some implementation instead. Because we used the entrait pattern, that implementation is right in our hands, it's that original, handwritten generic function.
There are two main ways to configure a unimock instance:
unimock::mock(clauses)
- Every interaction must be declared up front. If not, you'll get a panic.unimock::spy(clauses)
- Every interaction is unmocked by default.
A Unimock
value created with unimock::spy
is an alternative implementation of your entire2 entraited application.
With that kind of setup, you can start at the other end, i.e. instead of specifying the value of each
dependency to your unit test, you can instead say which interfaces to mock out. Subtractive instead of additive mocking.
Testing an application's external interface
Consider a REST API where we would like to test the interface of the API without invoking the
application's inner business logic. REST handlers in Rust are usually async fn
s passed to some web framework.
I will present a simple example using axum here:
async fn my_handler<A>(
Extension(app): Extension<A>
) -> ResponseType
where A: SomeEntraitMethod + Sized + Clone + Send + Sync + 'static
{
app.some_entrait_method()
}
We can make a generic Axum Router
using the same trait bounds, but duplicating these trait bounds
for a lot of different endpoints sounds a bit tedious. A solution to that could be to group related
handlers together in a generic struct, and have the handlers as static methods of that struct:
struct MyApi<D>(std::marker::PhantomData<D>);
impl<D> MyApi<D>
where
D: SomeEntraitMethod + SomeOtherEntraitMethod + Sized + Clone + Send + Sync + 'static
{
pub fn router() {
Router::new()
.route("/api/foo", get(Self::foo))
.route("/api/bar", get(Self::bar))
}
async fn foo(
Extension(deps): Extension<D>
) -> ResponseType {
deps.some_entrait_method().await
}
async fn bar(
Extension(deps): Extension<D>
) -> ResponseType {
deps.some_other_entrait_method().await
}
}
entrait and async
The last example used async fn
, but async functions in traits are not supported out of the box in current Rust.
The way around that for now is to use #[async_trait]
. Entrait supports this by accepting a keyword argument list:
#[entrait(Foo, async_trait=true)]
async fn foo() {
}
This has been designed in an opt-in manner to make it more visible that we are paying the cost of heap allocation
for every function invocation. When Rust one day supports async fn
in traits natively, you should be able
to remove this opt-in feature and things should then work in the same manner as synchronous functions.
Conclusion and further reading
The entrait pattern and related crates are in an experimental state.
What I'm hoping to achieve with this blog post is some feedback on the ideas and current implementation. I'm hoping some people will find the time to try it out, and maybe find flaws in the design that could be improved.
This blog post does not go into great depth about entrait
or unimock
.
You will hopefully find much more useful information if you visit respective rustdoc pages for each crate:
github | crates.io | docs.rs |
---|---|---|
entrait | 0.3 | docs |
unimock | 0.2 | docs |
implementation | 0.1 | docs |
My next plan for entrait is to develop a full-fledged example application. I will likely put it in the examples/
directory in the entrait repository.
Meanwhile, you can take a look at the tests/ directory, which already contains a handful of
good examples.
At least I hope that you found some of this to be interesting to read. I surely had an interesting time developing it and writing about it!
Footnotes
On Sync
bound for T
: T
should model an immutable application environment. Entrait is designed to work on multithreaded async executors,
hence the Sync
bound. Mutable caches and similar should be modelled with interior mutability, e.g. Mutex
.
There is one kind of function that cannot be automatically unmocked. It's those functions that have non-generic
deps
that should live at the leaf level of a dependency graph:
#[entrait(Foo)]
fn foo(deps: &SomeConcreteType) {}
If you create a default unimock::spy
, a call to Unimock::foo
will panic. But fortunately, SomeConcreteType
is not part of Foo::foo
's signature, so it can still be mocked normally.