README.md

# assert_boundary

[![CI](https://github.com/QuinnWilton/assert_boundary/actions/workflows/ci.yml/badge.svg)](https://github.com/QuinnWilton/assert_boundary/actions/workflows/ci.yml)
[![Hex.pm](https://img.shields.io/hexpm/v/assert_boundary.svg)](https://hex.pm/packages/assert_boundary)
[![Docs](https://img.shields.io/badge/docs-hexdocs-blue.svg)](https://hexdocs.pm/assert_boundary)

ExUnit assertion helpers for testing module dependency boundaries.

Uses Erlang's `:xref` to analyze BEAM bytecode and verify that module
dependencies respect architectural boundaries. Encode your architecture as
tests — they fail the moment someone introduces a forbidden dependency.

## Installation

```elixir
def deps do
  [
    {:assert_boundary, "~> 0.1.0", only: :test}
  ]
end
```

## Usage

```elixir
defmodule MyApp.BoundaryTest do
  use ExUnit.Case, async: true
  use AssertBoundary, app: :my_app

  test "web layer doesn't touch repo internals", %{boundary: boundary} do
    refute_calls(boundary, from: under(MyApp.Web), to: under(MyApp.Repo.Internal))
  end

  test "domain has restricted dependencies", %{boundary: boundary} do
    assert_boundary(boundary,
      modules: under(MyApp.Domain),
      allow: [under(MyApp.Schema), under(MyApp.Types)]
    )
  end

  test "controllers depend on domain", %{boundary: boundary} do
    assert_calls(boundary, from: under(MyApp.Web.Controller), to: under(MyApp.Domain))
  end

  test "repo internals are encapsulated", %{boundary: boundary} do
    assert_encapsulated(boundary,
      modules: under(MyApp.Repo.Internal),
      allow: [under(MyApp.Repo)]
    )
  end
end
```

## API

### `refute_calls(graph, from: pattern, to: pattern)`

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.

### `assert_calls(graph, from: pattern, to: pattern)`

Asserts that at least one module matching `from` calls a module matching `to`.
Use this to verify expected dependencies exist.

### `assert_boundary(graph, modules: pattern, allow: [pattern])`

Asserts that modules matching `modules` only depend on modules matching the
`allow` patterns. Dependencies within the boundary are always permitted.
This is an allowlist — any dependency not explicitly allowed is a violation.

### `assert_encapsulated(graph, modules: pattern, allow: [pattern])`

Asserts that modules matching `modules` are only called by modules matching
the `allow` patterns. Calls from within the boundary are always permitted.
This is the inverse of `assert_boundary` — it constrains incoming callers
rather than outgoing dependencies.

### Patterns

All assertion functions accept patterns that match module names (without
the `Elixir.` prefix):

- **Regex**: `~r/^MyApp\.Domain/`
- **Module atom**: `MyApp.Domain.User`
- **Prefix**: `under(MyApp.Domain)` — matches the module and all children
- **List**: `[~r/^MyApp\.Domain/, MyApp.Shared.Types]`

### Graph introspection

```elixir
graph = AssertBoundary.graph(:my_app)

# List all modules matching a pattern.
AssertBoundary.Graph.matching(graph, ~r/^MyApp\.Web/)

# List direct dependencies of a specific module.
AssertBoundary.Graph.dependencies_of(graph, MyApp.Web.Router)
```

## How it works

AssertBoundary analyzes compiled BEAM files using Erlang's `:xref` module.
The dependency graph reflects actual call dependencies from bytecode — not
source-level references like `alias` or `import`. Only calls between modules
within the same application are tracked; calls to external libraries are
excluded.

The graph is built once per test module via `setup_all` and passed through
ExUnit's test context as `%{boundary: graph}`.

## License

MIT