Process-scoped value overrides for Elixir – a frozen clock, a seeded random number generator, overridable env vars and a per-test config layer that survive across process boundaries, never leak between async: true tests, and cost nothing in production.

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, DATABASE_URL, a config entry – scoped to its process and everything that process spawns, with automatic cleanup on exit.

test "a token expires after 24 hours" do
  Ambient.Clock.set(~U[2026-01-01 09:00:00Z])
  token = Tokens.issue(user)          # any process it spawns sees this clock

  Ambient.Clock.advance(hours: 23)
  assert Tokens.valid?(token)

  Ambient.Clock.advance(hours: 2)
  refute Tokens.valid?(token)
end

Why

Overriding a global for one test without disturbing its neighbours is surprisingly hard:

ApproachPer-test isolationSurvives a spawned processAuto-cleanup
Application.put_env/3❌ global – breaks async: true
System.put_env/2❌ global – breaks async: true
Process.put/2❌ lost across the boundary✅ (dies with the pid)
Ambient✅ ($callers + allow/3)✅ (monitored)

Ambient gives you Process.put-style isolation plus cross-process inheritance (the same $callers mechanism Ecto's SQL Sandbox uses, plus an explicit allow/3 for long-lived processes), with values cleaned up automatically when the owning test process exits.

And in production the override machinery isn't compiled in at all – see Production cost.

Install

def deps do
  [{:ambient, "~> 0.1"}]
end

Not a test-only dependency: you read through Ambient.Clock and MyApp.Config from application code. The override machinery is what's absent from production, not the library – see the compile-time switch.

Opt into the override machinery, in config/config.exs:

config :ambient, enable_overrides: config_env() != :prod

It defaults to false, so a build that doesn't set it – your release – has no override machinery compiled in at all. See the compile-time switch.

Gate on != :prod rather than == :test: :prod is the only env where the guarantee matters, and Dialyzer runs in :dev – see Dialyzer.

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()

Forgetting the config line fails loudly at Ambient.start_servers/1; forgetting a table surfaces at the first write to it, as :server_not_started.

A use Ambient.Facade wrapper can be registered in place of the module it wraps – it passes __ambient_table__/0 through.

Built-in values

Four ready-made ambient values. Each is a thin wrapper you call from application code instead of the global it replaces, plus test-only setters that are scoped to the calling process and everything it spawns.

Config

The one most apps reach for first. Application.get_env/3 is how Elixir apps read their settings, and Application.put_env/3 is how tests usually override them – except that put_env mutates global state, so two async: true tests touching the same key clobber each other, and a value left behind leaks into every later test in the run.

Bind an accessor to your OTP app once:

defmodule MyApp.Config do
  use Ambient.Config, otp_app: :my_app
end

Then read through it everywhere you would have called Application.get_env/3:

# lib/my_app/billing.ex
def retry_limit, do: MyApp.Config.get(:retry_limit, 3)
def dunning_enabled?, do: MyApp.Config.get(:dunning_enabled, false)

get/2 checks for a process-local override first and falls back to Application.get_env(:my_app, key, default). In a production build the lookup isn't compiled in at all – get/2 is Application.get_env/3 (see Production cost).

In tests, override per process. No setup/on_exit pairing, no restoring the old value, no async: false:

use ExUnit.Case, async: true

test "gives up after the configured number of retries" do
  MyApp.Config.put(:retry_limit, 1)

  assert {:error, :exhausted} = Billing.charge(invoice)
end

test "a concurrent test is unaffected" do
  assert MyApp.Config.get(:retry_limit, 3) == 3
end

The override dies with the test process, so the second test sees the real value even while the first is still running.

It reaches spawned work too – a Task your code starts inherits it through $callers with no setup:

test "the background charge sees the override" do
  MyApp.Config.put(:retry_limit, 1)

  assert Task.async(fn -> Billing.charge(invoice) end) |> Task.await() ==
           {:error, :exhausted}
end

For a long-lived process the test didn't spawn – a named GenServer, an Oban worker – grant it access explicitly (see Reaching a process you didn't spawn):

