Packages
assert_boundary
0.1.0
ExUnit assertion helpers for testing module dependency boundaries using Erlang's :xref.
Current section
Files
Jump to
Current section
Files
lib/assert_boundary/assertions.ex
defmodule AssertBoundary.Assertions do
@moduledoc """
ExUnit assertion helpers for module boundary testing.
These functions verify that module dependencies respect architectural
boundaries. Import them in your test modules or use
`use AssertBoundary, app: :my_app` which imports them automatically.
## Assertions
- `refute_calls/2` — no module matching `from` calls any module matching `to`.
- `assert_calls/2` — at least one module matching `from` calls a module matching `to`.
- `assert_boundary/2` — modules only depend on allowed targets (outgoing allowlist).
- `assert_encapsulated/2` — modules are only called by allowed callers (incoming allowlist).
All assertion functions accept a `%AssertBoundary.Graph{}` as the first
argument and pattern options as a keyword list. Patterns can be:
- A `Regex` tested against the module name (without the `Elixir.` prefix).
- A module atom for exact matching.
- `under(module)` for prefix matching (the module and all children).
- A list of patterns — matches if any element matches.
"""
import ExUnit.Assertions
alias AssertBoundary.Graph
@doc """
Asserts that no module matching `from` calls any module matching `to`.
Use this to enforce that two parts of your system don't directly depend
on each other.
## Examples
refute_calls(boundary, from: ~r/^MyApp\\.Web/, to: ~r/^MyApp\\.Repo\\.Internal/)
refute_calls(boundary, from: MyApp.Web.Router, to: ~r/^MyApp\\.Internal/)
"""
@spec refute_calls(Graph.t(), keyword()) :: :ok
def refute_calls(%Graph{} = graph, opts) do
from = Keyword.fetch!(opts, :from)
to = Keyword.fetch!(opts, :to)
violations = find_matching_calls(graph, from, to)
if violations != [] do
flunk(format_violation_message(from, to, violations))
end
:ok
end
@doc """
Asserts that at least one module matching `from` calls a module matching `to`.
Use this to verify expected dependencies exist.
## Examples
assert_calls(boundary, from: ~r/^MyApp\\.Web/, to: ~r/^MyApp\\.Schema/)
"""
@spec assert_calls(Graph.t(), keyword()) :: :ok
def assert_calls(%Graph{} = graph, opts) do
from = Keyword.fetch!(opts, :from)
to = Keyword.fetch!(opts, :to)
calls = find_matching_calls(graph, from, to)
if calls == [] do
from_modules = Graph.matching(graph, from)
to_modules = Graph.matching(graph, to)
message =
[
"Expected at least one call from #{format_pattern(from)} to #{format_pattern(to)}, but found none.",
"",
" Modules matching from: #{format_module_list(from_modules)}",
" Modules matching to: #{format_module_list(to_modules)}"
]
|> Enum.join("\n")
flunk(message)
end
:ok
end
@doc """
Asserts that modules matching `modules` only depend on modules matching
the `allow` patterns.
Any internal dependency not covered by an allow pattern is a boundary
violation. Dependencies on modules that also match the `modules` pattern
are always permitted — modules within a boundary may depend on each other.
This is more restrictive than `refute_calls/2` — it operates as an
allowlist rather than a denylist.
## Examples
# Monitor may only call IR, Violation, and Type modules.
assert_boundary(boundary,
modules: ~r/^Accord\\.Monitor/,
allow: [~r/^Accord\\.IR/, ~r/^Accord\\.Violation/, ~r/^Accord\\.Type/]
)
"""
@spec assert_boundary(Graph.t(), keyword()) :: :ok
def assert_boundary(%Graph{} = graph, opts) do
module_pattern = Keyword.fetch!(opts, :modules)
allowed = Keyword.fetch!(opts, :allow)
boundary_modules = Graph.matching(graph, module_pattern)
violations =
for module <- boundary_modules,
{^module, dep} <- graph.calls,
not Graph.matches?(dep, module_pattern),
not Graph.matches?(dep, allowed),
do: {module, dep}
if violations != [] do
message =
[
"Boundary violation: modules matching #{format_pattern(module_pattern)} have disallowed dependencies.",
"",
" Allowed: #{format_pattern(allowed)}",
"",
" #{pluralize(violations, "Violation", "Violations")}:",
format_call_list(violations)
]
|> Enum.join("\n")
flunk(message)
end
:ok
end
@doc """
Asserts that modules matching `modules` are only called by modules matching
the `allow` patterns.
This is the inverse of `assert_boundary/2` — it constrains **incoming**
callers rather than outgoing dependencies. Calls from within the boundary
are always permitted.
## Examples
# TLA passes are only called by TLA.Compiler.
assert_encapsulated(boundary,
modules: under(Accord.Pass.TLA),
allow: [Accord.TLA.Compiler]
)
"""
@spec assert_encapsulated(Graph.t(), keyword()) :: :ok
def assert_encapsulated(%Graph{} = graph, opts) do
module_pattern = Keyword.fetch!(opts, :modules)
allowed = Keyword.fetch!(opts, :allow)
violations =
for {caller, callee} <- graph.calls,
Graph.matches?(callee, module_pattern),
not Graph.matches?(caller, module_pattern),
not Graph.matches?(caller, allowed),
do: {caller, callee}
if violations != [] do
message =
[
"Encapsulation violation: modules matching #{format_pattern(module_pattern)} have disallowed callers.",
"",
" Allowed callers: #{format_pattern(allowed)}",
"",
" #{pluralize(violations, "Violation", "Violations")}:",
format_call_list(violations)
]
|> Enum.join("\n")
flunk(message)
end
:ok
end
# -- Private ---------------------------------------------------------------
defp find_matching_calls(graph, from, to) do
Enum.filter(graph.calls, fn {caller, callee} ->
Graph.matches?(caller, from) and Graph.matches?(callee, to)
end)
end
defp format_violation_message(from, to, violations) do
count = length(violations)
[
"Boundary violation: found #{count} disallowed #{pluralize(violations, "call", "calls")}.",
"",
" from: #{format_pattern(from)}",
" to: #{format_pattern(to)}",
"",
" #{pluralize(violations, "Violation", "Violations")}:",
format_call_list(violations)
]
|> Enum.join("\n")
end
defp format_call_list(calls) do
Enum.map_join(calls, "\n", fn {caller, callee} ->
" #{inspect(caller)} -> #{inspect(callee)}"
end)
end
defp format_pattern(%Regex{} = regex), do: "~r/#{regex.source}/"
defp format_pattern({:under, module}) when is_atom(module), do: "under(#{inspect(module)})"
defp format_pattern(module) when is_atom(module), do: inspect(module)
defp format_pattern(list) when is_list(list), do: format_pattern_list(list)
defp format_pattern_list(patterns) do
Enum.map_join(patterns, ", ", &format_pattern/1)
end
defp format_module_list([]), do: "(none)"
defp format_module_list(modules) do
Enum.map_join(modules, ", ", &inspect/1)
end
defp pluralize([_], singular, _plural), do: singular
defp pluralize(_list, _singular, plural), do: plural
end