← Back to DevBytes

Testing Strategies for Elixir Applications

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

Layering Your Tests

The most effective Elixir suites separate tests by the boundary they exercise. A common and practical layering is:

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.Case or 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
end


The async: true flag 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
end


Notice 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
end


The DataCase module 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
end


This 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
end


The start_supervised!/1 helper 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/2 with an explicit timeout instead of Process.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
end


Testing Phoenix Controllers

Phoenix provides ConnCase for 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
end


Because ConnCase uses the Ecto sandbox, these tests can run concurrently with your context tests. The ~p sigil, 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/1 to mount a view and render/1 or element/3 to 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
end


Handling 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
end


In 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
end


In tests, configure a stub implementation:

# config/test.exs
config :my_app, notifications: MyApp.Notifications.StubAdapter


defmodule 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
end


defmodule 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
end


For 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
end


Property-Based Testing

For functions with large or infinite input domains, example-based tests can miss edge cases. The StreamData library 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
end


Property 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!/1 for processes. It gives you clean teardown and clear crash reports, avoiding leaked processes between tests.
  • Avoid Process.sleep/1. Replace sleeps with assert_receive/2, Mox.expect/4 verification, 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 :integration or :external let you run subsets of the suite and exclude slow tests during local development.
  • Write descriptive describe blocks. 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 DataCase or 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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles