Ambient (Ambient v0.1.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 (the current time, a random seed, a config entry) 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

All four 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/Agent children inherit through $callers with no setup. 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([Ambient.Clock, Ambient.Random, Ambient.Env, MyApp.Config])
ExUnit.start()

In production the flag is false and the override branches aren't compiled at all: each wrapper is the function it wraps – DateTime.utc_now/0, :rand.uniform/1, :crypto.strong_rand_bytes/1, System.get_env/2, Application.get_env/3 – 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

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

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.Random, 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.