< Testing | Up: README | Logging >
Dynamic facades enable Mimic-style bytecode interception — replace
any module with a dispatch shim at test time, then use the full
DoubleDown.Double API without defining an explicit contract or
facade. The shimmed module becomes both the contract (the name
used in test double setup) and the facade (what callers use).
When to use dynamic facades
| Scenario | Approach |
|---|---|
| New code, long-term boundary | Contract-based (defcallback + DoubleDown.ContractFacade) |
Existing @behaviour you don't control | DoubleDown.BehaviourFacade |
| Legacy code without contracts or behaviours | Dynamic facade |
| Third-party modules with no behaviour | Dynamic facade |
| Quick prototyping | Dynamic facade, graduate to contract-based later |
Dynamic facades trade compile-time safety (typespecs, LSP docs, spec mismatch detection) for zero-ceremony setup. Both approaches use the same dispatch and Double infrastructure — they coexist in the same test suite.
Setup
Call DynamicFacade.setup/1 in test/test_helper.exs before
ExUnit.start():
# test/test_helper.exs
DoubleDown.DynamicFacade.setup(MyApp.EctoRepo)
DoubleDown.DynamicFacade.setup(SomeThirdPartyClient)
ExUnit.start()
{:ok, _} = DoubleDown.Testing.start()setup/1 copies the original module to a backup
(Module.__dd_original__) and replaces it with a dispatch shim.
The original module name becomes the implicit contract — use it
as the first argument to all Double API calls:
# MyApp.EctoRepo is the contract — same module callers use
DoubleDown.Double.fake(MyApp.EctoRepo, DoubleDown.Repo.InMemory)
DoubleDown.Double.stub(SomeThirdPartyClient, fn :fetch, [id] -> {:ok, id} end)The shim checks NimbleOwnership for test handlers, falling back to the original implementation when none are installed. Bytecode replacement is VM-global — it must happen before any tests run. Tests that don't install a handler get the original module's behaviour automatically.
Using Double APIs
After setup, the full DoubleDown.Double API works with the
dynamic module — expects, stubs, fakes, passthrough, stateful
responders, cross-contract state access, dispatch logging:
setup do
# Stateful fake
DoubleDown.Double.fake(MyApp.EctoRepo, DoubleDown.Repo.InMemory)
:ok
end
test "insert then get" do
{:ok, user} = MyApp.EctoRepo.insert(User.changeset(%{name: "Alice"}))
assert ^user = MyApp.EctoRepo.get(User, user.id)
endStubs
# Function stub
DoubleDown.Double.stub(SomeClient, fn
:fetch, [id] -> {:ok, %{id: id}}
:list, [] -> []
end)
# Per-operation stub
DoubleDown.Double.stub(SomeClient, :fetch, fn [id] -> {:ok, %{id: id}} end)Expects
DoubleDown.Double.expect(SomeClient, :fetch, fn [_] -> {:error, :timeout} end)
# With passthrough — delegates to the original module
DoubleDown.Double.expect(SomeClient, :fetch, :passthrough)
# Stateful expect (requires a fake)
DoubleDown.Double.expect(SomeClient, :fetch, fn [id], state ->
{Map.get(state, id), state}
end)Override one operation, delegate the rest
Use Double.dynamic/1 to set up the original module as the
fallback, then layer expects on top:
SomeClient
|> DoubleDown.Double.dynamic()
|> DoubleDown.Double.expect(:fetch, fn [_] -> {:error, :not_found} end)
# fetch is overridden, all other functions delegate to the originalPassthrough to original
When no test handler is installed, the dynamic shim automatically falls back to the original module. This means unrelated tests are completely unaffected.
When a handler IS installed, Double.passthrough() and
:passthrough expects delegate to the fallback (fake, stub, or
module fake) — not directly to the original. To delegate to the
original explicitly, use Double.dynamic/1:
SomeClient
|> DoubleDown.Double.dynamic()
|> DoubleDown.Double.expect(:fetch, :passthrough)Cross-contract state access
Dynamic facades participate in cross-contract state access. A 4-arity handler on a dynamic module can read state from contract-based facades, and vice versa:
# Contract-based Repo with InMemory
DoubleDown.Double.fake(DoubleDown.Repo, DoubleDown.Repo.InMemory)
# Dynamic module reads Repo state
DoubleDown.Double.fake(MyApp.Legacy,
fn :check_user, [id], state, all_states ->
repo_state = Map.get(all_states, DoubleDown.Repo, %{})
users = repo_state |> Map.get(User, %{}) |> Map.values()
{Enum.any?(users, &(&1.id == id)), state}
end,
%{}
)Guardrails
DynamicFacade.setup/1 refuses to set up facades for:
- DoubleDown contract modules — use
DoubleDown.ContractFacadeinstead - DoubleDown internal modules — would break the dispatch machinery
- NimbleOwnership — required by dispatch
- Erlang/OTP modules — would be catastrophic
Comparison of facade types
See Choosing a facade type
for a full feature comparison table across ContractFacade,
BehaviourFacade, and DynamicFacade.
Migration path
Start with dynamic facades for quick wins, then graduate to typed facades for boundaries you want to keep long-term:
DynamicFacade.setup(MyModule)in test_helper.exs- Write tests using Double APIs
- When the boundary stabilises, choose your facade type:
- If the module already defines
@callbackdeclarations, useDoubleDown.BehaviourFacade - Otherwise, define a
defcallbackcontract and useDoubleDown.ContractFacade
- If the module already defines
- Remove the
DynamicFacade.setupcall