# Override config in async tests

`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](https://elixirdrops.net/d/qcreJkWo) 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.

```elixir
# ❌ 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:

```elixir
# 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](https://hexdocs.pm/elixir/Process.html#get/2)


---

Created by: almirsarajcic
Date: April 22, 2026
URL: https://elixirdrops.net/d/6yRZohAb
