Testing Strategies for Elixir Applications
Elixir ships with one of the most powerful testing frameworks in the functional programming world: ExUnit. Because Elixir applications are built around immutable data, isolated processes, and explicit supervision trees, they lend themselves naturally to fast, deterministic, and highly parallel tests. However, having a great tool is not the same as having a great strategy. This tutorial walks through what testing strategies mean in the Elixir ecosystem, why they matter, and how to structure your test suite so it remains a reliable safety net as your application grows.
What Is a Testing Strategy?
A testing strategy is the deliberate way you organize, layer, and execute tests across an application. Rather than writing ad-hoc tests wherever you happen to be working, a strategy defines which kinds of tests you write, where they live, what they are allowed to touch, and how fast they should run. In Elixir, this typically maps to a layered approach inspired by the classic test pyramid: a broad base of fast unit tests, a smaller layer of integration tests, and a thin top of end-to-end tests.
Elixir's process model adds a unique dimension. Because each test runs inside its own process and ExUnit can run tests concurrently by default, you can treat tests as small supervised systems. This means you can test asynchronous behavior, message passing, and stateful GenServers without resorting to sleeps or brittle timing hacks — provided you structure the tests thoughtfully.
Why Testing Strategies Matter
- Confidence vs. speed tradeoff: A well-layered suite gives you fast feedback on most changes and reserves slower, broader tests for the cases that truly need them.
- Determinism: Elixir's concurrency can introduce subtle race conditions. A clear strategy helps you isolate side effects and avoid flaky tests.
- Refactoring safety: Tests that verify behavior rather than implementation details let you restructure internal code without rewriting the test suite.
- Onboarding: A consistent test structure makes it easier for new developers to understand where and how to add coverage.
- CI cost: Unbounded integration tests can turn a 10-second suite into a 10-minute one. A strategy keeps CI sustainable.
Layering Your Tests
The most effective Elixir suites separate tests by the boundary they exercise. A common and practical layering is:
- Pure functions: Tested directly, no mocks, no database.
- Contexts and business logic: Tested against a real (sandboxed) database when persistence is involved.
- GenServers and OTP: Tested through their public API, sometimes with explicit state assertions.
- Phoenix controllers and LiveViews: Tested through the endpoint, exercising the full request pipeline.
- External services: Tested behind well-defined boundaries, using mocks or bypass only at the edges.
Setting Up ExUnit
ExUnit is included with Elixir and configured by default in Mix projects. The entry point is usually test/test_helper.exs:
ExUnit.start(exclude: [:skip]) # Configure the SQL sandbox for async database tests Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)Each test file begins with
ExUnit.Caseor one of its specialized variants:defmodule MyApp.MathTest do use ExUnit.Case, async: true describe "factorial/1" do test "returns 1 for 0" do assert MyApp.Math.factorial(0) == 1 end test "computes the factorial of positive integers" do assert MyApp.Math.factorial(5) == 120 end end endThe
async: trueflag tells ExUnit to run this module's tests in parallel with other async modules. This is safe as long as tests do not share mutable state. For pure function tests, always enable async.Testing Pure Functions
Pure functions are the easiest and fastest to test. They take inputs and return outputs with no side effects, so you can test them exhaustively without any setup or teardown. Whenever possible, push logic into pure modules so that the bulk of your suite lives here.
defmodule MyApp.DiscountTest do use ExUnit.Case, async: true alias MyApp.Discount describe "apply/2" do test "applies a percentage discount to a price" do assert Discount.apply(100, 0.10) == 90.0 end test "clamps the discount to zero when it exceeds the price" do assert Discount.apply(50, 1.50) == 0 end test "returns the original price for a zero discount" do assert Discount.apply(75, 0) == 75 end end endNotice that there is no database, no process, and no mock. These tests run in microseconds and never become flaky. Prefer this style whenever a piece of logic can be expressed without side effects.
Testing Contexts with a Real Database
When a module interacts with the database, the best strategy in Elixir is to test against a real, sandboxed database rather than mocking Ecto. The Ecto SQL sandbox provides each test with its own transaction that is rolled back at the end, allowing tests to run concurrently without interfering with each other.
defmodule MyApp.AccountsTest do use MyApp.DataCase, async: true alias MyApp.Accounts alias MyApp.Accounts.User describe "register_user/1" do @valid_attrs %{email: "jane@example.com", password: "supersecret123"} test "creates a user with valid attributes" do assert {:ok, %User{} = user} = Accounts.register_user(@valid_attrs) assert user.email == "jane@example.com" end test "returns an error changeset for duplicate emails" do Accounts.register_user(@valid_attrs) assert {:error, %Ecto.Changeset{} = changeset} = Accounts.register_user(@valid_attrs) assert %{email: ["has already been taken"]} = errors_on(changeset) end end endThe
DataCasemodule handles the boilerplate of checking out a sandbox connection:defmodule MyApp.DataCase do use ExUnit.CaseTemplate using do quote do alias MyApp.Repo import Ecto import Ecto.Changeset import Ecto.Query import MyApp.DataCase end end setup tags do pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async]) on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) :ok end def errors_on(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> Regex.replace(~r"%{(\w+)}", message, fn _, key -> opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() end) end) end endThis approach gives you high-fidelity tests that exercise real SQL, real constraints, and real transactions, while still running in parallel and completing in milliseconds.
Testing GenServers and OTP
Stateful processes should be tested through their public API. Avoid reaching into a GenServer's internal state directly; instead, send messages and assert on the observable results. This keeps tests resilient to internal refactors.
defmodule MyApp.RateLimiterTest do use ExUnit.Case, async: true alias MyApp.RateLimiter setup do start_supervised!({RateLimiter, [limit: 3, window_ms: 100]}) :ok end test "allows requests up to the limit" do assert RateLimiter.allow?("user-1") == :ok assert RateLimiter.allow?("user-1") == :ok assert RateLimiter.allow?("user-1") == :ok assert RateLimiter.allow?("user-1") == {:error, :rate_limited} end test "tracks limits independently per key" do assert RateLimiter.allow?("user-1") == :ok assert RateLimiter.allow?("user-2") == :ok assert RateLimiter.allow?("user-2") == :ok assert RateLimiter.allow?("user-2") == :ok assert RateLimiter.allow?("user-2") == {:error, :rate_limited} end endThe
start_supervised!/1helper is important. It links the process to the test's supervision tree, so if the GenServer crashes, the failure is reported clearly and the process is automatically torn down when the test finishes. This prevents leaked processes from polluting subsequent tests.For tests that need to assert on asynchronous messages, use
assert_receive/2with an explicit timeout instead ofProcess.sleep/1:test "notifies subscribers when a job completes" do Phoenix.PubSub.subscribe(MyApp.PubSub, "jobs") MyApp.Worker.run(:job_1) assert_receive {:job_completed, :job_1}, 500 endTesting Phoenix Controllers
Phoenix provides
ConnCasefor testing the request pipeline through the endpoint. This exercises routing, plugs, controllers, and views together without starting a full HTTP server.defmodule MyAppWeb.UserControllerTest do use MyAppWeb.ConnCase, async: true alias MyApp.Accounts @create_attrs %{email: "jane@example.com", password: "supersecret123"} describe "create/2" do test "creates a user and returns 201", %{conn: conn} do conn = post(conn, ~p"/api/users", @create_attrs) assert %{"id" => id, "email" => "jane@example.com"} = json_response(conn, 201) assert Accounts.get_user!(id) end test "returns 422 for invalid attributes", %{conn: conn} do conn = post(conn, ~p"/api/users", %{email: "bad", password: "x"}) assert json_response(conn, 422)["errors"] != %{} end end endBecause
ConnCaseuses the Ecto sandbox, these tests can run concurrently with your context tests. The~psigil, provided by Phoenix verified routes, ensures your paths stay in sync with the router at compile time.Testing LiveViews
LiveView tests simulate a live WebSocket connection in a single process, letting you assert on rendered HTML and events without a browser. Use
live/1to mount a view andrender/1orelement/3to interact with it.defmodule MyAppWeb.CounterLiveTest do use MyAppWeb.ConnCase, async: true import Phoenix.LiveViewTest test "increments the counter when the button is clicked", %{conn: conn} do {:ok, view, html} = live(conn, ~p"/counter") assert html =~ "Count: 0" view |> element("button", "Increment") |> render_click() assert render(view) =~ "Count: 1" end endHandling External Dependencies
One of the most important strategic decisions is how to handle external services such as payment gateways, email providers, or third-party APIs. The recommended approach is to define a behaviour in your application and inject the implementation at runtime. In tests, you substitute a stub or a mock that implements the same contract.
defmodule MyApp.Notifications do @callback send_email(to :: String.t(), subject :: String.t(), body :: String.t()) :: {:ok, term()} | {:error, term()} end defmodule MyApp.Notifications.MailgunAdapter do @behaviour MyApp.Notifications @impl true def send_email(to, subject, body) do # Real HTTP call to Mailgun :ok end endIn your application code, depend on the behaviour, not the adapter:
defmodule MyApp.WelcomeMailer do @notifications Application.compile_env(:my_app, :notifications, MyApp.Notifications.MailgunAdapter) def send_welcome(user) do @notifications.send_email(user.email, "Welcome!", "Thanks for signing up.") end endIn tests, configure a stub implementation:
# config/test.exs config :my_app, notifications: MyApp.Notifications.StubAdapterdefmodule MyApp.Notifications.StubAdapter do @behaviour MyApp.Notifications @impl true def send_email(to, subject, body) do send(self(), {:email_sent, %{to: to, subject: subject, body: body}}) :ok end enddefmodule MyApp.WelcomeMailerTest do use ExUnit.Case, async: true test "sends a welcome email to the user" do user = %{email: "jane@example.com"} MyApp.WelcomeMailer.send_welcome(user) assert_received {:email_sent, %{to: "jane@example.com", subject: "Welcome!"}} end endFor HTTP-level testing, the Bypass library lets you spin up a local HTTP server that stands in for the real external service. This is useful when you want to test how your adapter handles specific responses, timeouts, or error codes.
defmodule MyApp.Notifications.MailgunAdapterTest do use ExUnit.Case, async: true setup do bypass = Bypass.open() {:ok, bypass: bypass} end test "returns ok on a 200 response", %{bypass: bypass} do Bypass.expect_once(bypass, "POST", "/messages", fn conn -> Plug.Conn.resp(conn, 200, ~s'{"id":"123"}') end) adapter = %MyApp.Notifications.MailgunAdapter{base_url: "http://localhost:#{bypass.port}"} assert {:ok, _} = adapter.send_email("jane@example.com", "Hi", "Body") end endProperty-Based Testing
For functions with large or infinite input domains, example-based tests can miss edge cases. The
StreamDatalibrary brings property-based testing to Elixir, generating hundreds of random inputs automatically and checking that certain properties always hold.defmodule MyApp.ListUtilsTest do use ExUnit.Case, async: true use ExUnitProperties alias MyApp.ListUtils property "flatten/1 never contains nested lists" do check all nested <- list_of(one_of([integer(), list_of(integer())])) do flattened = ListUtils.flatten(nested) refute Enum.any?(flattened, &is_list/1) end end property "flatten/1 preserves element count of a flat list" do check all list <- list_of(integer()) do assert ListUtils.flatten(list) == list end end endProperty tests are especially valuable for parsers, serializers, financial calculations, and any code where invariants must hold across a wide range of inputs.
Best Practices
- Prefer real dependencies over mocks. Mocking Ecto, the repo, or internal modules tends to couple tests to implementation details. Use the sandbox and real processes whenever feasible.
- Keep tests async by default. Only disable async when a test genuinely cannot tolerate parallel execution, such as tests that touch shared external resources.
- Test behavior, not implementation. Assert on the outputs and observable side effects of a function or process, not on private functions or internal state.
- Use
start_supervised!/1for processes. It gives you clean teardown and clear crash reports, avoiding leaked processes between tests. - Avoid
Process.sleep/1. Replace sleeps withassert_receive/2,Mox.expect/4verification, or explicit synchronization messages. - Isolate external boundaries. Define behaviours for third-party services and substitute implementations in tests, so your core logic never depends on network calls.
- Keep the suite fast. A suite that runs in under a few seconds encourages developers to run it constantly. Move slow tests to tagged groups and run them separately in CI.
- Use tags to organize tests. Tags like
:integrationor:externallet you run subsets of the suite and exclude slow tests during local development. - Write descriptive
describeblocks. Group tests by the function or feature they cover, and use clear test names that read as specifications. - Refactor tests like production code. Extract helpers into
DataCaseor dedicated test modules, and keep setup blocks focused and readable.
Conclusion
A strong testing strategy in Elixir is less about picking a single tool and more about layering your tests deliberately: pure functions for speed and clarity, sandboxed database tests for persistence logic, supervised process tests for OTP, and boundary tests with behaviours or Bypass for external services. By leaning on ExUnit's concurrency, the Ecto sandbox, and explicit supervision, you can build a suite that is fast, deterministic, and resilient to refactoring. The result is a codebase where developers trust the tests, run them constantly, and ship changes with confidence — which is, ultimately, the entire point of testing in the first place.