In our last Elixir blog post, “Functional Mocks with Mox in Elixir”, we discussed how testing across module boundaries could be made easier by creating a Behaviour for a collaborating module, then utilizing the wonderful framework Mox to substitute a lightweight mock module in tests.
This approach is well and good when you have very concrete module boundaries that are well-defined and coarse enough to warrant the ceremony of wiring up a behavior for a module boundary. But what if we aren’t necessarily interested in all the work in creating a mock, and we need something simpler and more lightweight?
Join us as we discuss some alternative ways to write lightweight tests across function or module boundaries.
On dependency injection
Dependency Injection (or DI) is a software development system capability popularized in the object-oriented world, in which a software system provides a framework for accessing system dependencies. The test framework in these systems oftentime hooks into this dependency injection framework in order to replace live dependencies with test doubles. Practitioners of TDD (like us here at Carbon Five!) use test doubles in our tests to write concise, focused unit tests that verify behaviors with collaborating concepts. Additionally, using test doubles lets us test-drive out certain feature paths without actually having to have first implemented our collaborating dependencies.
In our prior post, we leveraged the use of module attributes to do the “injection” of the module dependency:
So in this case, the module attribute lookup in tandem with a system configuration setting is the manner in which we inject the
MyCollaboratingModule dependency. A Behaviour is then used to conform that dependency to the expected API.
However, this approach requires we create a Behaviour, wire up test and production implementations, and load up Mox in all of our tests. That’s a lot of ceremony!
Are there are other more lightweight ways to do dependency injection? Sure there are! Let’s look at a few others:
Approach 1: Explicitly passing collaborator functions and modules
In Jose Valim’s popular “Mocks and Explicit Contracts” blog post, he discusses a very lightweight opportunity to decouple your dependencies. Say your app references an implicit dependency on
This makes your tests difficult to write, because then your tests will run everything under the hood of
MyCollaboratingModule. What if it’s particularly slow, or hairy, complicated, or makes a call to an external service? Let’s refactor a bit.
Function passing in the arguments
Now I can write my tests and pass in a stub function (or verifying function):
There is a great simplicity in this approach!
Module passing in the arguments
You can also do the same thing, but instead of passing the function, you pass in the entire module:
Similarly, there is a nice simplicity to this approach
- Simple, intuitive and understandable
- Easily refactorable
- Code changes are localized to the file (no config changes)
- Most directly carries over from dependency injection approaches in other languages
- External dependencies become explicit
- Cluttered function interfaces can cripple readability and resist refactoring
- You may find you need to “wire” dependencies through multiple levels of functions
Approach 2: Delegating to a coordination function
Let’s imagine we begin with this software system – an HTTP endpoint that receives a HTTP request and builds a string to be read back in a text-to-voice system.
Here we have a 3-level-deep dependency graph. The
ResponseHandler calls out to
SpeechGenerator collaborating modules. Those two modules in turn call out to the
TimeUtilsmodules, respectively. While this approach is fine, each level of the system has some degree of coupling to other parts of the system. Is there another way to reduce the complexity here?
Delegating to an outside function
In this new approach, we reduce the responsibility surface area of module functions as much as possible, preferring that, as much as possible, lower-level modules merely acts on data and structs. Any coordination with external collaborating modules is delegated to an outside coordinator. Observe:
- All dependencies are handled at the same level
- Collaborator modules can be “dumb” and perform pure functional transformations
- If the higher coordinating function can justifiably isolate all the dependencies, this is a powerful tool.
- Some of the abstractions we originally had at different levels may be appropriate to in hiding complexity.
- The coordinating module or function gains a high degree of complexity and becomes more difficult to maintain.
- Feels a bit more like sweeping the problem under the rug.
Approach 3: Bag o’ functions
The next approach is a simple one, and it’s mainly aimed at easing the ergonomics of having to wire through a handful of function references in the argument body.
Observe how difficult it would be to work with a function signature like this:
Oh my goodness. Imagine any collaborating functions within this function declaration that then in turn need to have each specific function wired through its own function declaration. It’s hairy enough to make refactoring a miserable experience.
What if we instead grouped this scattering of functions and grouped them all into a map called
Then in downstream collaborating functions, we simply route the entire
dependencies map as a final argument.
Tests are simple to write too:
- Easy to extend with additional dependencies
- Dependencies are defined inline, together with minimal fuss
- Less “wiring” overall
- Does not have compile-time safety
- Passing around a bag of functions may make it easier to gloss over a growing dependency graph and increased complexity in your function.
Approach 4: Protocols and data
Here we get into more powerful forms of the Elixir language. For dependencies that represent a cohesive capability, we can use Protocols to represent an action that otherwise would have been a collaborating function. Let’s go back to one of our original examples:
What if we re-thought our dependencies in terms of protocols? We begin by reformulating our dependency in terms of the conforming data structure we wish our protocol to provide, in this case
Then we create a new struct that will provide the type to dispatch this new protocol on:
Now we implement both protocols for this new struct:
Let’s turn back to our original implementating call site and see what it might look like with a data structure that implements our new
ResponseHandler.Dependencies struct has an implementation of both the
SpeechGeneration protocols, it goes ahead and supplies the real methods at runtime. In our tests, however, we can define the following inline in our test:
- Strict guarantees provided by Elixir protocols = compile-time safety
- Lighter-weight and more flexible than implementing Behaviours. Protocols are extensible for arbitrary data structures.
- Can leverage Dialyzer for type checking
- Still lots of ceremony! A protocol requires a definition, a struct for both fake and real implementations, and implementations for each.
- Since protocols are dispatched on the types of the first arguments, this leads to potentially awkward function signatures
There we have it – four alternative lightweight approaches to dependency injection in Elixir, spanning from the most naive to the most powerful; from the simplest to the most complex.
Which one works for your code? The answer, as it usually does, is that it depends on your use case. I encourage the reader to always do the simplest thing that could possibly work. Start by doing simple function passing in the arguments list.
When you find yourself needing more and more function callbacks in your argument list, ask yourself if you can defer these calls to an outside caller.
If you must use all these functions, then consider consolidating them into a simple data structure and passing the structure around.
Finally, leverage Elixir’s protocol system if you want some really powerful compile-time checks against your collection of dependencies.
Of course, don’t forget that you may also find it appropriate to model your dependencies as Behaviours and mock function calls using Mox.
What do you think? What’s worked for you? Let us know on Twitter!
Many thanks to Carbon Five coworkers Hannah Howard, Craig Lyons, Erin Swenson-Healey and Will Ockelmann-Wagner for conversations around and input on this post.