Elixir中GenServer的惯用测试策略是什么? [英] What is the idiomatic testing strategy for GenServers in Elixir?

查看:60
本文介绍了Elixir中GenServer的惯用测试策略是什么?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

我正在编写一个查询在线天气API的模块。我决定将其实现为带有受监督的 GenServer 的应用程序。



这是代码:

  defmodule Weather do 
使用GenServer

def start_link()执行
GenServer.start_link( __MODULE__,:ok,名称:__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

在我的测试中,我决定使用设置回调以启动服务器:

  defmodule WeatherTest做
使用ExUnit.Case

设置执行
{:ok,genserver_pid} = Weather.start_link
{:ok,进程:genserver_pid}
end

test事情做
#使用Weather.weather_in
断言结束

测试其他来做
#使用Weather.weather_in断言其他内容
结束
结束

出于以下几个原因,我决定使用特定名称注册 GenServer




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


  • 我可以在我的中定义公共API Weather 模块,用于抽象底层 GenServer 的存在。用户无需为 weather_in 函数提供PID /名称即可与基础 GenServer


  • 我可以将我的 GenServer 放在监督树下




运行测试时,由于它们同时运行,因此 setup 回调每个测试执行一次。因此,存在并发尝试来启动服务器的尝试,但服务器失败并显示 {:error,{:already_started,#PID< 0.133.0>}}



我在Slack上问我是否可以做些什么。也许有我不知道的惯用解决方案...



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


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


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


  3. 使用特定名称注册服务器。如果测试都针对同一个唯一的服务器实例运行,则测试可以同时运行(使用 setup_all ,对于整个测试用例,一个实例只能启动一次)。但是,恕我直言,这是一种错误的测试方法,因为所有测试都将在同一台服务器上运行,从而更改其状态,从而导致彼此混乱。


考虑到用户可能不需要创建该GenServer的多个实例,为了简化起见,我倾向于使用测试并发性并选择解决方案2。




尝试解决方案2,但由于:already_started 的相同原因仍然失败。我再次阅读有关 async:false 的文档,发现它阻止了测试用例与其他测试用例并行运行。它没有按我所想的顺序运行测试用例的测试。
帮助!

解决方案

我注意到的一个关键问题是的签名错误handle_call ,应该为 handle_call(参数,来自州)(您目前只有 handle_call(参数)



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



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



如果您测试<$ c具有预期参数和状态组合的$ c> handle_ [call | cas​​t | info] 方法,您不必*必须启动GenServer:使用测试库替换OTP,并调出模块代码,就好像它是一个平面库一样。这不会测试您的api函数调用,但是如果将它们作为瘦传递方法使用,则可以最大程度地降低风险。



*如果您使用延迟回复,则此ap会有一些问题技巧,但您可能可以通过足够的工作来解决它们。



我对您的GenServer进行了几处更改:




  • 您的模块未使用其状态,因此从测试的角度来看,我通过添加其他高级Web服务使其变得更加有趣。

  • 我更正了handle_call签名

  • 我添加了一个内部状态模块来跟踪状态。即使在我的无状态的GenServer上,我也总是在以后不可避免地添加状态时创建此模块。



新模块:

  defmodule Weather do 
使用GenServer

def start_link()执行
GenServer .start_link(__ MODULE__,[],名称:__MODULE__)
end

def weather_in(city,country)do
GenServer.call(__ MODULE__,{:weather_in,city,country_code })
结束

def升级,执行:GenServer.cast(__ MODULE__,:upgrade)

def降级,执行:GenServer.cast(__ MODULE__,:downgrade )

defmodule State do
defstruct url::regular
end

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

def handle_cast(:upgrade,state)做
{:noreply,%{state | url::premium}}
end
def handle_cast(:降级,状态)做
{:noreply,%{state | url::regular}}
end

#注意句柄调用的正确签名:
def处理e_call({:weather_in,city,country},_from,state)做
响应= case state.url做
:regular->
#call远程api
:premium->
#call premium api
{:reply,response,state}
结束
结束

和测试代码:

 #假定您可以嘲笑实际的远程api调用
defmodule WeatherStaticTest做
使用ExUnit.Case,异步:true

#这些测试可以同时运行
测试将状态升级为高级做
{ :noreply,new_state} = Weather.handle_cast(:upgrade,%Weather.State {url::regular})
assert new_state.url ==:premium
end
测试即使升级也可以当我们已经很高级时
{:noreply,new_state} = Weather.handle_cast(:upgrade,%Weather.State {url::premium})
assert new_state.url ==:premium
结束
#等,等等,等等...
#可能在这里类似的降级

测试 weather_in using Regular做
state =%Weather .State {url::regular}
{:reply,response,newstate} = Weather.handle_call({:weather _in, dallas, US},无,状态)
assert newstate == state#我们不期望更改
assert response ==晴朗而炎热
end
测试 weather_in using premium做
state =%Weather.State {url::premium}
{:reply,response,newstate} = Weather.handle_call({:weather_in, dallas , US},零,州)
断言newstate ==状态#我们不期望更改
断言响应== 95F,湿度30%,晴天和高温
结束
#等,等等,等等...
结束


I am writing a module to query an online weather API. I decided to implement it as an Application with a supervised GenServer.

Here is the code:

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

In my test I decided to use a setup callback to start the server:

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

I decided to register the GenServer with a specific name for several reasons:

  • it is unlikely that someone would need multiple instances

  • I can define a public API in my Weather module that abstracts the existence of an underlying GenServer. Users won't have to provide a PID/Name to the weather_in function to communicate with the underlying GenServer

  • I can place my GenServer under a supervision tree

When I run the tests, as they run concurrently, the setup callback is executed once per test. Therefore there are concurrent attempts to start my server and it fails with {:error, {:already_started, #PID<0.133.0>}}.

I asked on Slack if there is anything I can do about it. Perhaps there is an idiomatic solution that I am not aware of...

To summarise the solutions discussed, when implementing and testing a GenServer, I have the following options:

  1. Not registering the server with a specific name to let each test start its own instance of the GenServer. Users of the server can start it manually but they must provide it to the public API of the module. The server can also be placed in a supervision tree, even with a name but the public API of the module will still need to know which PID to talk to. Given a name passed as a parameter, I guess they could find the associated PID (I suppose OTP can do that.)

  2. Registering the server with a specific name (like I did in my samples). Now there can be only one GenServer instance, tests must run sequentially (async: false) and each test must start and terminate the server.

  3. Registering the server with a specific name. Tests can run concurrently if they all run against the same unique server instance (Using setup_all, an instance can be started only once for the whole test case). Yet, imho this is a wrong approach to testing as all tests will run against the same server, changing its state and therefore messing with each other.

Considering the users may not need to create several instances of this GenServer, I'm tempted to trade the tests concurrency for simplicity and go with solution 2.

[Edit] Trying solution 2 but it still fails for the same reason :already_started. I read again the docs about async: false and found out that it prevents the test case from running in parallel with other test cases. It doesn't run the tests of my test case in sequence as I thought. Help!

解决方案

One crucial problem I note is that you have the wrong signature for handle_call, which should be handle_call(args, from, state) (you currently have just handle_call(args).

I've never used it, but those I look up to swear that QuickCheck is the gold standard for really testing GenServers.

At the unit test level, another option exists because of the functional architecture of GenServer:

If you test the handle_[call|cast|info] methods with expected argument and state combinations, you do NOT* have to start the GenServer: use your testing library to replace OTP, and call out to your module code as if it were a flat library. This won't test your api function calls, but if you keep those as thin pass-thru methods, you can minimize the risk.

*if you are using delayed replies, you'll have some problems with this approach, but you can probably sort them out with enough work.

I've made a couple changes to your GenServer:

  • Your module doesn't use it's state, so I've made it more interesting from a testing perspective by adding an alternative premium webservice.
  • I corrected the handle_call signature
  • I added an internal State module to track state. Even on my GenServers without state, I always create this module for later, when I inevitably add state in.

The new module:

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

and the testing code:

# 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

这篇关于Elixir中GenServer的惯用测试策略是什么?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