MyApp.Config.allow(genserver_pid)

The full surface:

MyApp.Config.get(:key, default)     # read: override, else Application.get_env/3
MyApp.Config.put(:key, value)       # override for this process and its children
MyApp.Config.revert(:key)           # drop one override, back to app env
MyApp.Config.reset()                # drop every override this process set
MyApp.Config.overridden?(:key)      # is one in scope?
MyApp.Config.allow(pid)             # let another process read this one's overrides

Bind as many accessors as you have apps – each otp_app gets its own table, so an umbrella's apps never collide.

Clock

Time is the other classic untestable global. Read it through Ambient.Clock and a test can freeze it, or travel, without Process.sleep/1 or hand-rolled date arithmetic:

# app code – instead of DateTime.utc_now/0, Date.utc_today/0, …
Ambient.Clock.utc_now()
Ambient.Clock.utc_today()
Ambient.Clock.naive_utc_now()
Ambient.Clock.now("Europe/Warsaw")   # needs a configured time zone database

# tests
Ambient.Clock.set(~U[2026-01-01 09:00:00Z])
Ambient.Clock.advance(days: 1)     # also :hours, :minutes, :seconds, or a bare int
Ambient.Clock.advance(hours: 1, minutes: 30)   # units are summed
Ambient.Clock.advance(-90)         # backwards, in seconds
Ambient.Clock.reset()
test "a token expires after 24 hours" do
  Ambient.Clock.set(~U[2026-01-01 09:00:00Z])
  token = Tokens.issue(user)

  Ambient.Clock.advance(hours: 23)
  assert Tokens.valid?(token)

  Ambient.Clock.advance(hours: 2)
  refute Tokens.valid?(token)
end

set/1 and advance/1 return the new time, so they compose into assertions.

Random

# app code
Ambient.Random.uniform(100)        # 1..100
Ambient.Random.uniform()           # float in [0.0, 1.0)
Ambient.Random.normal(0.0, 1.0)
Ambient.Random.shuffle(cards)
Ambient.Random.take_random(deck, 5)
Ambient.Random.bytes(32)

# tests – same seed replays the same stream every run
Ambient.Random.seed(42)
first = Ambient.Random.uniform(100)
Ambient.Random.seed(42)
assert Ambient.Random.uniform(100) == first

Repeated calls in one process advance the stream, so four shuffle/1 calls give four different permutations – the same four every run.

Across processes it's a fork, not one shared stream: a child inherits the owner's state as of the seed and advances its own copy. Sequential work is unsurprising (a child that draws after the parent continues where the parent left off), but concurrent siblings each get the same values:

Ambient.Random.seed(42)
1..4 |> Enum.map(fn _ -> Task.async(fn -> Ambient.Random.bytes(4) end) end)
     |> Task.await_many()
#=> four identical tokens

That is deliberate, and it's the only way to be reproducible: which sibling gets which value from a single stream depends on scheduling, so distinct and reproducible cannot both hold across concurrent processes. Pick one:

You wantUseCost
Reproducible run to runseed/1 (default)concurrent siblings share values
Distinct values everywhereseed/1 + shared modeordering isn't reproducible; async: false
Distinct and reproducible per workerseed/1 inside each worker, with its own seedyou choose the seeds

Shared mode routes every draw through one atomically-advanced stream, so concurrent callers never collide.

Cryptography

Seedable and unpredictable are opposites, so the rule is per-function, not per-module:

No seed in scopeUnder seed/1
bytes/1:crypto.strong_rand_bytes/1credential-safedeterministic, not safe
uniform, normal, shuffle, random, take_random:rand (exsss) – never credential-gradedeterministic
# fine – strong in production, replayable in tests
Ambient.Random.bytes(32) |> Base.url_encode64(padding: false)

# never – a token is not a dice roll
Enum.map(1..32, fn _ -> Ambient.Random.uniform(256) end)

