I’m sharing a pattern I’ve been tooling with. More or less, it’s an Elixir implementation of a Service Locator pattern (which is dubbed as an anti-pattern by some, but I feel like the realities of functional programming make some of the normal drawbacks less severe and overall a net positive).
This pattern is used thoroughly by PHP’s Laravel framework (and others), and is encapsulated quite cleanly in the PHP Pimple Package (yes, that’s an awful name). I confess that I personally never liked Python or Ruby’s monkey-patching approach to testing: I much prefer the explicit elegance of dependency injection that I think PHP does surprisingly well.
In Elixir, this pattern can be implemented without much fuss by using Application.get_env/2
and Application.put_env/3
. Although it’s not strictly necessary, the syntax of this pattern is much more pleasing if you have a convenience function like the following defined for your app:
def app(key), do: Application.get_env(:my_app, key)
(The function name app
here I’m borrowing directly from Laravel’s app function – but choose whatever name you want).
And then, in your config, you can define your “service” modules:
# config.exs
import Config
config :my_app,
json_module: Jason,
http_client: HTTPoison,
stripe_client: StripeModule
# ... etc ...
This allows us to elegantly call our configured modules, e.g.
# assume: `import MyApp`
app(:http_client).get("http://some-url.com")
AND we can override the modules with mocks for testing by using Application.put_env/3
.
For example, if I wanted to test what happens if my HTTP get request returns a 404, I can mock this in a module. It helps if I configure my mix.exs
def project do
[
elixirc_paths: elixirc_paths(Mix.env()),
# ... etc....
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
then I can add my mocks inside the test/support/
directory, e.g.
defmodule MyApp.Mocks.HttpClient404
def get(url) do
{:error, "Probably this is more believable if you actually pasted in an actual error from a REAL 404 response"}
end
end
Then my test can do something like:
# Use a setup block with `on_exit` to ensure all configured modules are put back into place after each test
setup do
http_client = Application.get_env(:my_app, :http_client)
on_exit(fn ->
Application.put_env(:my_app, :http_client, http_client)
end)
end
test "invalid URLs generate 404" do
Application.put_env(:my_app, :http_client, MyApp.Mocks.HttpClient404)
assert {:error, _} = MyApp.do_something_that_calls_http_client()
end
Hopefully that makes sense. This doesn’t seem like a perfect solution, but it doesn’t feel completely smelly either: it lets me test all aspects of 3rd party services going bezerk, and that’s a big win. The syntax smoothing is a nice bonus too.
Hopefully this is helpful to someone (and I sincerely hope I’m not pedaling nasty anti-patterns here).