Overridable OS environment variables.
In production get/2 is System.get_env/2. In tests, put/2 sets a value
for the calling process and everything it spawns – without touching the
actual environment.
Why not System.put_env/2?
It 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; on_exit cleanup is easy to forget and easy to get wrong when a test
fails midway. Ambient.Env.put/2 is process-scoped, inherited through the
$callers chain, and cleared automatically when the process exits – and the
real OS environment is never touched, so a concurrent test reading the same
variable still sees the real value.
This is the same argument Ambient.Config makes against
Application.put_env/3, applied one layer down.
Production code
Ambient.Env.get("DATABASE_URL")
Ambient.Env.get("PORT", "4000")
Ambient.Env.fetch!("SECRET_KEY_BASE")Read variables through these rather than System.get_env/1 anywhere the
value should be testable. Ambient.Credo.NoDirectEnv can enforce it.
Runtime reads only
An override can only affect a read that happens while it is in scope.
Config resolved at boot (config/runtime.exs) or at compile time has
already been read, so overriding the variable later changes nothing. Wrap
the read in a function your code calls when it needs the value.
Test usage
Ambient.Env.put("FEATURE_X", "true")
Ambient.Env.put_all(%{"REGION" => "eu-west-1", "TIER" => "premium"})
Ambient.Env.unset("HOME") # override it as absent, though it is set
Ambient.Env.revert("HOME") # drop the override, see the real value
Ambient.Env.reset() # drop every override this process set
# for a long-lived process that reads env in its own process:
Ambient.Env.allow(genserver_pid)Register the table once in test/test_helper.exs:
Ambient.start_servers([Ambient.Env])
Summary
Functions
Authorise child_pid to read owner_pid's overrides. For long-lived
processes that don't appear in the $callers chain.
Drop every override this process owns in this module's table.
Drop this process's override for key. No-op if there isn't one.
{:ok, value} if var is set (or overridden), :error otherwise. Mirrors
System.fetch_env/1.
The value of var, raising if it is unset. Mirrors System.fetch_env!/1,
including the exception type.
The value of environment variable var, or default when it is unset.
Whether an override for key is in scope for the calling process.
Override var for this process and everything it spawns. The real
environment is untouched.
Override several variables at once. Takes anything enumerating
{name, value} pairs of binaries – a map or a list. Not a keyword list:
variable names are strings, not atoms.
Set a process-local override for key. Auto-cleaned when the process exits.
Drop every override this process set. Like revert/1, an inherited override
from an ancestor still applies afterwards.
Drop this process's override for var, so get/2 resolves normally again.
Return this module's table to private, process-scoped mode.
Make owner_pid's overrides the ones every process reads. async: false
only – see Ambient.ProcessOverride.set_shared/2.
Functions
Authorise child_pid to read owner_pid's overrides. For long-lived
processes that don't appear in the $callers chain.
@spec delete_all() :: :ok
Drop every override this process owns in this module's table.
@spec delete_override(term()) :: :ok
Drop this process's override for key. No-op if there isn't one.
{:ok, value} if var is set (or overridden), :error otherwise. Mirrors
System.fetch_env/1.
The value of var, raising if it is unset. Mirrors System.fetch_env!/1,
including the exception type.
The value of environment variable var, or default when it is unset.
Delegates to System.get_env/2 unless overridden – and in a build without
overrides compiled in, is System.get_env/2.
Whether an override for key is in scope for the calling process.
Override var for this process and everything it spawns. The real
environment is untouched.
@spec put_all(Enumerable.t()) :: :ok
Override several variables at once. Takes anything enumerating
{name, value} pairs of binaries – a map or a list. Not a keyword list:
variable names are strings, not atoms.
Set a process-local override for key. Auto-cleaned when the process exits.
@spec reset() :: :ok
Drop every override this process set. Like revert/1, an inherited override
from an ancestor still applies afterwards.
@spec revert(String.t()) :: :ok
Drop this process's override for var, so get/2 resolves normally again.
"Normally" means the usual chain: if an ancestor still has an override for
var, that one applies; only when nothing is left does the real environment
show through.
@spec set_private() :: :ok
Return this module's table to private, process-scoped mode.
@spec unset(String.t()) :: :ok
Override var as unset for this process, whatever the real environment
says. get/2 then returns its default and fetch/1 returns :error.
This is how you test the "variable absent" path for something that really is
set. Note it writes an override; to drop one and fall back to the real
value, use revert/1.