Claude Code Plugins

Community-maintained marketplace

Feedback
0
0

Guide for writing comprehensive tests in Elixir using ExUnit, property-based testing, mocks, and test organization best practices

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name elixir-testing
description Guide for writing comprehensive tests in Elixir using ExUnit, property-based testing, mocks, and test organization best practices

Elixir Testing with ExUnit

This skill activates when writing, organizing, or improving tests for Elixir applications using ExUnit and related testing tools.

When to Use This Skill

Activate when:

  • Writing unit, integration, or property-based tests
  • Organizing test suites and test files
  • Setting up test fixtures and factories
  • Mocking external dependencies
  • Testing concurrent or asynchronous code
  • Improving test coverage or quality
  • Troubleshooting failing tests

ExUnit Basics

Test Module Structure

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

  describe "add/2" do
    test "adds two positive numbers" do
      assert Math.add(2, 3) == 5
    end

    test "adds negative numbers" do
      assert Math.add(-1, -1) == -2
    end

    test "adds zero" do
      assert Math.add(5, 0) == 5
    end
  end

  describe "divide/2" do
    test "divides two numbers" do
      assert Math.divide(10, 2) == 5.0
    end

    test "returns error for division by zero" do
      assert Math.divide(10, 0) == {:error, :division_by_zero}
    end
  end
end

Assertions

Common assertion patterns:

# Equality
assert actual == expected
refute actual == unexpected

# Boolean
assert is_binary(value)
assert is_integer(value)
refute is_nil(value)

# Pattern matching
assert {:ok, result} = function_call()
assert %User{name: "Alice"} = user

# Exceptions
assert_raise ArgumentError, fn ->
  String.to_integer("not a number")
end

assert_raise ArgumentError, "invalid argument", fn ->
  dangerous_function()
end

# Messages
send(self(), :hello)
assert_received :hello

assert_receive :message, 1000  # With timeout

refute_received :unwanted
refute_receive :unwanted, 100

Test Organization

Using describe blocks

Group related tests:

defmodule MyApp.UserTest do
  use ExUnit.Case

  describe "create_user/1" do
    test "creates user with valid attributes" do
      # ...
    end

    test "returns error with invalid email" do
      # ...
    end
  end

  describe "update_user/2" do
    test "updates user attributes" do
      # ...
    end
  end
end

Test tags

Categorize and filter tests:

@moduletag :integration

@tag :slow
test "expensive operation" do
  # ...
end

@tag :external
test "calls external API" do
  # ...
end

# Run only tagged tests
# mix test --only slow
# mix test --exclude external

Setup and Teardown

Test context

defmodule MyApp.UserTest do
  use ExUnit.Case

  setup do
    user = %User{name: "Alice", email: "alice@example.com"}
    {:ok, user: user}
  end

  test "user has name", %{user: user} do
    assert user.name == "Alice"
  end

  test "user has email", %{user: user} do
    assert user.email == "alice@example.com"
  end
end

Setup with describe

describe "authenticated user" do
  setup do
    user = insert(:user)
    token = generate_token(user)
    {:ok, user: user, token: token}
  end

  test "can access protected resource", %{token: token} do
    # ...
  end
end

Module setup

setup_all do
  # Runs once before all tests in module
  start_supervised!(MyApp.Cache)
  :ok
end

setup do
  # Runs before each test
  :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo)
end

Conditional setup

setup context do
  if context[:integration] do
    start_external_service()
    on_exit(fn -> stop_external_service() end)
  end

  :ok
end

@tag :integration
test "integration test" do
  # ...
end

Database Testing

Sandbox Mode

Configure for concurrent tests:

# config/test.exs
config :my_app, MyApp.Repo,
  pool: Ecto.Adapters.SQL.Sandbox

# test/test_helper.exs
Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)

# test/support/data_case.ex
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
end

Test Factories

Use ExMachina for test data:

# test/support/factory.ex
defmodule MyApp.Factory do
  use ExMachina.Ecto, repo: MyApp.Repo

  def user_factory do
    %MyApp.User{
      name: "Jane Smith",
      email: sequence(:email, &"email-#{&1}@example.com"),
      age: 25
    }
  end

  def admin_factory do
    struct!(
      user_factory(),
      %{role: :admin}
    )
  end

  def post_factory do
    %MyApp.Post{
      title: "A title",
      body: "Some content",
      author: build(:user)
    }
  end