The right-hand column can't happen in production, because the compile-time switch doesn't compile the seeded clause at all – there is no code path from a seed to bytes/1 to reach, no matter what a $callers chain, an allow/3 grant, a seeds script or a remote console does. That holds only if you derive the flag from config_env/0; a build that hard-codes it on has ordinary seeded bytes, and warns at compile time when Ambient can tell it's a prod build.

Env

System.put_env/2 mutates the whole VM: two async: true tests setting the same variable clobber each other, and a leaked value survives into every later test in the run.

# app code – instead of System.get_env/1
Ambient.Env.get("DATABASE_URL")
Ambient.Env.get("PORT", "4000")
Ambient.Env.fetch!("SECRET_KEY_BASE")

# tests
Ambient.Env.put("FEATURE_X", "true")
Ambient.Env.put_all(%{"REGION" => "eu-west-1", "TIER" => "premium"})
Ambient.Env.unset("HOME")      # override it as absent, even though it really is set
Ambient.Env.revert("HOME")     # drop the override, see the real value again
Ambient.Env.reset()            # drop every override this process set

unset/1 and revert/1 are deliberately different: unset/1 writes an override meaning "absent", which is how you test that path for a variable that really is set; revert/1 removes an override.

Runtime reads only. An override can only affect a read that happens while it is in scope. Anything resolved at boot (config/runtime.exs) or at compile time has already been read. Wrap the read in a function your code calls when it needs the value.

Build your own

Nothing about the built-ins is privileged – they're all use Ambient.Value. If your app has an ambient value of its own (the current tenant, the acting user, a request id), this is the supported way to make it as testable:

defmodule MyApp.Tenant do
  use Ambient.Value, table: :my_app_tenant_overrides

  def current, do: get_or(:tenant, MyApp.Tenant.Default)
  def put(tenant), do: put_override(:tenant, tenant)
end

Ambient.start_servers([MyApp.Tenant])   # in test_helper.exs, like a built-in

A module wrapping a specific domain usually adds its own verbs on top – a generated config's put/2, Clock.set/1, Random.seed/1, Env.put/2 – and leaves the generic layer below for everything else.

use Ambient.Value generates put_override/2, delete_override/1, delete_all/0, overridden?/1, allow/2, set_shared/1, set_private/0 and __ambient_table__/0 – all defoverridable – and imports the get_or/2 macro. If your reads also write (as Ambient.Random does, advancing its seed), write back with ProcessOverride.put_resolved/3 so it keeps working under shared mode.

get_or/2 is a macro on purpose: it expands at compile time, so in a build without overrides the lookup disappears entirely and only the fallback expression remains. def current, do: get_or(:tenant, MyApp.Tenant.Default) compiles to def current, do: MyApp.Tenant.Default. The fallback is evaluated only on a miss, so get_or(:key, expensive_call()) doesn't pay for a call it doesn't need.

Re-exporting under your own namespace

Prefer to call MyApp.Clock instead of Ambient.Clock across your app? Wrap a value module with Ambient.Facade – the delegates are derived at compile time, so the wrapper never drifts when the wrapped module gains a function:

defmodule MyApp.Clock do
  use Ambient.Facade, for: Ambient.Clock
end

MyApp.Clock.set(~U[2026-01-01 00:00:00Z])   # delegates to Ambient.Clock

Narrow the surface with :only / :except (names or {name, arity} pairs):

use Ambient.Facade, for: Ambient.Clock, only: [:utc_now, :utc_today]
use Ambient.Facade, for: Ambient.Random, except: [:seed]

Reaching a process you didn't spawn

Plain Task.async/Agent children inherit automatically via $callers. For a long-lived process the test didn't spawn (a named GenServer, an Oban worker), authorise it explicitly – the same pattern as Ecto.Adapters.SQL.Sandbox.allow/3:

   test process  put/set/seed  ETS override
        
         Task.async child  inherits via $callers   (no setup)
        
         Ambient.Clock.allow(worker_pid)
                     
              worker_pid  inherits via allow chain
Ambient.Clock.allow(worker_pid)
Ambient.Random.allow(worker_pid)
Ambient.Env.allow(worker_pid)
MyApp.Config.allow(genserver_pid)

