We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Override config in async tests
almirsarajcic
Application.put_env/3 in tests mutates global state. Two concurrent tests overwriting the same key will race — the restore in on_exit runs after the other test already read the wrong value. The usual fix is async: false, which serializes every test in the file to fix a problem that only affects a handful of them.
This is the upgrade to the Temporarily changing app config in tests drop. There’s a better way. Read the value from the process dictionary first, fall back to Application.get_env/2. Each test process gets its own override with no teardown required — ExUnit discards the process dictionary automatically when the test exits.
# ❌ Global mutation — concurrent tests race on the same key
setup do
original = Application.get_env(:my_app, :admin_phone)
Application.put_env(:my_app, :admin_phone, "15559990000")
on_exit(fn -> Application.put_env(:my_app, :admin_phone, original) end)
:ok
end
# ✅ Process-local — no setup, no teardown, no on_exit needed
test "sends alert to admin" do
Process.put(:admin_phone, "15559990000")
assert {:ok, _} = Notifier.send_alert("Server down")
end
The production code needs a small change — check the process dictionary before falling back to the application environment:
# config/test.exs — explicit nil so Application.get_env doesn't raise
config :my_app, :admin_phone, nil
# lib/my_app/notifier.ex
defp admin_phone do
case Process.get(:admin_phone, :__unset__) do
:__unset__ -> Application.get_env(:my_app, :admin_phone)
value -> value
end
end
The :__unset__ sentinel is important. Process.get(:admin_phone) returns nil when the key is missing — but nil is also a valid value when the phone number isn’t configured. Without the sentinel you can’t distinguish “never set” from “explicitly set to nil”. With it:
:__unset__→ key was never touched, fall through toApplication.get_envnil→ test explicitly set nil, treat as unconfigured- a string → use it
The whole file stays async: true. Each test overrides exactly the value it needs, isolated to its own process, with zero cleanup code.
One limitation: the process dictionary is not inherited across process boundaries. If the value is consumed inside a Task.Supervisor.start_child-spawned task, a long-running GenServer, or a LiveView process, Process.get/1 in that process returns the default. For those cases, see the companion drop on using ProcessTree for LiveView config overrides in async tests.
copied to clipboard