Dependency injection and Phoenix contexts

I’ve been having a hard time testing Contexts and/or external service clients.
For the simplicity I want to provide a small example and ask for advice on how to test this.

File structure:

|- app
|---- listings
|------- car.ex
|------- listings.ex
|---- car_service
|------- http_client.ex
|------- test_client.ex
|------- car_service.ex

Lets say I’m using a public function on the Listings context called info.

def info(%Car{} = car) do
  case CarService.fetch_info(car) do
    {:ok, _} ->
      # handle success logic
      :ok
    {:error, _} ->
      # handle failure logic
      :error
  end
end

As you can see this is actually calling an external service to fetch information about a car.
In order to test the CarService most people use environment based configuration for dependency injection

defmodule CarService do
  # http_client on dev
  # test_client on test
  @client Application.get_env(:app, :car_service)[:client]

  def fetch_info(car_data, client \\ @client) do
    client.get("route", car_data)
  end
end

and then pattern match on certain parameters in their test_client to get their expected results in tests.

This feels like a pain point to me since now I’d have to pattern match on specific key/values in car_data which would be creating additional implementation details in order to test boundaries (simulating all potential results from service).

Maybe I’m going about this the wrong way but I’d want a client per type of response, each implementing a client behaviour and returning their appropriate response (validation errors, unauthorized, missing, etc).

Now this would be easy to do if I wanted to test the CarService directly since I can inject the client in the parameters, but what if I wanted to test this through the Listings context?

The only way I can think of, is to pass the client to the context then to the service which seems like a bad idea since the idea of a context would be to hide implementation details from the caller.

from the callers point of view they just want to accomplish something, right, they don’t really care this is going over […] a service

I don’t think I’m trying to test too much and I can understand why people might not want to test their http_client. I guess I’d like if I could be able to override config based dependency injections per test but thats not possible.

I would appreciate any feedback, blog posts, examples, etc.

Here’s how I deal with DI and testing:

CarService should be a very thin layer over the external API, defining structs for the requests/responses but no business logic. Test it using ex_vcr so that you have an integration test that is cheap to run.
Define the public interface of CarService with a behaviour, as recommended in mocks and explicit contracts

Inject CarService into Listings using the Application environment:

defmodule Listings
  @car_service Application.get_env(:app, :listings)[:car_service]

  def info(%Car{} = car) do
    case @car_service.fetch_info(car) do ...
  end
end

Create a programmable FakeCarService module, this one stores the programmed responses in the Process dict so that tests can be run in parallel:

defmodule FakeCarService do
  def fetch_info(%Car{id: id}) do
    Process.get({__MODULE__, :fetch_info, %{id: id}) 
  end

  def respond_to(function_name, args, with: result) do
    Process.put({__MODULE__, function_name, args}, result)
  end
end

Then in the test:

describe "Listing info" do
  def "With a valid car" do
    FakeCarService.respond_to(:fetch_info, %{id: 1}, with: %CarInfo{seats: 4})
    assert %{seats: 4} = Listings.info(%Car{id: 1})
  end

  def "Handles authorization error" do
    FakeCarService.respond_to(:fetch_info, %{id: 2}, with: {:error, "Unauthorized"})
    assert {:error, "Unable to get info"} = Listings.info(%Car{id: 2})
  end
end

There are more complex variations on this basic setup, but you can adopt them as your needs grow.

Packages like the recently released mox save you from writing the Fake* modules by hand, if you prefer.

2 Likes