SandboxCase
Batteries-included test isolation for Elixir and Phoenix.
The problem
Getting test isolation right in Elixir is surprisingly fiddly. Ecto has its SQL Sandbox, but everything else — caches, feature flags, mocks, GenServers — is shared global state that leaks between tests. The more your app uses these, the worse it gets:
- Caches (Cachex, ConCache) retain data between tests. A test that writes to a cache poisons every test that runs after it.
- Feature flags (FunWithFlags) are global. Enabling a flag in one test enables it everywhere.
- Mocks (Mimic, Mox) need explicit
allowor$callerswiring to reach spawned processes — LiveViews, GenServers, async tasks. - Async tests make all of this harder. Every process in the call chain needs to participate in the sandbox, or you get
DBConnection.OwnershipErrorand mysterious "cannot find ownership process" crashes.
The common workaround is giving up on async: true and running everything synchronously. This is safe but slow, and doesn't actually fix the cache/flag leakage — it just makes it less likely to bite you.
A better default: no shared state survives between tests. Each test gets its own database transaction, its own cache instance, its own feature flag store, its own mock context. SandboxCase sets this up with one config and one line in test_helper.
Crucially, each adapter isolates state in a way that's native to the library — Ecto gets a wrapped transaction, Cachex gets a real but isolated cache instance, FunWithFlags gets its own ETS table. Your tests still exercise the actual library code paths, so you catch real bugs in how your app uses the dependency. You can always fully mock a dependency (which itself benefits from the batteries-included approach) when that's what your test calls for — but when you don't, the default should be clean isolation, not leaked state.
How it works
One config, one setup call, zero boilerplate. Built-in adapters for Ecto, Cachex, FunWithFlags, Mimic, Mox, and Redis — each activated only if the dep is loaded.
All macros expand at compile time. Outside MIX_ENV=test, sandbox_plugs and sandbox_on_mount emit nothing, and socket_with_sandbox emits a plain socket call. No runtime checks, no dead branches, no production dependencies on test libraries.
Installation
{:sandbox_case, "~> 0.1"}Configuration
# config/test.exs
config :sandbox_case,
otp_app: :my_app,
mox_mocks: [MyApp.MockWeather],
sandbox: [
ecto: true,
cachex: [:my_cache],
fun_with_flags: true,
mimic: [MyApp.ExternalService, MyApp.Payments],
mox: [{MyApp.MockWeather, MyApp.WeatherBehaviour}],
redis: [url: "redis://localhost:6379"]
]Setup
# test/test_helper.exs
SandboxCase.Sandbox.setup()
ExUnit.start()Endpoint and LiveView
# lib/your_app_web/endpoint.ex
import SandboxCase
sandbox_plugs()
socket_with_sandbox "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]]# lib/your_app_web.ex
def live_view do
quote do
use Phoenix.LiveView
import SandboxCase
sandbox_on_mount()
end
endOutside test env, both macros emit nothing.
Test modules
use SandboxCase.Sandbox.Case
Checks out all sandboxes in setup, checks them back in via on_exit. Ecto metadata for browser sessions:
SandboxCase.Sandbox.ecto_metadata(context.sandbox_tokens)GenServers and sandbox access
Ecto sandbox, Mimic stubs, and Mox mocks are resolved via $callers — a process dictionary key that Elixir's Task module sets automatically. GenServer.start_link does not set it, so a GenServer started from a LiveView or test won't see sandboxed state by default.
The fix: pass $callers explicitly when starting the GenServer.
defmodule MyApp.PriceServer do
use GenServer
def start_supervised(opts \\ []) do
callers = [self() | Process.get(:"$callers", [])]
GenServer.start_link(__MODULE__, Keyword.put(opts, :callers, callers))
end
@impl true
def init(opts) do
if callers = opts[:callers], do: Process.put(:"$callers", callers)
{:ok, %{}}
end
@impl true
def handle_call(:fetch_price, _from, state) do
# This works in tests — Mimic finds the test process via $callers,
# and Ecto finds the sandbox connection the same way.
price = MyApp.PriceService.fetch_price()
{:reply, price, state}
end
end
This pattern works for any process you spawn that needs access to the test sandbox: GenServers, Agents, custom processes via spawn_link, etc. Task.start_link and Task.async handle this automatically.
Custom adapters
Implement SandboxCase.Sandbox.Adapter to add isolation for any shared state.
defmodule MyApp.RedisSandbox do
@behaviour SandboxCase.Sandbox.Adapter
# Is the dep available? Return false to skip this adapter entirely.
@impl true
def available?, do: Code.ensure_loaded?(Redix)
# One-time setup — called from test_helper.exs via SandboxCase.Sandbox.setup().
# Use this to start pools, create isolation resources, etc.
@impl true
def setup(config) do
pool_size = config[:pool_size] || 4
# ... start a pool of Redis connections
:ok
end
# Per-test checkout — called in each test's setup.
# Return an opaque token that will be passed to checkin/1.
@impl true
def checkout(_config) do
# ... claim an isolated Redis DB, flush it
%{db: db_number}
end
# Per-test checkin — called in on_exit.
@impl true
def checkin(nil), do: :ok
def checkin(%{db: db}) do
# ... release the Redis DB back to the pool
:ok
end
# Optional: plug modules to register in the endpoint.
# Omit this callback if your adapter doesn't need a plug.
@impl true
def plugs, do: []
# Optional: on_mount modules to register in LiveViews.
# Omit this callback if your adapter doesn't need a hook.
@impl true
def hooks, do: []
endRegister it in config:
config :sandbox_case,
sandbox: [
ecto: true,
{MyApp.RedisSandbox, pool_size: 4}
]The adapter lifecycle:
available?/0— checked at compile time (for plugs/hooks) and runtime (for setup/checkout). Returnfalseto skip.setup/1— called once fromSandboxCase.Sandbox.setup()in test_helper.checkout/1— called per test. Returns a token.checkin/1— called inon_exitwith the token from checkout.plugs/0— (optional) modules emitted bysandbox_plugs().hooks/0— (optional) modules emitted bysandbox_on_mount().
License
MIT