Guide for writing comprehensive tests in Elixir using ExUnit, property-based testing, mocks, and test organization best practices
Activates when writing or improving Elixir tests with ExUnit. Provides guidance on test organization, assertions, fixtures, mocking with Mox, property-based testing with StreamData, and Phoenix/LiveView testing patterns.
/plugin marketplace add vinnie357/claude-skills/plugin install all-skills@vinnie357This skill inherits all available tools. When active, it can use any tool Claude has access to.
This skill activates when writing, organizing, or improving tests for Elixir applications using ExUnit and related testing tools.
Activate when:
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
test "calls API multiple times" do
MyApp.HTTPClientMock
|> expect(:get, 3, fn url ->
{:ok, %{"url" => url}}
end)
MyApp.batch_fetch([1, 2, 3])
end
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
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
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
test "GenServer handles messages" do
{:ok, pid} = MyApp.Worker.start_link()
MyApp.Worker.process(pid, :work)
assert_receive {:done, :work}, 1000
end
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
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
mix test --cover
# Detailed coverage
MIX_ENV=test mix coveralls
MIX_ENV=test mix coveralls.html
# mix.exs
def project do
[
# ...
test_coverage: [tool: ExCoveralls],
preferred_cli_env: [
coveralls: :test,
"coveralls.detail": :test,
"coveralls.html": :test
]
]
end
lib/my_app/user.ex → test/my_app/user_test.exsdescribe blocks to group related teststest/support for shared test helpers# 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 for common test dataassert_receive vs assert Process.info(...))# 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
# 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
# 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")
# 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
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.