Elixir Тестирование в Elixir: ExUnit, async, doctests, Mox и property-based со StreamData
0%

Тестирование в Elixir: ExUnit, async, doctests, Mox и property-based со StreamData

Тестирование в 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% покрытия — метрика, а не цель.

Источники

Что дальше

Архитектура и продакшн — контексты, слои, конфигурация и Ecto.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов