Hey Ringo,
I’ve just done something similar for our elixircards app - we have an app for authentication and I wanted to separate it from our accounts app - and in tests I didn’t want to hit the database - so here’s roughly what we came up with:
store
- has StoreBehaviour.ex
- has InMemoryStore.ex which is a GenServer implementation for testing purposes
auth
- depends on “store”
- needs to use a data store
- in tests it uses the in_memory store provided by “store”
- in dev/prod it uses the real store, eg. accounts
accounts
- depends on “store”
- needs to provide a data store implementation
- the api conforms to “StoreBehaviour”
Here’s some code snippets:
defmodule Store.Behaviour do
@moduledoc """
This module defines the interface we expect all stores to support.
By using this approach you can decouple your tests from your datastore
defmodule Store do
@behaviour Store.Behaviour
...
end
"""
@type behaviour_impl :: {module(), module() | pid()}
@callback get(behaviour_impl, args :: Number.t) :: map()
@callback get_by(behaviour_impl, args :: Keyword.t()) :: map()
@callback get_all(behaviour_impl) :: list(map())
@callback insert(behaviour_impl, args :: map()) :: map()
@spec get(behaviour_impl, args :: Keyword.t) :: map()
def get({impl, mod_or_pid}, args), do: apply(impl, :get, [mod_or_pid, args])
@spec get_by(behaviour_impl, args :: Keyword.t()) :: map()
def get_by({impl, mod_or_pid}, args), do: apply(impl, :get_by, [mod_or_pid, args])
@spec get_all(behaviour_impl) :: list(map())
def get_all({impl, mod_or_pid}), do: apply(impl, :get_all, [mod_or_pid])
@spec insert(behaviour_impl, args :: Keyword.t()) :: {:ok, map()} | {:error, reason :: any()}
def insert({impl, mod_or_pid}, args), do: apply(impl, :insert, [mod_or_pid, args])
end
The InMemory store is just an implementation of this behaviour using GenServer to persist the data:
defmodule Store.InMemory do
@moduledoc """
This is an in memory implementation of a datastore backed by a GenServer
and a list of data.
"""
@behaviour Store.Behaviour
use GenServer
@doc """
Starts the process with an empty list by default
"""
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, [], opts)
end
@doc """
Insert an item into the store.
"""
@impl Store.Behaviour
def insert(pid, item) do
GenServer.call(pid, {:insert, item})
end
# ...
end
And now in “auth” app we can make calls via StoreBehaviour. Note here we’re using a default @store unless a different store is passed to the function:
defmodule Auth.Guardian do
use Guardian, otp_app: :auth
alias Store.Behaviour, as: StoreBehaviour
@store Application.fetch_env!(:auth, :user_repo)
@doc """
Decode a user token and try to get the user
"""
@spec resource_from_claims(map) :: {:ok, user} | error
def resource_from_claims(a, store \\ @store)
def resource_from_claims(%{"sub" => "User:" <> id}, store) do
result = StoreBehaviour.get(store, id)
# ...
end
# ...
end
Which then means from “auth” tests we can call it like this:
defmodule Auth.GuardianTest do
use ExUnit.Case, async: true
alias Store.InMemory, as: Store
alias Accounts.User
@valid_email "hello@email.com"
@valid_password_hash "asdasdsa"
@valid_user %User{email: @valid_email, password_hash: @valid_password_hash}
setup do
{:ok, pid} = Store.start_link()
{:ok, %{store: pid}}
end
describe ".resource_from_claims" do
test "returns User when token passed in", ctx do
user = Store.insert(ctx.store, @valid_user)
result = Auth.Guardian.resource_from_claims(%{"sub" => "User:1"}, {Store, ctx.store})
assert result == {:ok, user}
end
end
end
And finally our real data store, accounts, also implements StoreBehaviour so it looks something like this:
defmodule Accounts do
@behaviour Store.Behaviour
@impl Store.Behaviour
def get(mod, id), do: Repo.get(mod, id)
@impl Store.Behaviour
def get_by(mod, opts), do: Repo.get_by(mod, opts)
@impl Store.Behaviour
def get_all(mod), do: Repo.all(mod)
@impl Store.Behaviour
def insert(mod, params), do: Repo.insert(mod, params)
# ...
end
And in “auth” config we set up the default store like this:
use Mix.Config
config :auth, user_repo: {Accounts, Users.User}
So that’s pretty much it.
Note: I did look at replacing our InMemory store with Mox, but for now what we have works.
I hope that helps. It’s basically the same idea as @silviurosu showed.