5 min read
On this page

Property and Integration Testing

Example-based tests are great for "given this input, expect this output." They're terrible at finding the input you didn't think of. Property tests turn that around: you describe what should be true for any valid input, and the framework generates hundreds of inputs trying to break your code. Mocking, similarly, is something Elixir's community has strong opinions about — global mocks are out, explicit contracts are in.

This file covers StreamData for property tests, Mox for contract-based mocking, and the patterns for testing GenServers, Phoenix controllers, and LiveViews. Together these are 90% of what a real test suite for an Elixir app looks like beyond the unit tests.

StreamData: Property-Based Testing

StreamData generates values. You describe the shape (an integer, a string, a list of maps with these keys), and the library produces an infinite stream of examples. Combined with ExUnitProperties.check, you can write tests that try hundreds of cases.

Add it to mix.exs:

{:stream_data, "~> 0.6", only: :test}

Then in a test file:

defmodule MyApp.SortTest do
  use ExUnit.Case, async: true
  use ExUnitProperties

  property "sorting is idempotent" do
    check all list <- list_of(integer()) do
      assert Enum.sort(list) == list |> Enum.sort() |> Enum.sort()
    end
  end

  property "sorted list has the same length as input" do
    check all list <- list_of(integer()) do
      assert length(Enum.sort(list)) == length(list)
    end
  end

  property "every element appears in the sorted output" do
    check all list <- list_of(integer()) do
      sorted = Enum.sort(list)
      Enum.each(list, fn x -> assert x in sorted end)
    end
  end
end

list_of(integer()) generates lists of integers — empty lists, lists of one element, lists of a thousand, with positive numbers, negatives, zero. By default, check all runs 100 iterations. If any fails, StreamData shrinks the input to find the smallest failing case.

The shrinking is the killer feature. You don't get "your test failed with [42, -17, 3, 0, 8, 99, ...]" — you get "your test failed with [1, 0]," because StreamData figured out that's the minimum case that triggers the bug.

Useful Generators

integer()
integer(0..100)
positive_integer()
float()
boolean()
string(:alphanumeric)
string(:printable, min_length: 1, max_length: 100)
binary()
list_of(integer())
nonempty_list_of(string(:alphanumeric))
map_of(atom(:alphanumeric), integer())
member_of([:red, :green, :blue])
one_of([integer(), string(:alphanumeric), boolean()])

For domain types, build generators that combine primitives:

def email_generator do
  gen all local <- string(:alphanumeric, min_length: 1, max_length: 20),
          domain <- string(:alphanumeric, min_length: 2, max_length: 10) do
    "#{local}@#{domain}.com"
  end
end

property "create_user accepts any valid email" do
  check all email <- email_generator() do
    assert {:ok, _} = Accounts.create_user(%{email: email, password: "hunter2hunter2"})
  end
end

Property tests shine for parsers, encoders, sorting and search algorithms, anything with mathematical properties (associativity, commutativity, idempotence), and validation logic where the space of inputs is large.

They're less useful for tests that involve a lot of state or external systems — every iteration runs your setup, which gets expensive. Reserve them for the pure parts of your code.

Mox: Mocking Without Global State

In a Ruby test, you can mock User.find and every test in the file is affected. In an Elixir test running in parallel, that's a disaster — one test's mock leaks into another. Mox solves this by making mocks per-process, with explicit contracts (behaviours).

The pattern is: define a behaviour for the thing you want to mock, write a real implementation, write a Mox-generated mock implementation, and inject which one to use via Application config.

# lib/my_app/email/sender.ex
defmodule MyApp.Email.Sender do
  @callback send(to :: String.t(), subject :: String.t(), body :: String.t()) ::
              :ok | {:error, term()}
end

# lib/my_app/email/ses_sender.ex - the real one
defmodule MyApp.Email.SESSender do
  @behaviour MyApp.Email.Sender

  @impl true
  def send(to, subject, body) do
    ExAws.SES.send_email(...) |> ExAws.request()
  end
end

# config/config.exs
config :my_app, :email_sender, MyApp.Email.SESSender

# config/test.exs
config :my_app, :email_sender, MyApp.Email.SenderMock

# test/test_helper.exs
Mox.defmock(MyApp.Email.SenderMock, for: MyApp.Email.Sender)
ExUnit.start()

In your application code, use the configured module:

defp sender, do: Application.fetch_env!(:my_app, :email_sender)

def notify_user(user) do
  sender().send(user.email, "Welcome", "Thanks for signing up")
end

Then in tests, set up expectations:

defmodule MyApp.AccountsTest do
  use ExUnit.Case, async: true
  import Mox

  setup :verify_on_exit!

  test "notify_user sends a welcome email" do
    expect(MyApp.Email.SenderMock, :send, fn to, subject, _body ->
      assert to == "alice@example.com"
      assert subject == "Welcome"
      :ok
    end)

    Accounts.notify_user(%{email: "alice@example.com"})
  end
end

expect/3 says "this function will be called once with these args; here's what to return." verify_on_exit! ensures all expectations were actually met by the end of the test.

Two important properties: expectations are scoped to the calling process (so async: true works), and they're contract-checked (the mock has to implement the behaviour, so if the real interface changes, the mock errors at compile time).

For tests where you want to allow the call but don't care about specifics, use stub/3:

stub(MyApp.Email.SenderMock, :send, fn _, _, _ -> :ok end)

The Mox approach forces a discipline — you can only mock things you've defined a behaviour for. That's friction, but the friction is the point: you end up with cleaner contracts at module boundaries.

Testing GenServers

A GenServer is just a process. To test it, start one (preferably with start_supervised!/1), call its public functions, and assert on the result.

