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
Ambient.Clock– an overridable wall clock (freeze / travel time)Ambient.Random– a seedable, replayable random number generatorAmbient.Env– overridable OS environment variablesAmbient.Config– an app-config accessor with a per-process override layer
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() != :prodThen 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
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
@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.
@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.