Build your own overridable value on top of Ambient.ProcessOverride.
An ambient value is one resolved implicitly from the surrounding context
rather than threaded through arguments. Ambient.Config and Ambient.Clock
are both built on this module – nothing about them is privileged. If your app
reads a value from the runtime rather than receiving it as an argument, this
is the supported way to make it as testable as they are.
defmodule MyApp.Flags do
use Ambient.Value, table: :my_app_flag_overrides
@doc "Is `flag` on for `actor`? In production, the real flag lookup."
def enabled?(flag, actor \ nil) do
get_or({:flag, flag}, FunWithFlags.enabled?(flag, for: actor))
end
@doc "Pin it for this test and everything it spawns."
def enable(flag), do: put_override({:flag, flag}, true)
def disable(flag), do: put_override({:flag, flag}, false)
endNote what stays a parameter: actor is passed in. The flag lookup is ambient
because nothing threads it; who it is evaluated for is an argument.
The fallback must be the real production implementation. In a build without overrides the lookup isn't compiled, so the fallback is all that is left. If it is a stub or a constant, production uses that stub forever and what you have is a test-only global with an app-shaped API.
Don't make authorization ambient. The current tenant and the acting user decide what a request may see, so an override that outlives its test is a data-exposure bug rather than a wrong timestamp. Thread those explicitly.
Register it once in test/test_helper.exs, exactly like a built-in:
Ambient.start_servers([Ambient.Clock, MyApp.Flags])What you get
use Ambient.Value, table: :some_table defines:
get_or/2(imported macro) – the read. Returns the override if one is in scope, otherwise evaluates the fallback expression.@ambient_enabled– whether this build compiled the machinery in, for values that need to drop a branch of their own.put_override/2,delete_override/1,delete_all/0– the writers.overridden?/1– whether an override is in scope for a key.allow/2– grant a process outside the$callerschain access.set_shared/1,set_private/0– shared mode forasync: falsetests.__ambient_table__/0– soAmbient.start_servers/1accepts the module.
All of them are defoverridable. allow/2 and set_shared/1 take a
defaulted second/first argument, so allow/1 and set_shared/0 are exported
too.
A module holding a single value conventionally uses one sentinel key
(:clock); one holding many (like Ambient.Config or the flags above) keys
by name.
get_or/2 is a macro, on purpose
It expands at compile time, so in a build that didn't opt into overrides
(see Ambient.ProcessOverride) the whole lookup disappears and only the
fallback expression remains:
def enabled?(flag, actor), do: get_or({:flag, flag}, FunWithFlags.enabled?(flag, for: actor))
# in a production build, compiles to exactly:
def enabled?(flag, actor), do: FunWithFlags.enabled?(flag, for: actor)That is what keeps a wrapper free to use everywhere in production code. The
fallback is only evaluated when there is no override, so
get_or(:key, expensive_call()) doesn't pay for the call it doesn't need.
Summary
Functions
Read the override for key, falling back to fallback when there is none.
Functions
Read the override for key, falling back to fallback when there is none.
Imported by use Ambient.Value, and resolved against that module's table.
A macro rather than a function: in a build without overrides compiled in it
expands to fallback alone, so the wrapper costs nothing in production.
def utc_now, do: get_or(:clock, DateTime.utc_now())