Подтвердить что ты не робот

Какова стратегия идиоматического тестирования для GenServers в Elixir?

Я пишу модуль для запроса онлайн-API погоды. Я решил реализовать его как Приложение с контролируемым GenServer.

Вот код:

defmodule Weather do
  use GenServer

  def start_link() do
    GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
  end

  def weather_in(city, country) do
    GenServer.call(__MODULE__, {:weather_in, city, country_code})
  end

  def handle_call({:weather_in, city, country}) do
    # response = call remote api
    {:reply, response, nil}
  end
end

В моем тесте я решил использовать обратный вызов setup для запуска сервера:

defmodule WeatherTest do
  use ExUnit.Case

  setup do
    {:ok, genserver_pid} = Weather.start_link
    {:ok, process: genserver_pid}
  end

  test "something" do
    # assert something using Weather.weather_in
  end

  test "something else" do
    # assert something else using Weather.weather_in
  end
end

Я решил зарегистрировать GenServer с определенным именем по нескольким причинам:

  • маловероятно, что кому-то понадобится несколько экземпляров

  • Я могу определить публичный API в модуле Weather, который абстрагирует существование базового GenServer. Пользователям не нужно предоставлять PID/Name функции weather_in для связи с базовым GenServer

  • Я могу разместить GenServer под деревом надзора

