name: elixir-testing description: ExUnit testing patterns, Mox for mocking, StreamData for property-based testing, and Phoenix test cases for Elixir applications.
Elixir Testing
When to Activate
Use this skill when:
- Writing ExUnit tests for Elixir or Phoenix applications
- Setting up Mox for mock-based testing
- Implementing property-based tests with StreamData
- Writing Phoenix controller or LiveView tests
- Setting up test factories with ExMachina
- Debugging flaky concurrent tests in Elixir
- Setting up code coverage with excoveralls
- Testing GenServer behavior and OTP processes
ExUnit Basics
defmodule MyApp.CalculatorTest do
use ExUnit.Case, async: true # Always async: true unless DB or shared state
describe "add/2" do
test "adds two positive numbers" do
assert Calculator.add(1, 2) == 3
end
test "handles negative numbers" do
assert Calculator.add(-1, 1) == 0
end
end
describe "divide/2" do
test "returns {:error, :division_by_zero} when divisor is 0" do
assert {:error, :division_by_zero} = Calculator.divide(10, 0)
end
end
end
Mox for Compile-Safe Mocks
# 1. Define behaviour
defmodule MyApp.Payments.Gateway do
@callback charge(amount :: integer(), token :: String.t()) ::
{:ok, map()} | {:error, String.t()}
end
# 2. Implementation
defmodule MyApp.Payments.StripeGateway do
@behaviour MyApp.Payments.Gateway
def charge(amount, token) do
Stripe.Charge.create(%{amount: amount, source: token, currency: "usd"})
end
end
# 3. Register mock in test/test_helper.exs
Mox.defmock(MyApp.MockGateway, for: MyApp.Payments.Gateway)
# 4. Configure application to use mock in test env
# config/test.exs
config :my_app, :payment_gateway, MyApp.MockGateway
# 5. Use in tests
defmodule MyApp.OrderServiceTest do
use ExUnit.Case, async: true
import Mox
setup :verify_on_exit!
test "processes payment on order creation" do
expect(MyApp.MockGateway, :charge, fn 1000, "tok_test" -> {:ok, %{id: "ch_123"}} end)
assert {:ok, order} = OrderService.create(%{amount: 1000, token: "tok_test"})
assert order.payment_id == "ch_123"
end
end
DataCase for Database Tests
# test/support/data_case.ex (generated by mix phx.new)
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
MyApp.DataCase.setup_sandbox(tags)
:ok
end
def setup_sandbox(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)
end
end
# Usage
defmodule MyApp.AccountsTest do
use MyApp.DataCase
test "creates user with valid attrs" do
assert {:ok, user} = Accounts.create_user(%{email: "user@example.com"})
assert user.id
end
end
ExMachina for Test Factories
# test/support/factory.ex
defmodule MyApp.Factory do
use ExMachina.Ecto, repo: MyApp.Repo
def user_factory do
%MyApp.Accounts.User{
email: sequence(:email, &"user#{&1}@example.com"),
password_hash: Bcrypt.hash_pwd_salt("password123"),
role: :user
}
end
def admin_factory do
struct!(user_factory(), role: :admin)
end
def post_factory do
%MyApp.Blog.Post{
title: "Test Post #{System.unique_integer()}",
body: "Body content",
author: build(:user)
}
end
end
# Usage in tests
user = insert(:user)
admin = insert(:admin)
post = insert(:post, author: user)
StreamData Property Testing
defmodule MyApp.ValidatorTest do
use ExUnit.Case, async: true
use ExUnitProperties
property "email validator accepts all valid emails" do
check all local <- string(:alphanumeric, min_length: 1),
domain <- string(:alphanumeric, min_length: 1) do
email = "#{local}@#{domain}.com"
assert Validator.valid_email?(email)
end
end
property "encode then decode is identity" do
check all data <- binary() do
assert Base.decode64!(Base.encode64(data)) == data
end
end
end
Testing GenServers
defmodule MyApp.CacheTest do
use ExUnit.Case, async: true
setup do
{:ok, pid} = start_supervised(MyApp.Cache)
%{cache: pid}
end
test "stores and retrieves values" do
MyApp.Cache.put(:key, "value")
assert MyApp.Cache.get(:key) == "value"
end
test "returns nil for missing keys" do
assert MyApp.Cache.get(:missing) == nil
end
end
Anti-Patterns
# WRONG: Testing implementation, not behavior
test "calls Repo.insert" do
expect(MockRepo, :insert, fn _ -> {:ok, %User{}} end)
Users.create(%{email: "test@example.com"})
verify!(MockRepo)
end
# CORRECT: Test observable behavior
test "creates user in database" do
assert {:ok, user} = Users.create(%{email: "test@example.com"})
assert Repo.get(User, user.id)
end
# WRONG: Sleeping for async operations
test "sends email asynchronously" do
Users.register(%{email: "test@example.com"})
Process.sleep(100) # Flaky!
assert email_sent?()
end
# CORRECT: Use ExUnit's built-in async testing patterns
test "enqueues email job" do
assert {:ok, _} = Users.register(%{email: "test@example.com"})
assert_enqueued worker: WelcomeEmailWorker, args: %{email: "test@example.com"}
end
Reference
- See
elixir-patternsskill for GenServer, Supervisor, and Phoenix context patterns - See
rules/elixir/testing.mdfor project-level testing standards