Compile-time Dependency Injection
In Building API Clients with Req - Wojtek Mach | ElixirConf EU 2024 talk, Wojtek shortly demonstrated how to test Req clients using behaviours and dependency injection. I highly recommend the talk.
The post is a reminder to myself on how and why to define such dependencies.
How to define a dependency
The following snippet demonstrates how to model time dependency in the codebase. Subsequent sections go over its details.
# app/clock.ex
defmodule Clock do
@callback now() :: DateTime.t()
@module Application.compile_env!(:my_app, :clock)
defdelegate now, to: @module
end
# app/clock/system.ex
defmodule Clock.System do
@behaviour Clock
@impl Clock
def now do
DateTime.now!("Etc/UTC")
end
end
# test/support/mocks.ex
Hammox.defmock(Clock.Mock, for: Clock)
# config/config.exs
config :app, :clock, Clock.System
# config/test.exs
config :app, :clock, Clock.Mock
# Tests
stub(Clock.Mock, :now, fn -> ~U[2024-06-13 09:18:06.497119Z] end)
Behaviour
Behaviours create contract for Clock
implementation, including the mock one. There's one downside to behaviours - they are stateless. The snippet's stub is frozen it time, but what if tests require "time travel"?
Agents is one of the ways to inject state in mocks:
defmodule ManualClock do
use Agent
def start_link(datetime) do
Agent.start_link(fn -> datetime end)
end
def now(clock) do
Agent.get(clock, & &1)
end
def shift(clock, value, unit) do
# Or use `DateTime.shift/3` on Elixir >= 1.17
Agent.update(clock, &DateTime.add(&1, value, unit))
end
end
# Using
time = start_supervised!({ManualClock, ~U[2024-06-13 09:18:06.497119Z]})
stub(Clock.Mock, :now, fn -> TestClock.now(time) end)
ManualClock.shift(time, 30, :minute)
Application.compile_env!
Pros of the unsafe version of Application.compile_env!
are important:
- Unsafe version verifies that the app's config injects a behaviour. It does so at compile time, shortening the error feedback cycle.
- Compile time injections are more performant. It may play a role in hot functions, especially if they previously were using
Application.fetch_env
on every call. - Compile time injections prevents adding much overhead, aside from one indirect call. Growing the corpus of injected dependencies shouldn't cause performance issues.
Hammox vs Mox
Hammox adds runtime type checks for mocks. It does so by extracting types from behaviours' callback specs. I often forget to update a mock function after updating the behavior's callback. Hammox throws error if the return type of the mock function doesn't match the callback's one.
Hammox is built on top of Mox, it does the same thing as Mox, it fully replicates Mox's API, and even use Mox under the hood. Which makes it an ideal candidate for gradual drop-in replacement of Mox.
Test Mocks
Notice that the example defines test mocks in test/support/mocks.ex
, when usually code bases do that in test/test_helper.exs
.
The reason is that a call to Application.compile_env
happens, well, during compilation time. While test_helpers.exs
content is interpreted together with tests in runtime, which is too late.
Having configuration for mocks in config/test.exs
: config :app, :clock, Clock.Mock
makes Elixir compiler to emit warning of unknown Clock.Mock
if you forget to transfer it to test/support/
directory.
To level up the configuration game, one can define mocks per environment:
# config/config.exs
config :app, :clock, Clock.Unimplemented
# config/dev.exs
config :app, :clock, Clock.System
# config/prod.exs
config :app, :clock, Clock.System
# config/test.exs
config :app, :clock, Clock.Mock
In this case, compiling the Clock module for test environment will emit a warning that module Clock.Unimplemented is not available or is yet to be defined
. The trick prevents live dependencies from leaking to tests.