Override config in async tests

almirsarajcic

almirsarajcic

2 hours ago

0 comments

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 to Application.get_env
  • nil → 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.

Process.get/2 docs

Comments (0)

Sign in with GitHub to join the discussion