Gestalt serves as a wrapper for Application.get_env/3 and System.get_env/1. It provides a mechanism for setting
process-specific overrides to application configuration and system variables, primarily to ease asynchronous testing of
behaviors dependent on specific values.
Gestalt should be used for getting runtime configuration, and not in places where configuration is compiled into modules.
Asynchronous Testing
Assuming your project sets configuration in config.exs, some elixir code will use Application.get_env/3 to access
its values.
use Mix.Config
config :my_project, :enable_new_feature, trueThis could be accessed at runtime using a config module:
defmodule Project.Config do
def enable_new_feature? do
Application.get_env(:my_project, :enable_new_feature)
end
endAnd tests could be written:
defmodule Project.ConfigTest do
use ExUnit.Case, async: true
alias Project.Config
describe "enable_new_feature?/0" do
test "when :enable_new_feature is true, it is true" do
:ok = Application.put_env(:my_project, :enable_new_feature, true)
assert Config.enable_new_feature?()
end
test "when :enable_new_feature is false, it is false" do
:ok = Application.put_env(:my_project, :enable_new_feature, false)
refute Config.enable_new_feature?()
end
end
endNow there is a problem. Because these tests are marked async: true, there will be times that they will run
concurrently. Since application env is global, Application.put_env/4 will effect everything else in the runtime. If
controller tests or acceptance tests assert on user-facing behavior related to the configuration, then the two tests
shown above may cause those to fail randomly and non-deterministically.
The same problem occurs with System.get_env/1. Code may behave differently in the presence of a specific environment
variable. For instance, optionally initializing a library depending on whether or not an authentication token is
present. System.put_env/2 can be used to update values for tests, leading to more non-deterministic test failures.
defmodule Project.Config do
#...
def enable_monitoring_lib? do
case monitoring_auth_token do
nil -> false
_ -> true
end
end
def monitoring_auth_token do
System.get_env("AUTH_TOKEN")
end
enddefmodule Project.ConfigTest do
#...
describe "enable_monitoring_lib?/0" do
test "when AUTH_TOKEN is present, it is true" do
:ok = System.put_env("AUTH_TOKEN", "abc123")
assert Config.enable_monitoring_lib?()
end
test "when AUTH_TOKEN is not present, it is false" do
:ok = System.delete_env("AUTH_TOKEN")
refute Config.enable_monitoring_lib?()
end
end
endOne solution would be to set async: false for all tests that depend upon configuration. Another would be to rewrite
the Config functions such that Application or System could be injected. The former would work for testing at the
acceptance level. The second would not, without jumping through many more hoops of dependency injection.
Pid-specific Overrides
Gestalt solves this problem in a different fashion, by starting an Agent to store override values for specific pids.
Initialization of the agent can be done in test/test_helpers.exs, for instance:
{:ok, _agent} = Gestalt.start()Now the config module can be rewritten as follows:
defmodule Project.Config do
def enable_new_feature? do
Gestalt.get_config(:my_project, :enable_new_feature, self())
end
def enable_monitoring_lib? do
case monitoring_auth_token do
nil -> false
_ -> true
end
end
def monitoring_auth_token do
Gestalt.get_env("AUTH_TOKEN", self())
end
endBy default, when there is no agent running or when there are no overrides for the current pid, Gestalt.get_config/4
falls back to Application.get_env/3 and Gestalt.get_env/2 falls back to System.get_env/1. For purposes of clarity
and to remind us that Gestalt overrides are pid-specific, the pid arguments are not optional. Gestalt functions do take
an extra optional argument, which is the agent name.
Now our tests can be rewritten as follows to use Gestalt.replace_config/5 and Gestalt.replace_env/4:
defmodule Project.ConfigTest do
use ExUnit.Case, async: true
alias Project.Config
describe "enable_new_feature?/0" do
test "when :enable_new_feature is true, it is true" do
:ok = Gestalt.replace_config(:my_project, :enable_new_feature, true, self())
assert Config.enable_new_feature?()
end
test "when :enable_new_feature is false, it is false" do
:ok = Gestalt.replace_config(:my_project, :enable_new_feature, false, self())
refute Config.enable_new_feature?()
end
end
describe "enable_monitoring_lib?/0" do
test "when AUTH_TOKEN is present, it is true" do
:ok = Gestalt.replace_env("AUTH_TOKEN", "abc123", self())
assert Config.enable_monitoring_lib?()
end
test "when AUTH_TOKEN is not present, it is false" do
:ok = Gestalt.replace_env("AUTH_TOKEN", nil, self())
refute Config.enable_monitoring_lib?()
end
end
endNote that self() can be used in both the code and the tests, because the code is running in the same pid as the test.
In most cases, this will be safe. In some few cases, the code might be running in a separate process from the test, in
which case replace_config and replace_env should use the pid of the running code.
Runtime vs. Compile-time
Gestalt can be used to override values in the runtime. A common pattern in Elixir dependency injection is to use application config to set module variables. This happens at compile time, making it impossible for Gestalt to provide overrides.
Gestalt macros for testing
For cases where runtime configuration should only be overridden during testing, Gestalt provides macros that compile
to Application and System in non-test environments.
defmodule Project.Config do
use Gestalt
def enable_new_feature? do
gestalt_config(:my_project, :enable_new_feature, self())
end
def enable_monitoring_lib? do
case monitoring_auth_token do
nil -> false
_ -> true
end
end
def monitoring_auth_token do
gestalt_env("AUTH_TOKEN", self())
end
endAcceptance tests
Acceptance tests often involve multiple processes. Wallaby, for instance, starts a Phoenix server, which processes
requests in separate processes. In this case, self() in the context of the test process will be different from
self() in the server process.
This can by solved with the Gestalt.copy/2 function, in a test plug.
If the test Mix env includes test/support in its elixir paths, then a plug can be written in
test/support/test/plug/gestalt.ex
defmodule ProjectWeb.Test.Plug.Gestalt do
alias Plug.Conn
def init(_), do: nil
def call(conn, _opts),
do:
conn
|> extract_metadata()
|> copy_overrides(conn)
defp extract_metadata(%Conn{} = conn),
do:
conn
|> Conn.get_req_header("user-agent")
|> List.first()
|> Phoenix.Ecto.SQL.Sandbox.decode_metadata()
defp copy_overrides(%{gestalt_pid: gestalt_pid}, conn) do
Gestalt.copy(gestalt_pid, self())
conn
end
defp copy_overrides(_metadata, conn), do: conn
endThis can then be added to the Phoenix Endpoint:
defmodule ProjectWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_project
if Application.get_env(:my_project, :sql_sandbox) do
plug(Phoenix.Ecto.SQL.Sandbox)
plug(ProjectWeb.Test.Plug.Gestalt)
end
# ....
endIn the CaseTemplate used for your acceptance tests, the following can then be configured (if using Wallaby):
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Project.Repo)
unless tags[:async], do: Ecto.Adapters.SQL.Sandbox.mode(Project.Repo, {:shared, self()})
# Add the :gestalt_pid test process pid to the metadata being passed through each acceptance request header
metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(Project.Repo, self()) |> Map.put(:gestalt_pid, self())
{:ok, session} = Wallaby.start_session(metadata: metadata)
{:ok, session: session}
endNow, any overrides configured for the test process will be copied onto the server process pid, and
Gestalt.get_config/3 or Gestalt.get_env/2 will have your overrides available.
LiveView tests
LiveView tests can be configured to share the Gestalt configuration via Phoenix.LiveView.on_mount/1.
In the test, the current pid should be added to the conn's session.
Phoenix.ConnTest.init_test_session(conn, %{gestalt_pid: self()})
{:ok, _live, _html} = live(conn, "/")The LiveView can run an on_mount handler to copy configuration for the socket's pid.
defmodule Web.Gestalt do
def on_mount(:copy_gestalt_config, _params, %{"gestalt_pid" => gestalt_pid}, socket) do
Gestalt.copy(gestalt_pid, self())
{:cont, socket}
end
def on_mount(:copy_gestalt_config, _params, _session, socket) do
{:cont, socket}
end
endmodule Web.MyLive do
use Web, :live_view
on_mount {Web.Gestalt, :copy_gestalt_config}
def render(assigns) do
~H"<div />"
end
def mount(_params, _session, socket), do: {:ok, socket}
end