Grants chain: if A allows B and B allows C, C resolves through to A. Cycles terminate rather than spinning.

Shared mode (for async: false tests)

Sometimes allow/3 is the wrong tool: the process you need to reach is deep inside a supervision tree, or you don't have its pid at all. Ambient.set_shared/2 makes one process's overrides the ones every process reads:

use ExUnit.Case, async: false   # required – this is global state

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

Ambient.start_servers/1, set_shared/2 and set_private/1 all take one value module or a list of them.

Same rule as Ecto.Adapters.SQL.Sandbox's shared mode and Mox.set_mox_global/0: never in an async: true test, or a concurrent test will see your clock.

While a table is shared:

  • only the shared owner may write – put/3 and set_shared/2 from anyone else raise {:not_shared_owner, pid}, so another test can't silently steal the table;
  • allow/3 is refused, since every process already reads the owner's values;
  • set_private/1 is deliberately open to any process – it's the way back to a sane state, and on_exit/1 runs in a different process from the test;
  • delete/2 and reset/0-style teardown stay scoped to the caller's own rows, so a non-owner's Clock.reset() doesn't (and can't) clear the shared value;
  • the owner is monitored, so a crashed test drops the table back to private on its own rather than leaving the suite globally overridden.

Ambient.ProcessOverride.mode/1 reports :private or {:shared, pid}.

Errors

Every Ambient misuse raises Ambient.Error with a machine-readable :reason (:overrides_disabled, :server_not_started, {:not_shared_owner, pid}, :cant_allow_in_shared_mode, :not_a_value_module, {:server_start_failed, reason}). Match on :reason, not on message text:

rescue
  e in Ambient.Error ->
    case e.reason do
      :server_not_started -> start_and_retry()
      _ -> reraise(e, __STACKTRACE__)
    end
end

Bad argument values (Ambient.Clock.advance(weeks: 1)) still raise ArgumentErrorAmbient.Error means Ambient is in the wrong state, not that you passed the wrong term.

The compile-time switch

Every override path is gated on one flag, resolved at compile time via Application.compile_env/3:

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

Default false. In a build that didn't opt in, no Ambient API can produce an override:

  • Ambient.start_servers/1, ProcessOverride.Server.start_link/1 and Server.init/1 all refuse to create the ETS table;
  • put/3, allow/3, set_shared/2 raise, as do the built-ins' writers (Clock.set/1, Random.seed/1, Env.put/2, put_override/2, …);
  • the built-ins' override branches aren't compiled at all – see Production cost;
  • reset/0-style teardown stays a safe no-op, so on_exit helpers don't need guarding.

What it deliberately does not do: ProcessOverride.fetch/2 keeps its ETS lookup in disabled builds, because a clause hard-wired to :error makes every caller's {:ok, _} branch provably dead and consumers compiling with --warnings-as-errors would fail on it. So a hand-rolled :ets.new(:ambient_clock_overrides, [:named_table, :public]) plus a row is visible to fetch/2, mode/1 and overridden?/1. It is not visible to the built-ins' actual reads – get_or/2 compiled those away, so Clock.utc_now/0 and a generated MyApp.Config.get/2 ignore it. Forging one needs arbitrary code execution inside the node anyway.

Three more things worth knowing:

  • Derive the flag from config_env/0. Compiling Ambient with it hard-coded on warns whenever Ambient can tell it's building for :prod – such a release carries the machinery and bytes/1 loses its guarantee. Ambient sees only MIX_ENV and the build directory, so a project with build_per_environment: false gets no warning; derive the flag and the question doesn't arise.
  • Mix records the value in the app manifest, so a release whose runtime config disagrees aborts at boot rather than drifting. (A mix run in :prod does not perform that check.)
  • It's resolved per _build env. Build releases with MIX_ENV=prod; a release built with MIX_ENV=test would carry the machinery.

Ambient.ProcessOverride.enabled?/0 reports the current build. Use it in a module body: its value is fixed when Ambient compiles, so a runtime branch on it is just dead code.

Dialyzer

Gate the flag on config_env() != :prod, not == :test.

