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