end

# In tests
defmodule MyApp.UserTest do
  use MyApp.DataCase
  import MyApp.Factory

  test "creates user" do
    user = insert(:user)
    assert user.id
  end

  test "creates admin" do
    admin = insert(:admin)
    assert admin.role == :admin
  end

  test "builds without inserting" do
    user = build(:user, name: "Custom Name")
    assert user.name == "Custom Name"
    refute user.id
  end
end

Testing Changesets

defmodule MyApp.UserTest do
  use MyApp.DataCase

  describe "changeset/2" do
    test "valid changeset with valid attributes" do
      attrs = %{name: "Alice", email: "alice@example.com", age: 25}
      changeset = User.changeset(%User{}, attrs)

      assert changeset.valid?
    end

    test "invalid without email" do
      attrs = %{name: "Alice", age: 25}
      changeset = User.changeset(%User{}, attrs)

      refute changeset.valid?
      assert "can't be blank" in errors_on(changeset).email
    end

    test "invalid with short password" do
      attrs = %{email: "test@example.com", password: "123"}
      changeset = User.changeset(%User{}, attrs)

      assert "should be at least 8 character(s)" in errors_on(changeset).password
    end
  end
end

# Helper function
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

Phoenix Testing

Controller Tests

defmodule MyAppWeb.UserControllerTest do
  use MyAppWeb.ConnCase
  import MyApp.Factory

  describe "index" do
    test "lists all users", %{conn: conn} do
      user = insert(:user)

      conn = get(conn, ~p"/users")

      assert html_response(conn, 200) =~ "Listing Users"
      assert html_response(conn, 200) =~ user.name
    end
  end

  describe "create" do
    test "creates user with valid data", %{conn: conn} do
      attrs = %{name: "Alice", email: "alice@example.com"}

      conn = post(conn, ~p"/users", user: attrs)

      assert redirected_to(conn) =~ ~p"/users"

      conn = get(conn, redirected_to(conn))
      assert html_response(conn, 200) =~ "Alice"
    end

    test "renders errors with invalid data", %{conn: conn} do
      conn = post(conn, ~p"/users", user: %{})

      assert html_response(conn, 200) =~ "New User"
    end
  end
end

LiveView Tests

