Current section

Files

Jump to
double_down docs dynamic.md
Raw

docs/dynamic.md

# Dynamic Facades
[< Testing](testing.md) | [Up: README](../README.md) | [Logging >](logging.md)
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 a contract or facade.
## When to use dynamic facades
| Scenario | Approach |
|----------|----------|
| New code, long-term boundary | Contract-based (`defcallback` + `DoubleDown.Facade`) |
| Legacy code without contracts | **Dynamic facade** |
| Third-party modules you can't modify | **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 `Dynamic.setup/1` in `test/test_helper.exs` **before**
`ExUnit.start()`:
```elixir
# test/test_helper.exs
DoubleDown.Dynamic.setup(MyApp.EctoRepo)
DoubleDown.Dynamic.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 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:
```elixir
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)
end
```
### Stubs
```elixir
# 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
```elixir
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)
```
### Module fake (Mimic-style override)
Override one operation, delegate the rest to the original:
```elixir
DoubleDown.Double.fake(SomeClient,
DoubleDown.Dynamic.original_module(SomeClient))
|> DoubleDown.Double.expect(:fetch, fn [_] -> {:error, :not_found} end)
# fetch is overridden, all other functions delegate to the original
```
## Passthrough 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 a module fake:
```elixir
DoubleDown.Double.fake(SomeClient,
DoubleDown.Dynamic.original_module(SomeClient))
```
## 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:
```elixir
# 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
`Dynamic.setup/1` refuses to set up facades for:
- **DoubleDown contract modules** — use `DoubleDown.Facade` instead
- **DoubleDown internal modules** — would break the dispatch machinery
- **NimbleOwnership** — required by dispatch
- **Erlang/OTP modules** — would be catastrophic
## Comparison with contract-based facades
| Feature | Contract-based | Dynamic |
|---------|---------------|---------|
| Setup ceremony | `defcallback` + `use Facade` + config | `Dynamic.setup(Module)` |
| Typespecs | Generated `@spec` on facade | None |
| LSP docs | `@doc` on facade functions | None |
| Compile-time spec checking | Yes (impl vs contract) | No |
| Production dispatch | Zero-cost inlined calls | N/A (test-only) |
| Test doubles | Full Double API | Full Double API |
| Stateful fakes | Full support | Full support |
| Cross-contract state | Full support | Full support |
| Dispatch logging | Full support | Full support |
| async: true | Yes | Yes |
## Migration path
Start with dynamic facades for quick wins, then graduate to
contract-based facades for boundaries you want to keep long-term:
1. `Dynamic.setup(MyModule)` in test_helper.exs
2. Write tests using Double APIs
3. When the boundary stabilises, define a `defcallback` contract
4. Create a facade with `use DoubleDown.Facade`
5. Remove the `Dynamic.setup` call
---
[< Testing](testing.md) | [Up: README](../README.md) | [Logging >](logging.md)