【问题标题】:What is the idiomatic testing strategy for GenServers in Elixir?Elixir 中 GenServer 的惯用测试策略是什么?
【发布时间】:2016-01-06 06:17:43
【问题描述】:

我正在编写一个模块来查询在线天气 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,对于整个测试用例,一个实例只能启动一次)。然而,恕我直言,这是一种错误的测试方法,因为所有测试都将针对同一台服务器运行,从而改变其状态并因此相互混淆。

考虑到用户可能不需要创建这个 GenServer 的多个实例,我很想为了简单而放弃测试并发并采用解决方案 2。

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

【问题讨论】:

    标签: elixir


    【解决方案1】:

    我注意到的一个关键问题是您的handle_call 签名错误,应该是handle_call(args, from, state)(您目前只有handle_call(args)

    我从未使用过它,但我仰慕的人发誓 QuickCheck 是真正测试 GenServer 的黄金标准。

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

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

    *如果您使用延迟回复,这种方法会出现一些问题,但您可以通过足够的工作来解决它们。

    我对您的 GenServer 进行了一些更改:

    • 您的模块没有使用它的状态,所以我通过添加一个替代的高级 Web 服务从测试的角度使它更有趣。
    • 我更正了 handle_call 签名
    • 我添加了一个内部状态模块来跟踪状态。即使在没有状态的 GenServer 上,我总是会创建这个模块以供以后不可避免地添加状态。

    新模块:

    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
    

    【讨论】:

    • "如果您使用预期的参数和状态组合测试 handle_[call|cast|info] 函数,则不必*必须启动 GenServer" 和 "这不会测试您的 api 函数调用,但如果您将这些保留为精简的直通方法,您可以将风险降到最低”是一个有趣的解决方案。
    【解决方案2】:

    对于刚刚在此过程中这么晚才注意到这个问题和回复,我深表歉意。我确实相信给出的回应是高质量的。就是说,我需要提出几点可以在您进行测试工具时对您有所帮助。 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 块相当于在每次测试之前执行那段代码。只需要写一次就很方便了。

    现在完全不同的方法,我将在代码中使用“doctests”来定义代码和测试。与 python doctests 类似,我们可以在模块文档中包含测试用例。这些测试按照规范使用“混合测试”执行。但是,测试存在于文档中,并且存在每次显式启动服务器的缺点(与单独的测试文件案例中的 setup/do/end 隐式方法相反。

    从文档中您会看到,可以通过缩进四个空格并输入 iex> 命令在文档块中启动文档测试。

    我喜欢@chris meyer 的作品。在这里,我将接受他的工作并做一些不同的事情。我将实际测试 api 函数而不是句柄函数。这是品味和风格的问题,我已经做了克里斯多次做过的事情。我只是认为查看 doctest 表单是有启发性的,因为它也很常见,并且在复杂的 API 函数不是简单传递的情况下,测试 API 函数本身很有价值。所以,使用 Chris 的 sn-p,这就是我要做的。

    @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. 文档通过了“混合测试”命令,所以他们在 您知道的文档有效
    3. 您的混合语义与“混合测试”相同

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

    【讨论】:

    • 我想指出——为了避免混淆——仍然需要在test 目录中编写一个模块,以便使用带有mix test 的文档测试。在该模块中,您必须使用 doctest 指令,如下所述:elixir-lang.org/getting-started/mix-otp/…
    【解决方案3】:

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

    找不到我之前做过的确切代码,所以这是凭记忆推测的。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2016-01-23
      • 2011-01-19
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2018-12-14
      • 1970-01-01
      • 2015-10-23
      相关资源
      最近更新 更多