Testing Effectful Code
View Source< Serializable Coroutines (EffectLogger) | Index | Hexagonal Architecture >
Algebraic effects enable a powerful testing pattern: domain logic written with effects runs with real handlers in production and pure in-memory handlers in tests. This makes property-based testing with thousands of iterations per second practical for code that would normally require a database.
The pattern
- Write domain logic with effects (Port, Transaction, Reader, Fresh, etc.)
- Run production code with real handlers (Ecto, HTTP clients, etc.)
- Run tests with pure/stub handlers (in-memory maps, deterministic values, recorded calls)
No mocks, no process dictionary tricks, no special test infrastructure. The same code, different handlers.
Example: a todo app
Domain logic uses effects for all I/O:
defmodule Todos.Handlers do
use Skuld.Syntax
defcomp handle(%ToggleTodo{id: id}) do
ctx <- Reader.ask(CommandContext)
todo <- Repository.get_todo!(ctx.tenant_id, id)
updated <- Repository.update_todo!(ctx.tenant_id, Todo.changeset(todo, %{completed: not todo.completed}))
{:ok, updated}
end
endA single Run.execute/2 entry point switches handler stacks by mode:
defmodule Run do
def execute(operation, opts) do
mode = Keyword.get(opts, :mode, :database)
tenant_id = Keyword.fetch!(opts, :tenant_id)
Todos.Handlers.handle(operation)
|> with_handlers(mode, tenant_id)
|> Comp.run!()
end
defp with_handlers(comp, :database, tenant_id) do
comp
|> Reader.with_handler(%CommandContext{tenant_id: tenant_id}, tag: CommandContext)
|> Port.with_handler(%{Repository.Contract => Repository.Ecto})
|> Fresh.with_uuid7_handler()
|> Throw.with_handler()
end
defp with_handlers(comp, :in_memory, tenant_id) do
comp
|> Reader.with_handler(%CommandContext{tenant_id: tenant_id}, tag: CommandContext)
|> Port.with_handler(%{Repository.Contract => Repository.InMemory})
|> InMemoryPersist.with_handler()
|> Fresh.with_test_handler()
|> Throw.with_handler()
end
endUnit tests
Test individual computations by installing the handlers you need:
test "toggle marks incomplete todo as complete" do
todo = %Todo{id: "1", title: "Buy milk", completed: false}
result =
Todos.Handlers.handle(%ToggleTodo{id: "1"})
|> Reader.with_handler(%CommandContext{tenant_id: "t1"}, tag: CommandContext)
|> Port.with_test_handler(%{
Repository.__key__(:get_todo, "t1", "1") => {:ok, todo},
Repository.__key__(:update_todo, "t1", _) => {:ok, %Todo{todo | completed: true}}
})
|> Throw.with_handler()
|> Comp.run!()
assert {:ok, %Todo{completed: true}} = result
endProperty-based tests
With pure handlers, property-based testing becomes straightforward:
use ExUnitProperties
property "ToggleTodo is self-inverse" do
check all(cmd <- Generators.create_todo(), max_runs: 100) do
{:ok, original} = Run.execute(cmd, mode: :in_memory, tenant_id: "t1")
{:ok, toggled} = Run.execute(
%ToggleTodo{id: original.id},
mode: :in_memory, tenant_id: "t1"
)
{:ok, restored} = Run.execute(
%ToggleTodo{id: original.id},
mode: :in_memory, tenant_id: "t1"
)
assert restored.completed == original.completed
end
end
property "CompleteAll only affects incomplete todos" do
check all(todos <- Generators.todos(max_length: 20)) do
incomplete_count = Enum.count(todos, &(not &1.completed))
{:ok, result} = run_with_todos(%CompleteAll{}, todos)
assert result.updated == incomplete_count
end
endThis runs hundreds of iterations per second because there's no database, no network, no process overhead - just pure function calls.
Built-in test handlers
Skuld provides test handlers for common effects:
| Effect | Test handler | What it does |
|---|---|---|
| Port | Port.with_test_handler/2 | Exact-match stub map |
| Port | Port.with_fn_handler/2 | Pattern-matching function |
| Port | Port.with_stateful_handler/4 | Stateful fn(mod, name, args, state) -> {result, new_state} |
| Port | Any handler + log: true | Dispatch logging in Port.State.log |
| Port.Repo | Repo.Test.new/1 (fn resolver) | Stateless Repo — writes apply changesets, reads use fallback or error |
| Port.Repo | Repo.InMemory.with_handler/3 | Stateful Repo — PK read-after-write, fallback for non-PK reads |
| Transaction | Transaction.Noop.with_handler/0 | Env state rollback, no database |
| Fresh | Fresh.with_test_handler/0 | Deterministic UUIDs (UUID5) |
| Random | Random.with_handler/1 | Fixed sequence or seeded |
| State | (standard handler) | In-memory, no persistence |
| Parallel | Parallel.with_sequential_handler/0 | Sequential for determinism |
| AtomicState | AtomicState.with_state_handler/1 | State-backed, no Agent |
Writing domain-specific generators
Create StreamData generators for your domain types:
defmodule MyApp.Generators do
use ExUnitProperties
def create_todo do
gen all(
title <- string(:alphanumeric, min_length: 1, max_length: 100),
priority <- member_of([:low, :medium, :high])
) do
%CreateTodo{title: title, priority: priority}
end
end
def todos(opts \\ []) do
max = Keyword.get(opts, :max_length, 10)
list_of(todo(), max_length: max)
end
defp todo do
gen all(
id <- string(:alphanumeric, length: 8),
title <- string(:alphanumeric, min_length: 1),
completed <- boolean()
) do
%Todo{id: id, title: title, completed: completed}
end
end
endTesting plain hexagons with Mox
When testing plain Elixir code that drives a Port contract (via
the generated Port module or Port.Adapter.Effectful), you can use
Mox against the contract's generated
Behaviour for isolated unit tests — no effect machinery needed. This
also strengthens the incremental adoption story: introducing a Port
contract immediately improves test isolation, before any effectful
code is written.
See Testing plain hexagons with Mox in the Hexagonal Architecture recipe for the full setup, examples, and adoption path.
Stateful test handlers
When tests need read-after-write consistency — insert a record, then
get it back within the same computation — use Port.with_stateful_handler
or the built-in Repo.InMemory.
Port.with_stateful_handler
The primitive for building custom stateful test doubles. The handler
function receives (mod, name, args, state) and returns
{result, new_state}:
handler = fn
MyCache, :put, [key, value], state ->
{:ok, Map.put(state, key, value)}
MyCache, :get, [key], state ->
{Map.get(state, key), state}
end
result =
my_comp
|> Port.with_stateful_handler(%{}, handler)
|> Comp.run!()Use output: to inspect the final handler state:
{result, final_state} =
my_comp
|> Port.with_stateful_handler(%{}, handler,
output: fn result, state -> {result, state.handler_state} end
)
|> Comp.run!()Repo.Test
A stateless Repo handler. Repo.Test.new/1 returns a 3-arity fn
resolver for use in a Port.with_handler/3 registry. Write operations
apply changesets and return {:ok, struct}. All read operations go
through an optional fallback_fn, or raise a clear error — the adapter
never silently returns nil or [] because it has no basis for
claiming a record does or doesn't exist.
alias Skuld.Effects.Port.Repo
# Writes only — reads will raise:
comp
|> Port.with_handler(%{Repo => Repo.Test.new()})
|> Throw.with_handler()
|> Comp.run!()
# With fallback for reads:
comp
|> Port.with_handler(%{
Repo => Repo.Test.new(
fallback_fn: fn
:get, [User, 1] -> %User{id: 1, name: "Alice"}
:all, [User] -> [%User{id: 1, name: "Alice"}]
end
)
})
# Note: Repo.Test fallback_fn is 2-arity (operation, args) — stateless.
# Repo.InMemory fallback_fn is 3-arity (operation, args, state) — has store access.
|> Throw.with_handler()
|> Comp.run!()Repo.InMemory
A stateful in-memory Repo implementation built on
Port.with_stateful_handler. State is a nested
%{Schema => %{pk => struct}} map. Writes are always handled by
the state. PK-based reads (get, get!) check state first — if the
record is found, it's returned; if not, the adapter falls through to
a fallback_fn or raises. All other reads go directly through the
fallback_fn or raise.
alias Skuld.Effects.Port.Repo
# Writes and PK reads — no fallback needed:
result =
comp do
{:ok, user} <- Repo.insert(User.changeset(%{name: "Alice"}))
found <- Repo.get(User, user.id)
{user, found}
end
|> Repo.InMemory.with_handler(Repo.InMemory.new())
|> Comp.run!()
assert {user, user} = resultSeed initial state and supply a fallback for non-PK reads:
state = Repo.InMemory.new(
seed: [%User{id: 1, name: "Alice"}, %User{id: 2, name: "Bob"}],
fallback_fn: fn
:all, [User], state ->
Map.get(state, User, %{}) |> Map.values()
:exists?, [User], state ->
map_size(Map.get(state, User, %{})) > 0
end
)
result =
Repo.all(User)
|> Repo.InMemory.with_handler(state)
|> Comp.run!()
assert length(result) == 2Inspect the final store:
{result, store} =
comp do
_ <- Repo.insert(User.changeset(%{name: "Alice"}))
_ <- Repo.insert(User.changeset(%{name: "Bob"}))
Repo.get(User, 1)
end
|> Repo.InMemory.with_handler(Repo.InMemory.new(),
output: fn result, state -> {result, state.handler_state} end
)
|> Comp.run!()
# store is %{User => %{1 => %User{...}, 2 => %User{...}}}When to use Repo.InMemory vs Repo.Test
Repo.Test— stateless: writes apply changesets and return{:ok, struct}but nothing is stored. All reads require afallback_fnor raise. Use for tests that only need fire-and-forget writes, or where you want full control over read return values.Repo.InMemory— stateful: writes store records, PK-based reads find them automatically. Non-PK reads require afallback_fn. Use when your test needs read-after-write consistency for PK lookups (insert then get, update then read back, etc.).
Tips
Test at the handler boundary - test computations with handlers, not the effect calls in isolation
Use
Port.with_fn_handlerfor property tests where exact values aren't known upfrontPort test handlers record calls - use
Port.with_test_handlerorPort.with_fn_handlerto stub persistence operationsMix handler modes -
with_handler,with_test_handler, andwith_fn_handlerall merge into a unified registry. Use runtime dispatch for contracts you want to exercise and test stubs for contracts you want to isolate:comp |> Port.with_test_handler(%{Notifications.__key__(:send, msg) => :ok}) |> Port.with_handler(%{Repository.Contract => Repo.Test.new()}) |> Throw.with_handler() |> Comp.run!()Port dispatch logging captures every Port call as a
{mod, name, args, result}4-tuple directly inPort.State.log. Passlog: trueandoutput:to any Port handler installer — no Writer needed:{result, log} = comp |> Port.with_test_handler(stubs, log: true, output: fn r, state -> {r, state.log} end ) |> Throw.with_handler() |> Comp.run!() # log is [{mod, name, args, result}, ...] in chronological orderFresh.with_test_handler produces deterministic UUIDs - tests are reproducible
Compose handler stacks in one place - a
Run.execute/2or similar function keeps test and production stacks aligned
< Serializable Coroutines (EffectLogger) | Index | Hexagonal Architecture >