Packages
double_down
0.29.0
0.69.0
0.68.0
0.66.0
0.65.0
0.64.1
0.64.0
0.63.3
0.63.2
0.63.1
0.63.0
0.62.1
0.61.0
0.60.4
0.60.3
0.60.2
0.60.1
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.0
0.52.3
0.52.2
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.1
0.48.0
0.47.2
0.47.1
0.47.0
0.46.3
0.46.2
0.46.1
0.46.0
0.45.0
0.44.0
0.43.0
0.42.0
0.41.1
0.41.0
0.40.0
0.39.0
0.38.0
0.37.2
0.37.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.1
0.31.0
0.30.1
0.30.0
0.29.0
0.28.1
0.28.0
0.27.0
0.26.0
0.24.0
Builds on the Mox pattern — generates behaviours and dispatch facades from `defcallback` declarations — and adds stateful test doubles powerful enough to test Ecto.Repo operations without a database.
Current section
Files
Jump to
Current section
Files
lib/double_down/log.ex
defmodule DoubleDown.Log do
@moduledoc """
Log-based expectation matcher for DoubleDown dispatch logs.
Declares expectations against the dispatch log after execution.
Matches on the full `{contract, operation, args, result}` tuple —
including results, which is meaningful because DoubleDown handlers
(especially `Repo.Test`) do real computation (changeset validation,
PK autogeneration, timestamps).
## Basic usage
DoubleDown.Log.match(:insert, fn
{_, _, [%Changeset{data: %Thing{}}], {:ok, %Thing{}}} -> true
end)
|> DoubleDown.Log.reject(:delete)
|> DoubleDown.Log.verify!(MyContract)
Matcher functions only need the positive matching clauses —
`FunctionClauseError` is caught and interpreted as "didn't match".
No need for a `_ -> false` catch-all, though returning `false`
explicitly can be useful for excluding specific values that are
hard to exclude with pattern matching alone.
## Matching on results
Unlike Mox/Mimic where asserting on return values would be
circular (you wrote the stub), DoubleDown handlers do real
computation. Matching on results is a meaningful assertion:
DoubleDown.Log.match(:insert, fn
{_, _, [%Changeset{data: %Thing{}}],
{:ok, %Thing{id: id}}} when is_binary(id) -> true
end)
|> DoubleDown.Log.verify!(RepoContract)
## Counting occurrences
DoubleDown.Log.match(:insert, fn
{_, _, [%Changeset{data: %Discrepancy{}}], {:ok, _}} -> true
end, times: 3)
|> DoubleDown.Log.verify!(DoubleDown.Repo)
## Multi-contract
Build separate matcher chains and verify each against its contract:
todos_log =
DoubleDown.Log.match(:create_todo, fn {_, _, _, {:ok, _}} -> true end)
repo_log =
DoubleDown.Log.match(:insert, fn {_, _, _, {:ok, _}} -> true end)
DoubleDown.Log.verify!(todos_log, MyApp.Todos)
DoubleDown.Log.verify!(repo_log, DoubleDown.Repo)
## Matching modes
### Loose (default)
Matchers must be satisfied in order within each operation, but
other log entries are allowed between them. Different operations
are matched independently (no cross-operation ordering):
DoubleDown.Log.match(:insert, matcher)
|> DoubleDown.Log.match(:update, matcher)
|> DoubleDown.Log.verify!(MyContract)
# Passes if log contains an insert and an update,
# regardless of other entries or relative order.
### Strict
Every log entry for the contract must be matched.
No unmatched entries allowed:
DoubleDown.Log.match(:insert, matcher)
|> DoubleDown.Log.match(:update, matcher)
|> DoubleDown.Log.verify!(MyContract, strict: true)
## Relationship to existing APIs
Built on `DoubleDown.Testing.get_log/1`. Completely decoupled from
handler choice — works with `Repo.Test`, `Repo.InMemory`,
`set_fn_handler`, `set_stateful_handler`, or `DoubleDown.Double`.
Can be used alongside `DoubleDown.Double` — Handler for fail-fast
validation and producing return values, Log for after-the-fact
result inspection.
"""
defstruct expectations: []
@type matcher :: (tuple() -> boolean())
@type expectation ::
{:match, atom(), matcher(), pos_integer()}
| {:reject, atom()}
@type t :: %__MODULE__{
expectations: [expectation()]
}
@doc """
Create an empty log expectation accumulator.
"""
@spec new() :: t()
def new, do: %__MODULE__{}
@doc """
Add a match expectation for an operation.
The matcher function receives the full log tuple
`{contract, operation, args, result}` and should return a truthy
value for entries that match. Write only the clauses that should
match — `FunctionClauseError` is caught and treated as "didn't
match".
The accumulator argument is optional — when omitted, a fresh
accumulator is created via `new/0`.
## Options
* `:times` — require exactly `n` matching entries (default 1).
"""
@spec match(t(), atom(), matcher(), keyword()) :: t()
def match(operation, matcher_fn, opts \\ [])
def match(operation, matcher_fn, opts)
when is_atom(operation) and is_function(matcher_fn, 1) and is_list(opts) do
match(new(), operation, matcher_fn, opts)
end
def match(%__MODULE__{} = acc, operation, matcher_fn)
when is_atom(operation) and is_function(matcher_fn, 1) do
match(acc, operation, matcher_fn, [])
end
def match(%__MODULE__{} = acc, operation, matcher_fn, opts)
when is_atom(operation) and is_function(matcher_fn, 1) and is_list(opts) do
times = Keyword.get(opts, :times, 1)
if times < 1 do
raise ArgumentError, "times must be >= 1, got: #{times}"
end
expectation = {:match, operation, matcher_fn, times}
%{acc | expectations: acc.expectations ++ [expectation]}
end
@doc """
Add a reject expectation for an operation.
Verification will fail if the operation appears anywhere in the
log for the contract.
The accumulator argument is optional — when omitted, a fresh
accumulator is created via `new/0`.
"""
@spec reject(t(), atom()) :: t()
def reject(operation) when is_atom(operation) do
reject(new(), operation)
end
def reject(%__MODULE__{} = acc, operation) when is_atom(operation) do
expectation = {:reject, operation}
%{acc | expectations: acc.expectations ++ [expectation]}
end
@doc """
Verify all expectations against the dispatch log for a contract.
Reads the dispatch log via `DoubleDown.Testing.get_log/1` and
checks that all match expectations are satisfied and all reject
expectations hold.
## Options
* `:strict` — when `true`, every log entry for the contract
must be matched by some matcher. Unmatched entries cause
verification to fail. Default `false` (loose mode).
Returns `{:ok, log}` where `log` is the full dispatch log for the
contract — useful in the REPL for inspecting what happened.
"""
@spec verify!(t(), module(), keyword()) :: {:ok, list()}
def verify!(acc, contract, opts \\ [])
def verify!(%__MODULE__{expectations: []}, _contract, _opts) do
raise ArgumentError, "no expectations to verify — call match/3 or reject/1 first"
end
def verify!(%__MODULE__{expectations: expectations}, contract, opts)
when is_atom(contract) do
strict? = Keyword.get(opts, :strict, false)
log = DoubleDown.Testing.get_log(contract)
# Separate match and reject expectations
{matches, rejects} =
Enum.split_with(expectations, fn
{:match, _, _, _} -> true
{:reject, _} -> false
end)
# Group matches by operation, preserving declaration order
# within each group. Loose-partial means per-operation ordering with
# independent cursors — no cross-operation ordering enforced.
matches_by_op =
Enum.group_by(matches, fn {:match, op, _, _} -> op end)
# Verify match expectations per operation group
all_matched_indices =
Enum.flat_map(matches_by_op, fn {_op, group_matches} ->
verify_matches(group_matches, log, contract)
end)
# Collect matched indices for strict mode
matched_index_set = MapSet.new(all_matched_indices)
# Verify reject expectations
verify_rejects(rejects, log, contract)
# Strict mode: check for unmatched log entries
if strict? do
verify_strict(log, matched_index_set, contract)
end
{:ok, log}
end
# -- Internal: match verification --
defp verify_matches(matches, log, contract) do
indexed_log = Enum.with_index(log)
{_remaining_log, matched_indices} =
Enum.reduce(matches, {indexed_log, []}, fn
{:match, operation, matcher_fn, times}, {remaining, acc} ->
find_n_matches(remaining, contract, operation, matcher_fn, times, acc, log)
end)
matched_indices
end
defp find_n_matches(remaining_log, contract, operation, matcher_fn, times, acc_indices, log) do
Enum.reduce(1..times, {remaining_log, acc_indices}, fn n, {remaining, indices} ->
case find_next_match(remaining, contract, operation, matcher_fn) do
{:ok, index, rest} ->
{rest, [index | indices]}
:not_found ->
raise """
DoubleDown.Log expectation not satisfied:
#{inspect(contract)}.#{operation} — match #{n} of #{times} not found.
Searched #{length(remaining)} remaining log entries.
Tip: check that the handler produced the expected result and that
the matcher function's pattern matches the log entry shape
{contract, operation, args, result}.
Full log for #{inspect(contract)}:
#{format_log(log)}
"""
end
end)
end
defp find_next_match([], _contract, _operation, _matcher_fn), do: :not_found
defp find_next_match([{entry, index} | rest], contract, operation, matcher_fn) do
{entry_contract, entry_op, _args, _result} = entry
if entry_contract == contract and entry_op == operation and matches?(matcher_fn, entry) do
{:ok, index, rest}
else
find_next_match(rest, contract, operation, matcher_fn)
end
end
defp matches?(matcher_fn, entry) do
matcher_fn.(entry)
rescue
FunctionClauseError -> false
end
defp format_log([]), do: " (empty)"
defp format_log(log) do
Enum.map_join(log, "\n", fn {c, op, args, result} ->
" #{inspect(c)}.#{op} args=#{inspect(args)} result=#{inspect(result)}"
end)
end
# -- Internal: reject verification --
defp verify_rejects(rejects, log, contract) do
Enum.each(rejects, fn {:reject, operation} ->
found =
Enum.find(log, fn {c, op, _args, _result} ->
c == contract and op == operation
end)
if found do
{_, _, args, result} = found
raise """
DoubleDown.Log reject expectation violated:
#{inspect(contract)}.#{operation} was called but should not have been.
Args: #{inspect(args)}
Result: #{inspect(result)}
Full log for #{inspect(contract)}:
#{format_log(log)}
"""
end
end)
end
# -- Internal: strict mode --
defp verify_strict(log, matched_index_set, contract) do
unmatched =
log
|> Enum.with_index()
|> Enum.reject(fn {_entry, index} -> MapSet.member?(matched_index_set, index) end)
if unmatched != [] do
details =
Enum.map_join(unmatched, "\n", fn {{c, op, args, result}, _index} ->
" #{inspect(c)}.#{op} args=#{inspect(args)} result=#{inspect(result)}"
end)
raise """
DoubleDown.Log strict verification failed — unmatched log entries:
#{details}
Full log for #{inspect(contract)}:
#{format_log(log)}
"""
end
end
end