In many production systems you'll want to have one module capable of talking to many potential implementations of a collaborator module (e.g a in memory cache, a redis-based cache etc). While testing it's useful to control which module the module under test is talking to.
Here are the approaches I can see. The two points that seem to divide the approaches are their tool-ability (dialyzer) and their ability to handle stateful implementations (which need a pid
).
Modules are first class, so you can pass them in. Used in EEx, where passed module must implement a behaviour.
defmodule Cache do
use Behaviour
defcallback cached?(any,any) :: boolean
defcallback put(any,any) :: nil
end
defmodule Cache.Memory do
def put(set,x) do: Set.add set, x
def cached?(set,x) do: Set.member? map, x
end
defmodule Cache.Redis do
def put(redis_pid,x) do
{:ok,1} = Redis.set redis_pid, x, 1
end
def cached?(redis_pid,x) do
{:ok,x} = Redis.get(redis_pid,x)
x != nil
end
end
# usage
defmodule UsesCache do
def start(cache,cache_pid) do
cache.put(cache_pid,:hello)
true = cache.cached?(cache_pid,:hello)
end
end
UsesCache.start(Cache.Memory,HashSet.new)
Similar idea to duck-typing.
- simple
- dializer(?)
- modules with state - you'd have to pass a
pid
too, e.g{module,pid}
(eugh)
Write a Protocol
for the functionality. You can then pass in an opaque value to collaborators, and the implementation will be decided at runtime.
- handles stateful and stateless implementations easily
- dialyze-able
- requires stub implementations for testing
defprotocol Cache do
def cached?(id,item)
def put(id,item)
end
defmodule Cache.Memory do
defstruct set: nil
alias __MODULE__, as: Mod
defimpl Cache, for: Mod do
def put(%Mod{set: set},x) do: Set.add set, x
def cached?(%Mod{set: set},x) do: Set.member? map, x
end
end
defmodule Cache.Redis do
defstruct redis: nil
alias __MODULE__, as: Mod
defimpl Cache, for: Mod do
def put(%Mod{redis: redis},x) do
{:ok,1} = Redis.set redis, x, 1
end
def cached?(%Mod{redis: redis},x) do
{:ok,x} = Redis.get(redis,x)
x != nil
end
end
end
# usage
defmodule UsesCache do
def start(cache) do
Cache.put(cache,:hello)
true = Cache.cached?(cache,:hello)
end
end
UsesCache.start(%CacheMemory{set:HashSet.new})
For a single method, you could just pass a function. Then in tests you pass a stub method, and in production you can wrap up the real module behind it.
def start_link({some_callback}) do
:gen_server.start_link(@name,SomeModule,{some_callback},[])
end
def init({some_callback}) do
{:ok,%State{callback: some_callback})
end
Now the callback
field of state can be used by functions of this gen_server
module.
# usage
defmodule UsesCache do
def start(put,cached) do
put.(:hello)
true = cached.(:hello)
end
end
# can create callbacks from anything: stateful, stateless etc
- works with any implementation, even ad-hoc
- dialyze-able
- pass many values for large APIs
Create a stateful module that holds the module, refer to that.
- doesn't work for use-cases with many instances (pass-the-pid)
- dialyzer - need a Behaviour for the return type of the getter
You could generate a module based on a run-time config.
- moving parts
- doesn't work for use-cases with many instances (pass-the-pid)
- dialyzer - seems likely to throw it off
You discuss a few approaches here, but have you found a solution that works for you yet?
As a person who's worked in large codebases and seen the benefits of both purity and isolated unit testing, I'm quite concerned about true isolation and DI and this sort of thing, as I explore Elixir.