defmodule MyAppWeb.UserLiveTest do
  use MyAppWeb.ConnCase
  import Phoenix.LiveViewTest
  import MyApp.Factory

  describe "Index" do
    test "displays users", %{conn: conn} do
      user = insert(:user)

      {:ok, view, html} = live(conn, ~p"/users")

      assert html =~ "Listing Users"
      assert has_element?(view, "#user-#{user.id}")
      assert render(view) =~ user.name
    end

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

      assert view
             |> form("#user-form", user: %{name: "Alice", email: "alice@example.com"})
             |> render_submit()

      assert_patch(view, ~p"/users")

      html = render(view)
      assert html =~ "Alice"
    end

    test "updates user", %{conn: conn} do
      user = insert(:user)

      {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit")

      assert view
             |> form("#user-form", user: %{name: "Updated Name"})
             |> render_submit()

      assert_patch(view, ~p"/users/#{user.id}")

      html = render(view)
      assert html =~ "Updated Name"
    end

    test "deletes user", %{conn: conn} do
      user = insert(:user)

      {:ok, view, _html} = live(conn, ~p"/users")

      assert view
             |> element("#user-#{user.id} a", "Delete")
             |> render_click()

      refute has_element?(view, "#user-#{user.id}")
    end
  end

  describe "form validation" do
    test "validates on change", %{conn: conn} do
      {:ok, view, _html} = live(conn, ~p"/users/new")

      result =
        view
        |> form("#user-form", user: %{email: "invalid"})
        |> render_change()

      assert result =~ "must have the @ sign"
    end
  end

  describe "real-time updates" do
    test "receives updates from PubSub", %{conn: conn} do
      {:ok, view, _html} = live(conn, ~p"/users")

      user = insert(:user)

      # Trigger PubSub event
      Phoenix.PubSub.broadcast(MyApp.PubSub, "users", {:user_created, user})

      assert render(view) =~ user.name
    end
  end
end

Channel Tests

defmodule MyAppWeb.RoomChannelTest do
  use MyAppWeb.ChannelCase

  setup do
    {:ok, _, socket} =
      MyAppWeb.UserSocket
      |> socket("user_id", %{user_id: 42})
      |> subscribe_and_join(MyAppWeb.RoomChannel, "room:lobby")

    %{socket: socket}
  end

  test "ping replies with pong", %{socket: socket} do
    ref = push(socket, "ping", %{"hello" => "there"})
    assert_reply ref, :ok, %{"hello" => "there"}
  end

  test "shout broadcasts to room:lobby", %{socket: socket} do
    push(socket, "shout", %{"hello" => "all"})
    assert_broadcast "shout", %{"hello" => "all"}
  end

  test "broadcasts are pushed to the client", %{socket: socket} do
    broadcast_from!(socket, "broadcast", %{"some" => "data"})
    assert_push "broadcast", %{"some" => "data"}
  end
end

Mocking and Stubbing

Using Mox

Define behaviors and mocks:

# Define behaviour
defmodule MyApp.HTTPClient do
  @callback get(String.t()) :: {:ok, map()} | {:error, term()}
end

# In config/test.exs
config :my_app, :http_client, MyApp.HTTPClientMock

# In test/test_helper.exs
Mox.defmock(MyApp.HTTPClientMock, for: MyApp.HTTPClient)

# In application code
defmodule MyApp.UserFetcher do
  @http_client Application.compile_env(:my_app, :http_client)

  def fetch_user(id) do
    @http_client.get("/users/#{id}")
  end
end

# In tests
defmodule MyApp.UserFetcherTest do
  use ExUnit.Case, async: true
  import Mox

  setup :verify_on_exit!

  test "fetches user successfully" do
    expect(MyApp.HTTPClientMock, :get, fn "/users/1" ->
      {:ok, %{"name" => "Alice"}}
    end)

    assert {:ok, %{"name" => "Alice"}} = MyApp.UserFetcher.fetch_user(1)
  end

  test "handles error" do
    expect(MyApp.HTTPClientMock, :get, fn _ ->
      {:error, :network_error}
    end)

    assert {:error, :network_error} = MyApp.UserFetcher.fetch_user(1)
  end
end

Stubbing Multiple Calls

test "calls API multiple times" do
  MyApp.HTTPClientMock
  |> expect(:get, 3, fn url ->
    {:ok, %{"url" => url}}
  end)

  MyApp.batch_fetch([1, 2, 3])
end

Global Stubs

setup do
  stub(MyApp.HTTPClientMock, :get, fn _ -> {:ok, %{}} end)
  :ok
end

test "can override stub" do
  expect(MyApp.HTTPClientMock, :get, fn _ ->
    {:error, :timeout}
  end)

  # ...
end

Property-Based Testing

Use StreamData for property-based tests:

defmodule MyApp.MathPropertyTest do
  use ExUnit.Case
  use ExUnitProperties

  property "addition is commutative" do
    check all a <- integer(),
              b <- integer() do
      assert Math.add(a, b) == Math.add(b, a)
    end
  end

  property "list reversal is involutive" do
    check all list <- list_of(integer()) do
      assert Enum.reverse(Enum.reverse(list)) == list
    end
  end

  property "concatenation length" do
    check all list1 <- list_of(term()),
              list2 <- list_of(term()) do
      concatenated = list1 ++ list2
      assert length(concatenated) == length(list1) + length(list2)
    end
  end
end

Custom Generators

defmodule MyApp.Generators do
  use ExUnitProperties

  def email do
    gen all username <- string(:alphanumeric, min_length: 1),
            domain <- string(:alphanumeric, min_length: 1),
            tld <- member_of(["com", "org", "net"]) do
      "#{username}@#{domain}.#{tld}"
    end
  end

  def user do
    gen all name <- string(:alphanumeric, min_length: 1),
            email <- email(),
            age <- integer(18..100) do
      %User{name: name, email: email, age: age}
    end
  end
end

# Use in tests
property "validates email format" do
  check all email <- MyApp.Generators.email() do
    assert User.valid_email?(email)
  end
end

Testing Async and Concurrent Code

Testing Processes

test "GenServer handles messages" do
  {:ok, pid} = MyApp.Worker.start_link()

  MyApp.Worker.process(pid, :work)

  assert_receive {:done, :work}, 1000
end

Testing Tasks

test "async task completes" do
  parent = self()

  Task.start(fn ->
    result = expensive_computation()
    send(parent, {:result, result})
  end)

  assert_receive {:result, value}, 5000
  assert value == expected
end

Testing Race Conditions

test "concurrent updates are handled correctly" do
  {:ok, counter} = Counter.start_link(0)

  tasks = for _ <- 1..100 do
    Task.async(fn -> Counter.increment(counter) end)
  end

  Task.await_many(tasks)

  assert Counter.get(counter) == 100
end

Test Coverage

Generate Coverage Reports

mix test --cover

# Detailed coverage
MIX_ENV=test mix coveralls
MIX_ENV=test mix coveralls.html

Coverage Configuration

# mix.exs
def project do
  [
    # ...
    test_coverage: [tool: ExCoveralls],
    preferred_cli_env: [
      coveralls: :test,
      "coveralls.detail": :test,
      "coveralls.html": :test
    ]
  ]
end

Best Practices

Test Organization

  • One test file per module: lib/my_app/user.extest/my_app/user_test.exs
  • Use describe blocks to group related tests
  • Use test/support for shared test helpers
  • Keep tests focused on one behavior per test

Naming

  • Use descriptive test names that explain what is being tested
  • Start with the action being tested
  • Include the expected outcome
# Good
test "create_user/1 returns error with invalid email"
test "add/2 returns sum of two positive integers"

# Avoid
test "it works"
test "test1"

Setup

  • Use setup for common test data
  • Keep setup focused - don't create unnecessary data
  • Use context to pass data between setup and tests
  • Use factories for complex data structures

Assertions

  • Prefer pattern matching over multiple assertions
  • Use specific assertions (assert_receive vs assert Process.info(...))
  • Test one logical assertion per test when possible

Async Tests

# Mark tests as async when they don't share state
use ExUnit.Case, async: true

# Don't use async when tests:
# - Modify global state
# - Use database without sandbox
# - Access shared resources

Test Data

  • Use factories (ExMachina) for consistent test data
  • Avoid hardcoded IDs - use factories and references
  • Keep test data minimal - only what's needed for the test
  • Use descriptive data that makes tests readable

External Dependencies

  • Mock external APIs and services
  • Use Mox for behavior-based mocking
  • Stub at the boundary - don't mock internal modules
  • Tag tests that require external services

Debugging Tests

Running Specific Tests

# Run single test file
mix test test/my_app/user_test.exs

# Run specific line
mix test test/my_app/user_test.exs:42

# Run tests matching pattern
mix test --only integration

# Run tests excluding pattern
mix test --exclude slow

Test Output

# Add IEx.pry breakpoint
import IEx
test "debugging" do
  user = build(:user)
  IEx.pry()  # Stops here
  # ...
end

# Print during tests
IO.inspect(value, label: "DEBUG")

Failed Test Debugging

# Re-run only failed tests
mix test --failed

# Show detailed error traces
mix test --trace

# Run tests one at a time
mix test --max-cases 1

Key Principles

  • Test behavior, not implementation: Test what the code does, not how it does it
  • Keep tests fast: Use async tests, avoid unnecessary setup, mock slow dependencies
  • Make tests readable: Use descriptive names, clear assertions, minimal setup
  • Test at the right level: Unit tests for logic, integration tests for interactions
  • Use factories: Consistent, reusable test data with ExMachina
  • Mock at boundaries: Mock external services, not internal modules
  • Property-based testing: Use StreamData for algorithmic code
  • Embrace the database: Use Ecto sandbox for fast, isolated database tests