elixir-testing

Elixir Testing with ExUnit

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "elixir-testing" with this command: npx skills add vinnie357/claude-skills/vinnie357-claude-skills-elixir-testing

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.ex → test/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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

nushell

No summary provided by upstream source.

Repository SourceNeeds Review
General

anti-fabrication

No summary provided by upstream source.

Repository SourceNeeds Review
General

elixir-anti-patterns

No summary provided by upstream source.

Repository SourceNeeds Review
General

material-design

No summary provided by upstream source.

Repository SourceNeeds Review