Mock Application Config Environment. Gives each test its own isolated view of Application.get_env while preventing leaks and allowing async running of code that uses the global application config state.

How It Works

Mace sits between your code and Application.get_env. When your test calls Mace.put_config(:my_app, :timeout, 100), Mace registers that override for the test process. Any call to Application.get_env(:my_app, :timeout) from that process (or a linked child process) gets the override instead of the real config.

Your production code doesn't change. It still calls Application.get_env. Mace handles the interception transparently.

Install

def deps do
  [{:mace, "~> 0.1", only: [:test]}]
end

Setup

test/test_helper.exs:

Mace.Store.init()
ExUnit.start()

In any test module that needs Application.get_env interception, call Mace.Mock.install() once in setup_all:

defmodule MyModuleTest do
  use ExUnit.Case, async: true

  setup_all do
    Mace.Mock.install()
    :ok
  end
end

Basic Use

defmodule TimeoutTest do
  use ExUnit.Case, async: true

  setup_all do
    Mace.Mock.install()
    :ok
  end

  setup do
    Mace.put_config(:my_app, :timeout, 100)
    :ok
  end

  test "handles short timeouts" do
    # MyModule.do_thing() calls Application.get_env(:my_app, :timeout)
    # It sees 100, even though the real config says 5000
    assert MyModule.do_thing() == :ok
  end

  test "handles long timeouts" do
    Mace.put_config(:my_app, :timeout, 50_000)
    assert MyModule.do_thing() == :ok
  end
end

Both tests run with async: true. Each sees its own timeout value.

Debugging Failures

When a test fails, knowing the active config is half the battle. Config cleanup happens automatically when the test process exits, but if you want a diff on failure, use Mace.cleanup/1 in on_exit:

setup context do
  Mace.put_config(:my_app, :timeout, 100)
  on_exit(fn -> Mace.cleanup(context) end)
  :ok
end

Then call Mace.diff/1 in your failure output, or wire up the formatter to show diffs automatically when tests fail:

Test config diff for :my_app:

  :timeout:  5000 (default)    100 (test)
  :debug:    false (default)    true (test)

Expand and Contract

Config flags are a good way to evolve code safely — ship the new behavior behind a flag, test with it on, test with it off, remove the old code when you're confident. Mace makes this pattern straightforward to test.

Say you're replacing an HTTP client. The real config defaults to the old client:

# config/config.exs
config :my_app, :http_client, MyApp.LegacyClient

The module reads the config at runtime:

defmodule MyApp.HTTP do
  def client do
    Application.get_env(:my_app, :http_client)
  end
end

Expand

Add the new client module. In your test, set the config flag to the new client for one describe block and the old client for another:

describe "with legacy client" do
  setup do
    Mace.put_config(:my_app, :http_client, MyApp.LegacyClient)
  end

  test "makes requests" do
    # hits the old code path
  end
end

describe "with new client" do
  setup do
    Mace.put_config(:my_app, :http_client, MyApp.NewClient)
  end

  test "makes requests" do
    # hits the new code path, same tests
  end
end

You now have test coverage for both paths without changing any production config files. Ship the new client behind the flag. Run in production with the new client enabled for a subset of traffic. Once you're confident, delete the legacy module and the flag — the tests for the old path get removed, the ones for the new path stay.

What this Looks like in practice

Here's a file upload pipeline being migrated from local disk storage to S3:

describe "with local disk storage" do
  setup do
    Mace.put_config(:my_app, :storage_backend, MyApp.LocalStorage)
    Mace.put_config(:my_app, :storage_path, "test/fixtures/uploads")
  end

  test "stores and retrieves files" do
    assert MyApp.Upload.save(file) == :ok
    assert MyApp.Upload.fetch(file.id) == file
  end
end

describe "with S3 storage" do
  setup do
    Mace.put_config(:my_app, :storage_backend, MyApp.S3Storage)
    Mace.put_config(:my_app, :s3_bucket, "test-bucket")
  end

  test "stores and retrieves files" do
    assert MyApp.Upload.save(file) == :ok
    assert MyApp.Upload.fetch(file.id) == file
  end
end

Same test, two storage backends. No need to swap config files or mess with Application.put_env globally — each describe block gets its own config, and you can run them both with async: true.

Spawned Processes

Tasks and GenServers started with start_link automatically inherit the test's config via link-walking. Nothing to do:

test "task sees test config" do
  Mace.put_config(:my_app, :timeout, 100)

  task = Task.async(fn ->
    Application.get_env(:my_app, :timeout)  # => 100
  end)

  assert Task.await(task) == 100
end

If you're doing something exotic that doesn't create a link, use Mace.task/1 to explicitly transfer config to the child process.

A warning about libraries

Config is an application concern, not a library concern. Reading config from a library:

  • Creates hidden coupling between the host app's config files and the library's behavior
  • Makes it impossible for two dependencies of the same app to use the library with different configuration
  • Breaks when used from escripts or releases where the app isn't started

As such while Mace may be useful for libraries, please be careful with your design decisions.

Accept configuration as function arguments or module options instead:

# Bad: library reads config
defmodule MyLib do
  def timeout, do: Application.get_env(:my_lib, :timeout, 5000)
end

# Good: caller passes config
defmodule MyLib do
  def do_thing(opts \\ []) do
    timeout = Keyword.get(opts, :timeout, 5000)
  end
end

When Mace still helps

Sometimes configuration is the best option for a library, many time that indicates a harness that runs your application code or something that help compose other libraries.

defmodule MyLibTest do
  use ExUnit.Case, async: true

  setup_all do
    Mace.Mock.install()
    :ok
  end

  setup do
    Mace.put_config(:my_lib, timeout: 100, retries: 3)
    :ok
  end

  test "respects configured timeout" do
    assert MyLib.do_thing() == :ok
  end
end

When Application config is the right answer

Using Application config in a library is best avoided, but sometimes it is the right answer. Ecto repos, Phoenix endpoints, and Oban queues all read their configuration from the host app's Application environment. Each needs config to be set once globally rather than threaded through every call site, and each is central enough to its application that there's no ambiguity about which app owns the config keys.

API

Function
Mace.put_config(app, key, value)Set a config override for this test
Mace.put_config(app, keyword_list)Set multiple overrides at once
Mace.get_config(app, key)Read the active override (returns {:ok, v} or :error)
Mace.reset()Clear all overrides (escape hatch; automatic on test exit)
Mace.diff(app)Show diff of overrides vs application defaults
Mace.task(fn)Spawn a Task that inherits config
Mace.cleanup(context)Record diff + reset; call in on_exit for failure debugging
Mace.pid_config()Return full config map for this process

Inspiration

While working with Fivetro on some projects this expand contract need came up and it felt like a great library to share. Check them out if you need Elixir or freight software help!