Ambient (Ambient v0.2.0)

Copy Markdown View Source

Ambient – process-scoped value overrides for Elixir, with built-ins.

An ambient value is one resolved implicitly from the surrounding context rather than threaded through arguments. Ambient lets a test set such a value (a config entry, the current time) scoped to its process and everything that process spawns, with zero leakage across concurrent async: true tests and automatic cleanup on exit.

Built-in values

  • Ambient.Config – an app-config accessor with a per-process override layer
  • Ambient.Clock – an overridable wall clock (freeze / travel time)

Both sit on Ambient.ProcessOverride, the shared engine (ETS + $callers inheritance + an Ecto-Sandbox-style allow/3), and are assembled with Ambient.Value – which you can use to build overridable values of your own.

Reaching other processes

Task children inherit through $callers with no setup – Agent and GenServer do not set it, so they need allow/2. For a long-lived process the test didn't spawn, grant it explicitly with the module's allow/2. When you can't reach the process at all, set_shared/2 makes one process's overrides global for the duration of an async: false test.

Setup

Opt the test build into the override machinery – it is off by default:

# config/config.exs
config :ambient, enable_overrides: config_env() != :prod

Then start one override server per table before the suite runs, in test/test_helper.exs:

Ambient.start_servers([MyApp.Config, Ambient.Clock])
ExUnit.start()

In production the flag is false and the override branches aren't compiled at all: each wrapper is the function it wraps – Application.get_env/3, DateTime.utc_now/0 – with no lookup and no branch. See Ambient.ProcessOverride for what that guarantees.

Summary

Types

One value module or a list of them: a module that uses Ambient.Value (including a use Ambient.Facade wrapper of one), or a raw table atom.

Functions

Report whether a value module's table is process-scoped or globally shared.

Return the given modules to private (process-scoped) mode. Idempotent, and safe to call on tables that were never shared. Callable from any process – on_exit/1 runs in its own.

Switch the given tables to shared mode, with owner_pid as the process whose overrides everyone reads.

Start one override Server per given table.

Types

values()

@type values() :: module() | atom() | [module() | atom()]

One value module or a list of them: a module that uses Ambient.Value (including a use Ambient.Facade wrapper of one), or a raw table atom.

Functions

mode(value)

@spec mode(module() | atom()) :: :private | {:shared, pid()}

Report whether a value module's table is process-scoped or globally shared.

Takes a value module or facade, like set_shared/2 and set_private/1Ambient.ProcessOverride.mode/1 is the same query against a raw table atom. Returns :private for a table that was never started, so it is safe to call anywhere.

Ambient.mode(MyApp.Clock)
#=> :private

Ambient.set_shared(MyApp.Clock)
Ambient.mode(MyApp.Clock)
#=> {:shared, #PID<0.123.0>}

set_private(values)

@spec set_private(values()) :: :ok

Return the given modules to private (process-scoped) mode. Idempotent, and safe to call on tables that were never shared. Callable from any process – on_exit/1 runs in its own.

set_shared(values, owner_pid \\ self())

@spec set_shared(values(), pid()) :: :ok

Switch the given tables to shared mode, with owner_pid as the process whose overrides everyone reads.

Accepts the same modules as start_servers/1, singly or as a list.

test "the whole system sees the frozen clock" do
  Ambient.Clock.set(~U[2026-01-01 09:00:00Z])
  Ambient.set_shared(Ambient.Clock)
  on_exit(fn -> Ambient.set_private(Ambient.Clock) end)
  # … any process, however spawned, now reads that clock
end

This is global state, so use it only in async: false tests – the same rule as Ecto.Adapters.SQL.Sandbox's shared mode and Mox.set_mox_global/0. The owner is monitored, so a crashed test reverts the table on its own.

See Ambient.ProcessOverride.set_shared/2 for the per-table details.

start_servers(values)

@spec start_servers(values()) :: :ok

Start one override Server per given table.

Accepts one value module or a list of them: Ambient.Clock, Ambient.Clock, a module that uses Ambient.Config or Ambient.Value, a use Ambient.Facade wrapper of one – anything exporting __ambient_table__/0 – or raw table atoms. Idempotent: a table whose server is already running is skipped.

Call once from test/test_helper.exs before ExUnit.start/0.

Raises unless the build opted into overrides (config :ambient, enable_overrides: config_env() != :prod) – see Ambient.ProcessOverride. Failing here means a consumer who forgot the config line fails loudly at suite boot instead of watching every override silently fall through to real values.