In a build without overrides the writers raise, so their success typing is none() – and dialyxir runs in :dev by default, so gating on == :test points Dialyzer at exactly that build. Ambient's generated specs say no_return() there, which keeps the generated code itself clean, but a function of yours that wraps a writer still can't return:

def put(tenant), do: put_override(:tenant, tenant)
# warning: Function put/1 has no local return

That is Dialyzer being right. Measured on a small consuming app with one use Ambient.Config and one use Ambient.Value:

enable_overridesWarnings in the consumer's own modules
config_env() != :prod0
config_env() == :test1 – the wrapper above

So gate on != :prod and the question doesn't arise. If you'd rather keep == :test, move such wrappers behind if Ambient.ProcessOverride.enabled?(), which compiles them out of the build that can't run them anyway.

Production cost

None worth measuring. get_or/2 expands to the fallback expression alone, so each wrapper compiles down to the function it wraps:

WrapperProduction build compiles to
Ambient.Clock.utc_now/0DateTime.utc_now/0
Ambient.Random.bytes/1:crypto.strong_rand_bytes/1
Ambient.Random.uniform/1:rand.uniform/1
Ambient.Env.get/2System.get_env/2
MyApp.Config.get/2Application.get_env/3

No ETS lookup, no branch, no message round-trip. Ambient.ProcessOverride's own fetch/2 still costs one :ets.whereis/1, but the built-ins don't call it in a disabled build.

Enforcing the conventions (optional Credo checks)

Ambient ships four Credo checks that keep direct clock/RNG/env/config reads from sneaking back in. They're opt-in: credo is an optional dependency, so if you don't use Credo you pull nothing and the checks aren't even compiled. If you do, enable them in .credo.exs:

checks: %{
  extra: [
    {Ambient.Credo.NoDirectClock, []},
    {Ambient.Credo.NoDirectRandom, []},
    {Ambient.Credo.NoDirectEnv, exempt_suffixes: ["config/runtime.exs"]},
    {Ambient.Credo.NoDirectConfig, otp_app: :my_app, replacement: "MyApp.Config"}
  ]
}
  • NoDirectClock flags DateTime.utc_now/0 & friends; NoDirectRandom flags :rand.* / Enum.shuffle|random|take_random (but allows :crypto.strong_rand_bytes/1); NoDirectEnv flags System.get_env/* and System.put_env/*; NoDirectConfig flags runtime Application.get_env(:my_app, …).

  • All take replacement: (the wrapper name shown in the message) and exempt_suffixes: (paths to skip). So if you re-export under your own namespace, point them at it:

    {Ambient.Credo.NoDirectClock, replacement: "MyApp.Clock", exempt_suffixes: ["lib/my_app/clock.ex"]}

How it works

Each override domain is a named, public ETS table owned by an Ambient.ProcessOverride.Server. Writes are keyed {owner_pid, key}; reads resolve through self → allow chain → $callers, or straight to the shared owner when the table is shared. The server monitors every writer and clears its rows (and any allow rows pointing at it) on exit – so concurrent async: true tests never see each other's values and nothing leaks.

Reads are plain ETS lookups in the calling process: no GenServer call, no serialization point between concurrent tests.

How it compares

  • nimble_ownership (docs) solves the same ownership/allowance problem, and is what Mox is built on. It keeps state in a GenServer, so every read is a GenServer.call, and it offers shared mode, lazy allowances and manual cleanup. Ambient reads from public ETS instead, and adds what nimble_ownership deliberately doesn't: the built-ins, the Facade, the Credo checks, and the compile-time switch that makes wrappers free in production.
  • Mox (docs) overrides behaviour (what a module does). Ambient overrides values (what a function reads). They're complementary, and Ambient deliberately stays out of mocking.
  • Ecto.Adapters.SQL.Sandbox is the direct inspiration for allow/3 and shared mode.
  • Application.put_env/3 and System.put_env/2 are what Ambient.Config and Ambient.Env exist to replace in tests: both are VM-global, so neither is safe under async: true.

License

MIT © Mariusz Zak