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

时间:2015-10-08 14:40:51

标签: 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,原因如下:

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

  • 我可以在我的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这是一种错误的测试方法,因为所有测试都将针对同一台服务器运行,改变其状态,从而相互搞乱。

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

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

3 个答案:

答案 0 :(得分: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

和测试代码:

# 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

答案 1 :(得分:1)

我很抱歉刚才注意到这个问题并在此过程中做出如此反应。我相信所给出的反应是高质量的。也就是说,我需要在测试线束时提出一些可以帮助你的要点。来自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.

如果不审查底层代码,这似乎意味着在测试文件中使用setup do / end块等于在每次测试之前执行该位代码。只需编写一次就很方便了。

现在完全采用不同的方法,我将使用&#34; doctests&#34;在代码中定义代码和测试。与python doctests类似,我们可以在模块文档中包含测试用例。这些测试用&#34;混合测试&#34;执行。按照规范。但是,测试存在于文档中,并且每次都有明确启动服务器的缺点(与单独测试文件中的设置/执行/结束的隐式方法相反。

从文档中您将看到,可以通过缩进四个空格并放置iex&gt;来在文档块中启动文档测试。命令。

我喜欢@chris meyer的作品。在这里,我将采取他的工作并做一些不同的事情。我将实际测试api函数而不是句柄函数。这是一个品味和风格的问题,我完成了克里斯多次做过的事情。我认为看到doctest形式是很有启发性的,因为它也很常见,并且在复杂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

上述技术有几个优点:

  1. Elixir文档在编译时自动生成
  2. 文档通过&#34;混合测试&#34;命令,所以他们看到了什么 您知道的文档
  3. 您的混音语法与&#34; mix test&#34;
  4. 相同

    我在使用代码编辑器进行格式化时遇到了一些麻烦,所以如果有人想编辑一下,请这样做。

答案 2 :(得分:0)

不确定你的第二个选择是否像这样重用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

无法找到我之前完成此操作的确切代码,因此这是对内存的猜测。