Когда я запускаю тесты, так как они запускаются одновременно, обратный вызов setup выполняется один раз для каждого теста. Поэтому есть параллельные попытки запустить мой сервер, и он терпит неудачу с {:error, {:already_started, #PID<0.133.0>}}.

Я спросил Slack, есть ли что-нибудь, что я могу с этим поделать. Возможно, есть идиоматическое решение, о котором я не знаю...

Чтобы обобщить обсуждаемые решения, при реализации и тестировании GenServer у меня есть следующие параметры:

  • Не регистрировать сервер с определенным именем, чтобы каждый тест запускал свой собственный экземпляр GenServer. Пользователи сервера могут запускать его вручную, но они должны предоставить его публичному API модуля. Сервер также может быть помещен в дерево контроля, даже с именем, но общедоступный API модуля все равно должен знать, с каким PID разговаривать. Учитывая имя, переданное как параметр, я предполагаю, что они могут найти связанный PID (я полагаю, OTP может это сделать.)

  • Регистрация сервера с определенным именем (как в моих примерах). Теперь может быть только один экземпляр GenServer, тесты должны выполняться последовательно (async: false), и каждый тест должен начинаться с и.

  • Регистрация сервера с определенным именем. Тесты могут выполняться одновременно, если все они выполняются с одним и тем же уникальным экземпляром сервера (с помощью setup_all экземпляр можно запустить только один раз для всего тестового примера). Тем не менее, imho это неправильный подход к тестированию, поскольку все тесты будут выполняться против одного и того же сервера, изменяя его состояние и, следовательно, возиться друг с другом.

Учитывая, что пользователям не нужно создавать несколько экземпляров этого GenServer, я испытываю желание обменять тесты concurrency для простоты и пойти с решением 2.

[Изменить] Попробуйте решение 2, но оно по-прежнему не работает по той же причине :already_started. Я снова прочитал документы о async: false и выяснил, что он предотвращает запуск тестового случая параллельно с другими тестовыми примерами. Он не запускает тесты моего тестового примера последовательно, как я думал. Помогите!

4b9b3361

Ответ 1

Одна важная проблема, которую я отмечаю, заключается в том, что у вас есть неправильная подпись для handle_call, которая должна быть handle_call(args, from, state) (у вас есть только handle_call(args).

Я никогда не использовал его, но те, кого я ищу, могут поклясться, что QuickCheck - это золотой стандарт для действительно тестирования GenServers.

На уровне unit test существует еще одна опция из-за функциональной архитектуры GenServer:

Если вы тестируете методы handle_[call|cast|info] с ожидаемыми сочетаниями аргументов и состояний, вы НЕ должны запускать GenServer: используйте свою тестовую библиотеку для замены OTP и вызовите код модуля, как если бы это была плоская библиотека, Это не будет проверять ваши вызовы функций api, но если вы сохраните их как тонкие методы pass-thru, вы можете свести к минимуму риск.

*, если вы используете отложенные ответы, у вас возникнут некоторые проблемы с этим подходом, но вы, вероятно, сможете их отсортировать с достаточной работой.

Я сделал пару изменений в вашем GenServer:

  • Ваш модуль не использует его, поэтому я сделал его более интересным с точки зрения тестирования, добавив альтернативный веб-сервис premium.
  • Я исправил подпись handle_call
  • Я добавил внутренний модуль состояния для отслеживания состояния. Даже в моем GenServers без состояния, я всегда создаю этот модуль для более позднего, когда я неизбежно добавляю состояние.

Новый модуль:

defmodule Weather do
  use GenServer

  def start_link() do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  def weather_in(city, country) do
    GenServer.call(__MODULE__, {:weather_in, city, country_code})
  end

  def upgrade, do: GenServer.cast(__MODULE__, :upgrade)

  def downgrade, do: GenServer.cast(__MODULE__, :downgrade)

  defmodule State do
    defstruct url: :regular
  end

  def init([]), do: {:ok, %State{}}

  def handle_cast(:upgrade, state) do
    {:noreply, %{state|url: :premium}}
  end
  def handle_cast(:downgrade, state) do
    {:noreply, %{state|url: :regular}}
  end

  # Note the proper signature for handle call:
  def handle_call({:weather_in, city, country}, _from, state) do
    response = case state.url do
      :regular ->
        #call remote api
      :premium ->
        #call premium api
    {:reply, response, state}
  end
end

и код тестирования:

# assumes you can mock away your actual remote api calls
defmodule WeatherStaticTest do
  use ExUnit.Case, async: true

  #these tests can run simultaneously
  test "upgrade changes state to premium" do
    {:noreply, new_state} = Weather.handle_cast(:upgrade, %Weather.State{url: :regular})
    assert new_state.url == :premium
  end
  test "upgrade works even when we are already premium" do
    {:noreply, new_state} = Weather.handle_cast(:upgrade, %Weather.State{url: :premium})
    assert new_state.url == :premium
  end
  # etc, etc, etc...
  # Probably something similar here for downgrade

  test "weather_in using regular" do
    state = %Weather.State{url: :regular}
    {:reply, response, newstate} = Weather.handle_call({:weather_in, "dallas", "US"}, nil, state)
    assert newstate == state   # we aren't expecting changes
    assert response == "sunny and hot"
  end
  test "weather_in using premium" do
    state = %Weather.State{url: :premium}
    {:reply, response, newstate} = Weather.handle_call({:weather_in, "dallas", "US"}, nil, state)
    assert newstate == state   # we aren't expecting changes
    assert response == "95F, 30% humidity, sunny and hot"
  end
  # etc, etc, etc...      
end

Ответ 2

Использовать ExUnit on_exit обратный вызов stop процесс GenServer.

defmodule WeatherTest do
  use ExUnit.Case

  setup do
    {:ok, weather} = Weather.start_link

    on_exit fn ->
      GenServer.stop(weather)
    end

    {:ok, process: weather}
  end
end

Ответ 3

Извиняюсь, что сейчас замечаю этот вопрос и ответ так поздно. Я верю, что полученный ответ имеет высокое качество. Тем не менее, мне нужно сделать пару моментов, которые могут помочь вам при тестировании жгутов. Первое примечание из документации ExUnit.Callbacks, в которой

The setup_all callbacks are invoked once to setup the test 
case before any test is run and all setup callbacks are run 
before each test. No callback runs if the test case has no tests 
or all tests were filtered out.

Без обзора базового кода это означает, что использование блока установки do/end в тестовом файле равносильно выполнению этого бита кода перед каждым тестом. Удобно писать только один раз.

Теперь для другого метода я буду использовать "доктрины" в коде для определения кода и тестов. Подобно доктринам python, мы можем включать тестовые примеры в документацию модуля. Эти тесты выполняются с помощью "теста на смешивание" в соответствии с нормой. Тем не менее, тесты живут в документации и имеют недостаток явного запуска сервера каждый раз (в отличие от неявного метода установки /do/end в отдельном случае тестового файла.

В документации вы увидите, что тест документа может быть инициирован в блоке документа путем отступов четырех пробелов и ввода команды iex > .

Мне нравится работа @chris meyer. Здесь я возьму его работу и немного поразному. Я фактически проверю функции api вместо функций дескриптора. Это вопрос вкуса и стиля, и я сделал то, что сделал Крис много раз. Я просто считаю, что поучительно видеть форму доктрины, поскольку она также довольно распространена, а в случае сложной функции API, которая является простым проходом через нее, очень полезно проверить функцию API. Итак, используя фрагмент Криса, вот что я буду делать.

@doc """
Start our server.

### Example

We assert that start link gives :ok, pid

    iex> Weather.start_link
    {:ok, pid}
"""
def start_link() do
  GenServer.start_link(__MODULE__, [], name: __MODULE__)
end

@doc """
We get the weather with this funciton.

    iex> {:ok, pid} = Weather.start_link
    iex> Weather.in(pid, "some_city", "country_code")
    expected_response
    iex> Weather.in(pid, "some_other_city", "some_other_code")
    different_expected_response
"""
def weather_in(svr, city, country) doc
  GenServer.call(svr, {:weather_in, city, country_code})
end

Вышеупомянутый метод имеет несколько преимуществ:

  • Документация Elixir автоматически генерируется при компиляции
  • Документация передает команду "mix test", поэтому то, что они видят в ваши документы, которые вы знаете, работают
  • Симмедики микса совпадают с "mix test"

У меня возникла небольшая проблема с форматированием с помощью редактора кода, поэтому, если кто-то хочет немного изменить это, сделайте это.

Ответ 4

Не уверен, что ваш второй вариант состоял в том, чтобы повторно использовать pid, как это, или если он специально полагался на выполнение последовательно; но вы должны иметь возможность повторно использовать pid следующим образом:

setup do
  genserver_pid = case Progress.whereis(:weather) do
    nil -> 
      {:ok, pid} = Weather.start_link
      Progress.register(pid, :weather)
      pid
    pid -> pid
  end

  {:ok, process: genserver_pid}
end

Невозможно найти точный код, где я делал это раньше, так что это пример из памяти.