Elixir中GenServers的惯用测试策略是什么?

sva*_*let 23 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
Run Code Online (Sandbox Code Playgroud)

在我的测试中,我决定使用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
Run Code Online (Sandbox Code Playgroud)

我决定GenServer使用特定名称注册,原因如下:

  • 某人不太可能需要多个实例

  • 我可以在我的Weather模块中定义一个公共API,它抽象出底层的存在GenServer.用户不必向weather_in函数提供PID /名称以与底层通信GenServer

  • 我可以将我GenServer置于监督树下

当我运行测试时,当它们同时运行时,setup每次测试都会执行一次回调.因此,有并发尝试启动我的服务器,它失败了{:error, {:already_started, #PID<0.133.0>}}.

我问Slack是否有任何我可以做的事情.也许有一个我不知道的惯用解决方案......

总结所讨论的解决方案,在实现和测试时GenServer,我有以下选择:

  1. 不使用特定名称注册服务器以使每个测试启动其自己的GenServer实例.服务器的用户可以手动启动它,但是他们必须将它提供给模块的公共API.服务器也可以放在监督树中,即使有名称,但模块的公共API仍然需要知道要与哪个PID通信.给定一个名称作为参数传递,我猜他们可以找到相关的PID(我想OTP可以做到这一点.)

  2. 使用特定名称注册服务器(就像我在我的示例中所做的那样).现在只能有一个GenServer实例,测试必须按顺序运行(async: false),每个测试必须启动终止服务器.

  3. 使用特定名称注册服务器.如果测试都针对相同的唯一服务器实例运行,则测试可以并发运行(使用setup_all,对于整个测试用例,实例只能启动一次).然而,imho这是一种错误的测试方法,因为所有测试都将针对同一台服务器运行,改变其状态,从而相互搞乱.

考虑到用户可能不需要创建这个GenServer的多个实例,我很想简单地交换测试并发性并使用解决方案2.

[编辑]尝试解决方案2,但由于同样的原因它仍然失败:already_started.我再次阅读文档async: false,发现它可以防止测试用例与其他测试用例并行运行.它没有像我想象的那样按顺序运行我的测试用例的测试.救命!

Chr*_*yer 25

我注意到的一个关键问题是你有错误的签名handle_call,应该是handle_call(args, from, state)(你现在只有handle_call(args).

我从来没有使用它,但是我发誓,QuickCheck是真正测试GenServers的黄金标准.

在单元测试级别,由于GenServer的功能架构,存在另一种选择:

如果handle_[call|cast|info]使用期望的参数和状态组合测试方法,则不必启动GenServer:使用测试库替换OTP,并调用模块代码,就像它是一个平面库一样.这不会测试你的api函数调用,但是如果你将它们保持为瘦的pass-thru方法,你可以将风险降到最低.

*如果您使用延迟回复,这种方法会遇到一些问题,但您可以用足够的工作对它们进行排序.

我对GenServer进行了一些更改:

  • 您的模块不使用它的状态,所以我从测试的角度通过添加替代的高级Web服务使其变得更有趣.
  • 我更正了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
Run Code Online (Sandbox Code Playgroud)

和测试代码:

# 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
Run Code Online (Sandbox Code Playgroud)

  • “如果您使用预期的参数和状态组合测试 `handle_[call|cast|info]` 函数,则您不必*启动 `GenServer`”和“这不会测试您的 api 函数调用,但如果您将这些保留为精简传递方法,可以最大限度地降低风险”是一个有趣的解决方案。 (2认同)