Как тестировать асинхронный код?

В Elixir тестирование асинхронного кода выполняется с помощью модуля ExUnit, который входит в стандартную библиотеку. Он предоставляет инструменты для управления временем, проверки сообщений между процессами и отслеживания завершения задач. Тестирование асинхронного кода в функциональной и конкурентной среде Elixir требует понимания процессов, сообщений и состояния.

Основные подходы к тестированию асинхронного кода

1. Тестирование сообщений между процессами

Если ваш код использует send/receive, вы можете проверить, было ли сообщение отправлено нужному процессу:

test "отправка сообщения" do
send(self(), :hello)
assert_receive :hello
end
  • send(self(), ...) — имитация отправки сообщения текущему процессу.

  • assert_receive — проверяет, пришло ли сообщение за 100 мс (по умолчанию).

  • Если нужно больше времени: assert_receive :msg, 1000

2. Тестирование с использованием Task

Если используется Task.async/await, то просто вызываем await:

test "асинхронная задача" do
task = Task.async(fn -> 1 + 2 end)
result = Task.await(task)
assert result == 3
end

— Task.await блокирует выполнение до завершения задачи (или выхода по таймауту).

Если задача длительная, можно настроить таймаут:

Task.await(task, 5000) # ждём до 5 секунд

3. Тестирование GenServer

Если ваш модуль — GenServer, можно напрямую вызывать его интерфейс:

test "GenServer ответ" do
{:ok, pid} = MyServer.start_link(\[\])
MyServer.set_value(pid, 42)
assert MyServer.get_value(pid) == 42
end

Иногда нужно проверить внутреннее состояние:

state = :sys.get_state(pid)
assert state == %{value: 42}

— Только для отладки и тестов. В проде лучше не использовать :sys.

4. Проверка send в GenServer

Если GenServer отправляет сообщение в другой процесс:

def handle_cast({:notify, msg}, state) do
send(state.pid, msg)
{:noreply, state}
end

В тесте:

test "уведомление" do
{:ok, pid} = MyServer.start_link(pid: self())
MyServer.notify(:ping)
assert_receive :ping
end

5. Использование setup для подготовки окружения

Если каждый тест должен запускать отдельный процесс, используем setup:

setup do
{:ok, pid} = MyGenServer.start_link(\[\])
%{pid: pid}
end
test "значение сохраняется", %{pid: pid} do
MyGenServer.set(pid, 10)
assert MyGenServer.get(pid) == 10
end

6. Контроль времени с помощью :timer и Process.sleep

Иногда в тесте нужно подождать асинхронную операцию:

test "сообщение придет через 50 мс" do
spawn(fn ->
:timer.sleep(50)
send(self(), :done)
end)
assert_receive :done, 100
end

— assert_receive автоматически ждёт.
— Process.sleep/1 используется редко в тестах, но допустимо при необходимости.

7. Использование capture_log для перехвата логов

Если ваш код логирует асинхронные ошибки, можно проверить логи:

import ExUnit.CaptureLog
test "ошибка логируется" do
log =
capture_log(fn ->
MyModule.do_something_wrong()
end)
assert log =~ "ошибка"
end

8. Контроль порядка выполнения

Если несколько сообщений приходят в разном порядке — используйте assert_receive поочерёдно. Или проверяйте содержимое Mailbox вручную, используя flush.

Пример:

flush_messages = fn ->
receive do
msg -> \[msg | flush_messages.()\]
after
0 -> \[\]
end
end

9. Тестирование процессов, которые могут упасть

Вы можете использовать Process.flag(:trap_exit, true) в тесте, если проверяете выход процесса:

test "процесс завершился" do
Process.flag(:trap_exit, true)
pid = spawn_link(fn -> exit(:normal) end)
assert_receive {:EXIT, ^pid, :normal}
end

Это полезно при тестировании Supervisor'ов и мониторинга.

10. Именованные процессы

Удобно использовать имена процессов для взаимодействия в тестах:

MyServer.start_link(name: :my_server)
MyServer.set_value(:my_server, 5)
assert MyServer.get_value(:my_server) == 5

11. Тестирование с Mox (если есть зависимости)

Если процесс зависит от других модулей (например, внешнего API), используйте Mox для мокирования:

Mox.expect(MyAPI.Mock, :get_data, fn -> {:ok, "response"} end)

12. Тестирование в асинхронном режиме

Модуль ExUnit позволяет запускать тесты параллельно, если они независимы:

ExUnit.start(async: true)

И в файле test/my_test.exs:

defmodule MyTest do
use ExUnit.Case, async: true
end

— Но нельзя использовать общие ресурсы или глобальные имена, иначе получишь race condition.

Асинхронный код в Elixir полностью тестируем за счёт процессов, сообщений, замыканий и инструментов типа Task, assert_receive, capture_log, Mox, ExUnit.Case, setup, spawn, Process.flag и других.