Тестирование в Elixir
Тестирование в Elixir — часть языка, а не сторонняя надстройка: ExUnit входит в поставку, тесты пишутся с первой минуты (mix new уже создаёт test/). Более того, конкурентная природа BEAM позволяет запускать тесты параллельно, а документация умеет быть исполняемым тестом. Разберём всё — от юнита до property-based и CI.
ExUnit — встроенный фреймворк
# test/math_test.exs
defmodule MathTest do
use ExUnit.Case, async: true # async: true — параллельно с другими async-модулями
doctest Math # запустить примеры из @doc модуля Math (см. ниже)
describe "add/2" do
test "складывает положительные числа" do
assert Math.add(2, 3) == 5
end
test "работает с нулём" do
assert Math.add(0, 5) == 5
end
end
test "деление на ноль падает" do
assert_raise ArithmeticError, fn -> Math.divide(1, 0) end
end
end
Запуск:
mix test # все тесты
mix test test/math_test.exs # один файл
mix test test/math_test.exs:12 # конкретный тест по номеру строки
mix test --failed # только упавшие в прошлый раз
mix test --stale # только затронутые изменениями
Главный макрос — assert. ExUnit делает «умную» диагностику: при падении assert a == b он покажет обе стороны и подсветит разницу, без библиотек вроде «expect». Есть refute (обратное), assert_raise, assert_receive (для сообщений процессов), assert_in_delta (для float).
Асинхронные тесты — скорость благодаря BEAM
async: true разрешает ExUnit запускать этот тест-модуль параллельно с другими async-модулями по числу ядер. На больших наборах это кратно ускоряет прогон.
Условие корректности: тест не должен трогать разделяемое глобальное состояние (глобальные именованные процессы, общая БД без изоляции). Внутри одного модуля тесты всегда идут последовательно. Для тестов с БД изоляцию даёт Ecto.Adapters.SQL.Sandbox (каждый тест — своя транзакция с откатом), что позволяет держать async: true даже с базой.
Практика: делайте async: true по умолчанию; отключайте только там, где реально есть общее состояние.
Фикстуры и setup
defmodule AccountTest do
use ExUnit.Case, async: true
setup do
user = %User{email: "test@example.com"}
# то, что вернём в map, попадёт в контекст теста
{:ok, user: user}
end
test "у пользователя есть email", %{user: user} do
assert user.email == "test@example.com"
end
# setup можно делать под тег
@tag :slow
test "долгий сценарий" do
# запускается только при mix test --include slow
end
end
setup выполняется перед каждым тестом, setup_all — один раз на модуль. Теги (@tag) позволяют выборочно включать/исключать группы (--include/--exclude).
Doctests — документация как тест
Уникальная фича: примеры в @doc с префиксом iex> исполняются как тесты. Это гарантирует, что документация не устареет и не соврёт.
defmodule Math do
@doc """
Складывает два числа.
## Примеры
iex> Math.add(2, 3)
5
iex> Math.add(-1, 1)
0
"""
def add(a, b), do: a + b
end
Подключается одной строкой doctest Math в тест-модуле. Теперь mix test проверит, что Math.add(2, 3) действительно даёт 5. Идеально для чистых функций с наглядными примерами.
Тестовая пирамида в Elixir
- Юнит-тесты — чистые функции и отдельные модули. Их большинство, они быстрые и
async: true. Elixir поощряет функциональное ядро → его легко тестировать без моков. - Интеграционные — контексты + БД (через Ecto Sandbox), взаимодействие процессов, GenServer’ы. Средний слой.
- E2E / контроллерные — в Phoenix:
ConnTestдля HTTP-эндпоинтов,LiveViewTestдля LiveView (кликаем, вводим, проверяем DOM без браузера). Их меньше, они дороже.
Идиома дизайна для тестируемости: держите функциональное ядро чистым (легко юнит-тестировать), а «грязные» краешки (I/O, процессы) — тонкими. Это перекликается с архитектурой из главы Архитектура и продакшн.
Тестирование процессов и OTP
test "counter увеличивается" do
{:ok, pid} = Counter.start_link(0)
Counter.inc(pid, 5)
assert Counter.value(pid) == 5
end
# Проверка получения сообщения (полезно для проверки, что процесс что-то шлёт)
test "воркер уведомляет о завершении" do
Worker.run(self()) # передаём себя как получателя
assert_receive {:done, _result}, 1_000 # ждём сообщение до 1 сек
end
start_supervised/1 в тесте запускает процесс под тестовым супервизором и автоматически останавливает его после теста — избегает утечек процессов между тестами.
Моки — правильно, через поведения (Mox)
В Elixir не принято подменять функции «на лету» (это ломает конкурентность тестов). Идиома — Mox: мок создаётся по контракту (behaviour), а реализация выбирается через конфигурацию. «Mock as a noun, not a verb» — мок как конкретная реализация интерфейса для теста, а не глобальный патч.
# 1. Контракт
defmodule MyApp.HTTPClient do
@callback get(url :: String.t()) :: {:ok, map()} | {:error, term()}
end
# 2. Определяем мок по контракту (в test/support или test_helper.exs)
Mox.defmock(MyApp.HTTPClientMock, for: MyApp.HTTPClient)
# 3. Конфигурируем: в тестах используем мок
# config/test.exs: config :my_app, :http_client, MyApp.HTTPClientMock
# config/prod.exs: config :my_app, :http_client, MyApp.RealHTTPClient
# 4. Код берёт реализацию из конфига
defp http_client, do: Application.get_env(:my_app, :http_client)
# 5. В тесте задаём ожидания
test "обрабатывает ответ API" do
Mox.expect(MyApp.HTTPClientMock, :get, fn "https://api/x" ->
{:ok, %{"status" => "ok"}}
end)
assert {:ok, _} = MyApp.fetch_x()
Mox.verify!() # проверить, что ожидания выполнены (обычно через verify_on_exit!)
end
Mox совместим с async: true (у каждого теста своя изоляция ожиданий) — большое преимущество перед глобальными патчами. Мокайте только границы системы (внешние API, платёжные шлюзы), а не собственную бизнес-логику.
Property-based тестирование со StreamData
Пример-ориентированные тесты проверяют конкретные случаи. Property-based проверяет свойства на сотнях случайно сгенерированных входов и при падении «сжимает» контрпример до минимального. Библиотека — StreamData.
defmodule SortTest do
use ExUnit.Case, async: true
use ExUnitProperties
property "сортировка идемпотентна и сохраняет длину" do
check all list <- list_of(integer()) do
sorted = Enum.sort(list)
assert length(sorted) == length(list) # длина не меняется
assert Enum.sort(sorted) == sorted # повторная сортировка ничего не меняет
assert Enum.all?(Enum.zip(sorted, tl(sorted)), fn {a, b} -> a <= b end)
end
end
property "кодирование и декодирование обратны друг другу (round-trip)" do
check all data <- map_of(string(:alphanumeric), integer()) do
assert data |> encode() |> decode() == data
end
end
end
Property-тесты особенно ценны для: сериализации (round-trip), инвариантов структур данных, парсеров, любых функций с чётким математическим свойством. Они находят краевые случаи, о которых вы не подумали (пустой список, отрицательные, огромные значения). Комбинируйте: пример-тесты для конкретики + property-тесты для инвариантов.
Покрытие кода
mix test --cover # встроенный отчёт по покрытию
Для наглядного HTML-отчёта и порогов используют ExCoveralls:
# mix.exs
{:excoveralls, "~> 0.18", only: :test}
# + test_coverage: [tool: ExCoveralls] в project()
mix coveralls # отчёт в консоль
mix coveralls.html # HTML-отчёт
Важно про метрику: покрытие показывает, какой код исполнялся, а не что он проверен корректно. 100% покрытия с бессмысленными assert’ами хуже, чем 80% осмысленных. Не гонитесь за цифрой ради цифры; используйте покрытие, чтобы найти непротестированные критичные ветки.
Интеграция в CI
Минимальный «зелёный конвейер» для Elixir-проекта:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
db:
image: postgres:16
env: { POSTGRES_PASSWORD: postgres }
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1 # официальный action для Erlang/Elixir
with:
otp-version: "27.0"
elixir-version: "1.17.2"
- name: Кэш зависимостей и PLT для Dialyzer
uses: actions/cache@v4
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
- run: mix deps.get
- run: mix format --check-formatted # стиль
- run: mix credo --strict # линтер
- run: mix deps.unlock --check-unused # нет лишних зависимостей
- run: mix test --cover # тесты + покрытие
- run: mix dialyzer # статический анализ типов
Порядок шагов важен: быстрые и дешёвые проверки (format, credo) раньше медленных (test, dialyzer) — «падаем рано». Кэшируйте deps, _build и PLT — иначе Dialyzer будет пересобирать кэш каждый раз. Подробнее про пайплайн — в главе SDLC.
Типичные ошибки в тестах
async: trueпри разделяемом глобальном состоянии — «мигающие» (flaky) тесты. Либо изолируйте состояние, либо отключите async для этого модуля.- Тесты, зависящие от порядка выполнения — признак скрытого общего состояния.
- Мок собственной логики вместо границ — тесты проверяют моки, а не поведение. Мокайте только внешний мир.
Process.sleepдля синхронизации — хрупко и медленно. Используйтеassert_receive/assert_eventually-паттерны, сообщения,start_supervised.- Забыть
verify_on_exit!()с Mox — невыполненные ожидания не проверятся. - Гонка за 100% покрытия — метрика, а не цель.
Источники
- ExUnit, Mox, StreamData
- Elixir School — Testing
- Статья Жозе Валима «Mocks and explicit contracts» (dashbit.co) — почему моки через поведения.
Что дальше
Архитектура и продакшн — контексты, слои, конфигурация и Ecto.