Если вы программируете большой проект, я полагаю, что вы все равно будете использовать сторонние сервисы. Представьте, что вы отправляете SMS через услуги любимого провайдера. Этот код может выглядеть так:

SmsProvider.send_sms(from, to, "message")

В этом случае вы используете API внешнего провайдера для отправки SMS. HTTP-запрос перейдет от вашего приложения к месту назначения и вызовет доставку SMS.

Теперь нам нужно проверить эту ситуацию. Это будет наш самый первый подход:

assert {:ok, %Message{}} = SmsProvider.send_sms(from, to, "message")

Как видите, он будет вести себя так, как если бы он был в производственной среде. Ваше SMS с неприятным поддельным тестовым сообщением будет доставлено несуществующим пользователям с отсутствующими номерами телефонов. Грустный!

Самое популярное решение - издевательство. Вы предполагаете, что вызов send_sms функции с ожидаемыми аргументами вернет ожидаемый результат:

mock(SmsProvider, :send_sms, fn _, _, _ -> {:ok, %Message{status: :sent}} end)
assert {:ok, %Message{status: :sent}} = SmsProvider.send_sms(from, to, "message")

Такой подход будет работать, по крайней мере, для этого теста. Проблема достаточно проста - это не лучшее решение для асинхронных тестов. Мы издевались над SmsProvider, поэтому он будет переопределяться в каждом следующем асинхронном тесте:

use MyApp.DataCase, async: true

# ...

assert {:error, :wrong_phone_number} = SmsProvider.send_sms(wrong_number, to, "message")

Этот тест завершится неудачно, потому что вызов send_sms уже был имитирован в другом тесте ранее. Весь метод имитации не подходит для параллельного тестирования, поэтому нам нужно найти другой для этой цели.

Асинхронное тестирование

Вместо насмешек мы можем попробовать вызвать функцию, которая переопределит sms_send/3. Давайте создадим модуль TestProvider со следующим содержанием:

defmodule TestProvider do
  def send_sms(from, to, message) do
    {:ok, %Message{status: :sent, from: from, to: to, text: message}}
  end
end

Теперь мы можем попробовать использовать этот модуль в качестве адаптера в нашем модуле SmsProvider. Он будет использовать адаптер по умолчанию в средах разработки и производства и будет использовать TestProvider при тестировании:

# config/test.exs
config :my_app, SmsProvider, adapter: TestProvider

# config/config.exs
config :my_app, SmsProvider, adapter: SmsApiService

# sms_provider.ex
defmodule SmsProvider do
  @adapter Application.fetch_env(:my_app, :sms_provider, :adapter)

  defdelegate send_sms(from, to, message), to: @adapter
end

Приступим к тесту:

assert {:ok, %Message{}} = SmsProvider.send_sms(from, to, "message") # true

Теперь он должен работать даже в параллельных тестах. Ваши SMS не будут доставлены ни реальным, ни фальшивым пользователям, ваши деньги будут сохранены, а ваши тесты больше не пострадают.

Использование Mox

Однако есть еще возможности для улучшения.

Mox - относительно новая библиотека, которая решает проблему параллельного тестирования и имитаций. Он следует следующим принципам:

  • Вы можете создавать макеты только на основе поведения
  • Нет динамической генерации макетов модулей, каждый макет должен быть определен в тестах.
  • Полная поддержка параллелизма
  • Использование сопоставления с образцом и функциональных предложений для утверждения

Теперь давайте добавим его в наш список зависимостей:

# mix.exs
def deps do
  [
    {:mox, "~> 0.4", only: :test}
  ]
end

Переписать наши тесты с помощью Mox очень просто. Нам нужно создать поведение поставщика SMS и реализовать его для разных сред:

# sms_provider.ex
defmodule SmsProvider do
  @callback send_sms(String.t, String.t, String.t) :: {:ok, %Message{}} | {:error, :wrong_number}
end

# test_helper.exs
Mox.defmock(SmsProviderMock, for: SmsProvider)

# test.exs

defmodule Test do
  use ExUnit.Case, async: true

  import Mox

  # Make sure mocks are verified when the test exits
  setup :verify_on_exit!

  test "returns message on success" do
    expect SmsProviderMock, :send_sms, fn _, _, _ -> {:ok, %Message{status: :sent}}
    assert {:ok, %{status: :sent}} = SmsProvider.send_sms(from, to, message)
  end
end

Если вам не нужно проверять ожидания в тесте, вы можете попробовать функцию stub_with/2, чтобы заглушить весь модуль:

# sms_provider.ex
defmodule SmsProvider do
  @callback send_sms(String.t, String.t, String.t) :: {:ok, %Message{}} | {:error, :wrong_number}
  @callback sent_sms(String.t) :: [%Message{}]
end

# test_provider.ex
defmodule TestProvider do
  @behaviour SmsProvider
  def send_sms(_from, _to, message), do: {:ok, %Message{status: :sent, message: message}}
  def sent_sms(number) :: [%Message{}]
end

# test.exs
defmock(SmsProviderMock, for: SmsProvider)
stub_with(SmsProviderMock, TestProvider)

Альт! Теперь ваши тесты зеленые, быстрые и блестящие благодаря параллельному тестированию и Mox :)

Вывод

Если вас определенно интересует параллельное тестирование, вы можете прочитать отличную статью от Jose Valim и погрузиться в документацию Mox.

Всем удачного взлома!

Эта статья изначально была размещена на моем собственном сайте.