defmodule MyApp.CounterTest do
  use ExUnit.Case, async: true

  setup do
    counter = start_supervised!(MyApp.Counter)
    %{counter: counter}
  end

  test "starts at zero", %{counter: counter} do
    assert MyApp.Counter.value(counter) == 0
  end

  test "increments", %{counter: counter} do
    MyApp.Counter.increment(counter)
    MyApp.Counter.increment(counter)
    assert MyApp.Counter.value(counter) == 2
  end
end

When the test ends, the supervised process gets shut down cleanly. Each test gets a fresh counter.

For testing internal state, you can use :sys.get_state/1 — but only for assertions in tests, never in production code:

test "stores requests in state", %{server: pid} do
  MyApp.Server.process(pid, %{a: 1})
  assert %{requests: [%{a: 1}]} = :sys.get_state(pid)
end

For testing async message handling, send a message and assert on side effects (a database row, a file, a message back):

test "broadcasts on tick" do
  Phoenix.PubSub.subscribe(MyApp.PubSub, "ticks")
  pid = start_supervised!(MyApp.Ticker)
  send(pid, :tick)
  assert_receive {:tick, _timestamp}, 200
end

Testing Phoenix Controllers

Phoenix's ConnTest gives you helpers to build connections, send requests, and assert on responses.

defmodule MyAppWeb.PostControllerTest do
  use MyAppWeb.ConnCase, async: true

  describe "POST /posts" do
    test "creates a post and redirects", %{conn: conn} do
      user = insert!(:user)

      conn =
        conn
        |> log_in_user(user)
        |> post(~p"/posts", post: %{title: "Hello", body: "World"})

      assert redirected_to(conn) == ~p"/posts"
      assert get_flash(conn, :info) =~ "created"
    end

    test "renders errors when invalid", %{conn: conn} do
      user = insert!(:user)

      conn =
        conn
        |> log_in_user(user)
        |> post(~p"/posts", post: %{title: ""})

      assert html_response(conn, 200) =~ "can&#39;t be blank"
    end
  end
end

use MyAppWeb.ConnCase brings in build_conn, post, get, redirected_to, html_response, json_response, and the SQL sandbox setup. Tests can run async because each gets its own connection and database transaction.

For JSON APIs, assert on the parsed JSON:

test "returns the post as JSON", %{conn: conn} do
  post = insert!(:post)
  conn = get(conn, ~p"/api/posts/#{post.id}")

  assert %{"id" => id, "title" => title} = json_response(conn, 200)
  assert id == post.id
  assert title == post.title
end

Testing LiveViews

LiveView has its own test helpers in Phoenix.LiveViewTest. The pattern is live(conn, path) to mount, then render_click, render_submit, render_change to fire events.

defmodule MyAppWeb.CounterLiveTest do
  use MyAppWeb.ConnCase, async: true
  import Phoenix.LiveViewTest

  test "renders the count", %{conn: conn} do
    {:ok, view, html} = live(conn, ~p"/counter")
    assert html =~ "0"

    assert render_click(view, "increment") =~ "1"
    assert render_click(view, "increment") =~ "2"
    assert render_click(view, "decrement") =~ "1"
  end
end

render_click(view, "increment") simulates a click on an element with phx-click="increment". The return is the updated HTML.

For form submissions:

test "creates a user", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/users/new")

  rendered =
    view
    |> form("#user-form", user: %{email: "alice@example.com", password: "hunter2hunter2"})
    |> render_submit()

  assert_redirect(view, ~p"/users")
end

For PubSub-driven updates:

test "shows new posts in real time", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/posts")
  assert render(view) =~ "No posts"

  {:ok, post} = Posts.create_post(%{title: "Hello"})
  Phoenix.PubSub.broadcast(MyApp.PubSub, "posts", {:new, post})

  # Give the LiveView a moment to handle the message and re-render
  assert render(view) =~ "Hello"
end

LiveView tests run async, mount real LiveView processes, and exercise the full render cycle. They're the closest thing to integration tests without spinning up a browser.

For tests that need actual browser behavior (JS hooks, real WebSockets, browser APIs), use Wallaby or Hound — but most LiveView functionality can be tested without them.

Common Pitfalls

Writing property tests where the property is just a restated implementation. "After encode then decode, you get the original" is a real property. "After calling sort, the list is sorted" needs a check that doesn't reuse the sort function — assert that consecutive elements are in order.

Mocking modules you don't own. Mox requires a behaviour. If you want to fake HTTPoison, wrap it in your own behaviour and mock that. The wrapper is a few lines and gives you a stable interface that doesn't break when HTTPoison releases a new major version.

Forgetting verify_on_exit!. Without it, an unmet expectation passes silently — the test ends, the expectation was never called, and you get a green check on broken code.

Testing GenServer internals through :sys.get_state/1 instead of through the public API. If you can't test the behavior through the API, the API is incomplete.

Mounting a LiveView and immediately asserting on PubSub-driven content without giving the message time to arrive. render/1 after a broadcast is usually fine, but if you're chasing flakiness, add Process.sleep(10) or use render_async/1 for clearer intent.

Key Takeaways

StreamData generates inputs and shrinks failing cases to the minimum example — use it for parsers, encoders, validators, and any function with provable invariants. Mox enforces explicit contracts via behaviours, scopes mocks per-process for safe parallelism, and refuses to mock modules without a defined contract. Test GenServers with start_supervised!/1 and the public API. Test Phoenix controllers with ConnCase. Test LiveViews with Phoenix.LiveViewTest. The pattern across all of them: real processes, real database transactions, real protocols — just isolated per test.