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 layerAmbient.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() != :prodThen 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
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.
Takes a value module or facade, like set_shared/2 and set_private/1 –
Ambient.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>}
@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.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.