Packages

HexPort: Hexagonal architecture ports for Elixir. Typed port contracts with async-safe test doubles, dispatch logging, and stateful test handlers. Define boundaries with `defport`, swap implementations for testing without a database.

Current section

Files

Jump to
hex_port docs testing.md
Raw

docs/testing.md

# Testing
[< Getting Started](getting-started.md) | [Up: README](../README.md) | [Repo >](repo.md)
HexPort's testing system is built on
[NimbleOwnership](https://hex.pm/packages/nimble_ownership) — the same
ownership library that Mox uses internally. Each test process gets its
own handlers, state, and logs, so `async: true` works out of the box.
## Setup
Start the ownership server once in `test/test_helper.exs`:
```elixir
{:ok, _} = HexPort.Testing.start()
```
This starts a `NimbleOwnership` GenServer. In production, this server
doesn't exist, so the dispatch lookup is zero-cost (a single
`GenServer.whereis` returning `nil`).
## Handler modes
HexPort provides three ways to register test handlers. All are
process-scoped and isolated between concurrent tests.
### Module handler
Register any module that implements the contract's `@behaviour`:
```elixir
HexPort.Testing.set_handler(MyApp.Todos, MyApp.Todos.Fake)
```
Dispatch calls `apply(MyApp.Todos.Fake, operation, args)`.
### Function handler
Register a 2-arity closure `(operation, args) -> result` with pattern
matching:
```elixir
HexPort.Testing.set_fn_handler(MyApp.Todos, fn
:create_todo, [params] -> {:ok, struct!(Todo, params)}
:get_todo, [id] -> {:ok, %Todo{id: id, title: "Test"}}
:list_todos, [_tenant] -> [%Todo{id: "1", title: "Test"}]
end)
```
This is the most common mode for simple tests where you just need
canned return values.
### Stateful handler
Register a 3-arity closure `(operation, args, state) -> {result, new_state}`
with an initial state value:
```elixir
HexPort.Testing.set_stateful_handler(
MyApp.Todos,
fn
:create_todo, [params], todos ->
todo = struct!(Todo, Map.put(params, :id, map_size(todos) + 1))
{{:ok, todo}, Map.put(todos, todo.id, todo)}
:get_todo, [id], todos ->
case Map.get(todos, id) do
nil -> {{:error, :not_found}, todos}
todo -> {{:ok, todo}, todos}
end
:list_todos, [_tenant], todos ->
{Map.values(todos), todos}
end,
%{} # initial state
)
```
State is stored in NimbleOwnership and updated atomically on each
dispatch. This gives you a lightweight in-memory store for tests that
need read-after-write consistency.
For Ecto Repo operations specifically, HexPort ships ready-made
stateful test doubles — see [Repo](repo.md).
## Dispatch logging
Record every call that crosses a port boundary, then assert on the
sequence:
```elixir
setup do
HexPort.Testing.enable_log(MyApp.Todos)
HexPort.Testing.set_fn_handler(MyApp.Todos, fn
:get_todo, [id] -> {:ok, %Todo{id: id}}
end)
:ok
end
test "logs dispatch calls" do
MyApp.Todos.get_todo("42")
assert [{:get_todo, ["42"], {:ok, %Todo{id: "42"}}}] =
HexPort.Testing.get_log(MyApp.Todos)
end
```
The log captures `{operation, args, result}` tuples in dispatch order.
Enable logging before making calls; `get_log/1` returns the full
sequence.
## Process sharing and async safety
All test handlers are process-scoped. `async: true` tests run in full
isolation — each test process has its own handlers, state, and logs.
**Task.async children** automatically inherit their parent's handlers
via the `$callers` chain. No setup needed.
**Other processes** (plain `spawn`, Agent, GenServer) need explicit
sharing:
```elixir
HexPort.Testing.allow(MyApp.Todos, self(), agent_pid)
```
`allow/3` also accepts a lazy pid function for processes that don't
exist yet at setup time:
```elixir
HexPort.Testing.allow(MyApp.Todos, self(), fn -> GenServer.whereis(MyWorker) end)
```
## Cleanup
Call `reset/0` to clear all handlers, state, and logs for the current
process:
```elixir
setup do
HexPort.Testing.reset()
# ... set up fresh handlers ...
end
```
In practice, most tests just set handlers in `setup` without calling
`reset` — NimbleOwnership's per-process isolation means there's no
cross-test leakage.
## Mox compatibility
Because `defport` generates standard `@callback` declarations, the
contract module works as a Mox behaviour out of the box:
```elixir
# test/support/mocks.ex
Mox.defmock(MyApp.Todos.Mock, for: MyApp.Todos)
# config/test.exs
config :my_app, MyApp.Todos, impl: MyApp.Todos.Mock
```
```elixir
import Mox
setup :verify_on_exit!
test "get_todo returns the expected todo" do
MyApp.Todos.Mock
|> expect(:get_todo, fn "42" -> {:ok, %Todo{id: "42"}} end)
assert {:ok, %Todo{id: "42"}} = MyApp.Todos.get_todo("42")
end
```
This works because HexPort's dispatch resolution checks test handlers
first, then falls back to application config. When using Mox, the
config points to the mock module, and Mox's own process-scoped
expectations provide the isolation.
You can use either approach — HexPort's built-in handlers or Mox —
depending on your preference. HexPort's handlers don't require
defining mock modules or changing config, and the stateful handler
mode has no Mox equivalent.
---
[< Getting Started](getting-started.md) | [Up: README](../README.md) | [Repo >](repo.md)