Rather than utilize mocks, Rust's system benefits from a "sans IO" style; instead of business logic talking to the database directly, the business logic (attempts to mostly) functions without talking directly to IO, taking a more pure computational shape. Then there's a small glue/integration layer that should be fairly trivial and not need standalone "unit" testing, just the "integration" testing that you wouldn't use mocks for anyway.
Ofc "you don't need mocks if you just don't write overly coupled code" is a bit flippant, but Rust really does prefer if you and help you to couple components less tightly.
It's not always as simple as that, of course; if the business logic needs to request further IO, or you want to work in a streaming fashion, business logic can't just be a pure function. But that's what the IO traits (and other similarly shaped traits) are for; you can fairly easily swap between your production IO and a simple in-memory provider. The difference to mocks is that you're still providing a (mostly) complete implementor of the interface, just a simpler one that doesn't need to handle all the annoying realities. Traits are the way to swap between providers, as the equivalent of and doing the same thing as how you'd abuse inheritance to provide RepoMock
where Repo
was originally expected.
I quite like matklad's perspective in How to Test, which comes from working on rust-analyzer, which is a perfect case study of both difficult to test (giant persistent ball of state) and simple to test (well defined oneshot queries over that ball of state).
The biggest takeaway imho is that rather than mocks (expect these interface calls to happen and return these values) you typically do want to have your tests using a "real" but fast/local provider. Ideally, all of your tests should work the same and continue to pass if the component being tested were to be ripped out and replaced with an AI black box. That's an aggressive way to put it, but it's a good way to ensure that the tests aren't getting in the way of should-be-encapsulated refactors.
In fact you can argue that mocks increase coupling, since you're now testing the exact way the mocked component gets used, rather than just that the result is correct. At a minimum, it increases the friction of refactoring how either component talks to the other, since the mock tests are tightly coupled to both.
If you already have that dynamicism between those implementations, then that dynamicism is the exact place where you would provide
Creating a new trait
solely to mock a component can be a minor code smell, but it's so because it's relatively likely this isn't a proper place to be injecting a mock-like, if there's only one possible actual implementation. If it's a layer that it makes sense to have alternative providers for, on the other hand, the generality makes sense and it's not really a code smell.
It's absolutely fine to use Box<dyn Trait>
for this kind of looseish coupling boundary. In your traditional OOPish system where mocking is a simple abuse of inheritance away, it's almost exactly equivalent behavior to what everything always does. The important part is that whenever you have dyn Trait
that it's a looseish coupling and you actually do function with whatever correct implementation of the trait interface, and aren't relying on the specific behavior of some concrete implementation behind the dynamicism.
Static dispatch is "better" for performance in theory (but worse for compile time and code size), but only marginally in most cases. There are some things you can't do with dyn
, but if your interface is dyn
-compatible, it's almost certainly fine to use trait objects.
The main reason people shy away from them in public interfaces is that you can typically provide a trait object where a generic is asked for, so that leaves the choice to the caller. But being dynamic is much simpler and typically nicer to compile times as well.
The main thing is isolation. This is especially important since cargo multithreads tests in parallel by default, so it's easy for tests to interfere with each other and slow each other down, even if they don't cross-pollute results.
Many would probably call what rust-analyzer's tests do as mocking; they set up essentially an entire virtual file system for each test that they then ask the inference engine to look at.
But the key insight is that it isn't a mock, though; it's a fully functional provider. But it provides the same isolation benefits that a mock is designed to. Just without encoding an expectation of how exactly the provider is consumed.