如何为演员编写单元测试? SObjectizer方法

Actor通过避免共享的,共享的可变状态来简化多线程编程。 每个演员都拥有自己的数据,任何人都看不到。 Actor仅通过异步消息进行交互。 因此,使用actor时以种族和死锁的形式出现的最令人恐惧的多线程恐怖并不可怕(尽管actor遇到了麻烦,但这不是现在所要解决的)。

通常,使用actor编写多线程应用程序既容易又愉快。 包括因为演员本身写得轻松自然。 您甚至可以说编写演员代码是工作中最简单的部分。 但是当演员被写作时,一个很好的问题出现了:“如何检查作品的正确性?”

这个问题真的很好。 我们经常被问及何时谈论一般的参与者 ,特别是关于SObjectizer的问题。 直到最近,我们只能笼统地回答这个问题。

但是发布了5.5.24版本 ,其中对参与者进行单元测试的可能性提供了实验性支持。 在本文中,我们将尝试讨论它是什么,如何使用它以及实现它。

演员测试是什么样的?


我们将通过几个示例考虑SObjectizer的新功能,并介绍什么是什么。 可以在此存储库中找到所讨论示例的源代码。

在整个故事中,术语“演员”和“代理人”将互换使用。 它们指定相同的内容,但是在SObjectizer中,术语“代理”在历史上一直使用,因此,更多的“代理”将被更频繁地使用。

Pinger和Ponger的最简单示例


演员Pinger和Ponger的例子可能是演员框架最常见的例子。 可以说是经典。 好吧,如果是这样,那么让我们从经典开始。

因此,我们有一个Pinger代理,在其工作开始时,它将Ping消息发送给Ponger代理。 然后,Ponger代理会发回Pong消息。 这就是C ++代码中的样子:

// Types of signals to be used. struct ping final : so_5::signal_t {}; struct pong final : so_5::signal_t {}; // Pinger agent. class pinger_t final : public so_5::agent_t { so_5::mbox_t m_target; public : pinger_t( context_t ctx ) : so_5::agent_t{ std::move(ctx) } { so_subscribe_self().event( [this](mhood_t<pong>) { so_deregister_agent_coop_normally(); } ); } void set_target( const so_5::mbox_t & to ) { m_target = to; } void so_evt_start() override { so_5::send< ping >( m_target ); } }; // Ponger agent. class ponger_t final : public so_5::agent_t { so_5::mbox_t m_target; public : ponger_t( context_t ctx ) : so_5::agent_t{ std::move(ctx) } { so_subscribe_self().event( [this](mhood_t<ping>) { so_5::send< pong >( m_target ); } ); } void set_target( const so_5::mbox_t & to ) { m_target = to; } }; 

我们的任务是编写一个测试,以验证在向SObjectizer注册这些代理后,Ponger将收到Ping消息,而Pinger将收到Pong消息作为响应。

好啦 我们使用doctest单元测试框架编写了这样的测试,并获得:

 #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include <doctest/doctest.h> #include <ping_pong/agents.hpp> #include <so_5/experimental/testing.hpp> namespace tests = so_5::experimental::testing; TEST_CASE( "ping_pong" ) { tests::testing_env_t sobj; pinger_t * pinger{}; ponger_t * ponger{}; sobj.environment().introduce_coop([&](so_5::coop_t & coop) { pinger = coop.make_agent< pinger_t >(); ponger = coop.make_agent< ponger_t >(); pinger->set_target( ponger->so_direct_mbox() ); ponger->set_target( pinger->so_direct_mbox() ); }); sobj.scenario().define_step("ping") .when(*ponger & tests::reacts_to<ping>()); sobj.scenario().define_step("pong") .when(*pinger & tests::reacts_to<pong>()); sobj.scenario().run_for(std::chrono::milliseconds(100)); REQUIRE(tests::completed() == sobj.scenario().result()); } 

这似乎很容易。 让我们看看这里发生了什么。

首先,我们下载代理测试支持工具的描述:

 #include <so_5/experimental/testing.hpp> 

所有这些工具都在so_5 ::实验::测试名称空间中进行了描述,但是为了不重复这么长的名称,我们引入了一个更简短,更方便的别名:

 namespace tests = so_5::experimental::testing; 

以下是对单个测试用例的描述(此处我们不需要更多内容)。

在测试用例中,有几个关键点。

首先,这是为SObjectizer创建和启动一个特殊的测试环境:

 tests::testing_env_t sobj; 

没有这种环境,代理的“测试运行”将无法完成,但是稍后我们将讨论。

testing_env_t类与SObjectizer中的wrapped_env_t类非常相似。 同样,SObjectizer在构造函数中启动,在析构函数中停止。 因此,在编写测试时,您不必考虑启动和停止SObjectizer。

接下来,我们需要创建并注册Pinger和Ponger代理。 在这种情况下,我们需要使用这些代理来确定所谓的。 “测试方案。” 因此,我们分别存储指向代理的指针:

 pinger_t * pinger{}; ponger_t * ponger{}; sobj.environment().introduce_coop([&](so_5::coop_t & coop) { pinger = coop.make_agent< pinger_t >(); ponger = coop.make_agent< ponger_t >(); pinger->set_target( ponger->so_direct_mbox() ); ponger->set_target( pinger->so_direct_mbox() ); }); 

然后,我们开始使用“测试方案”。

测试用例是由直接的步骤序列组成的部分,这些步骤必须从头到尾完成。 短语“来自直接序列”表示在SObjectizer-5.5.24中,脚本严格按顺序执行“工作”,没有任何分支或循环。

为代理编写测试是需要执行的测试脚本的定义。 即 从最开始到最后,测试方案的所有步骤都应该起作用。

因此,在我们的测试案例中,我们定义了一个两步方案。 第一步,验证Ponger代理将接收并处理Ping消息:

 sobj.scenario().define_step("ping") .when(*ponger & tests::reacts_to<ping>()); 

第二步检查Pinger代理是否收到Pong消息:

 sobj.scenario().define_step("pong") .when(*pinger & tests::reacts_to<pong>()); 

这两个步骤对于我们的测试用例已经足够了,因此,在确定了它们之后,我们继续执行脚本。 我们运行脚本,并允许其运行不超过100毫秒:

 sobj.scenario().run_for(std::chrono::milliseconds(100)); 

对于两个代理交换消息而言,一百毫秒应该绰绰有余(即使测试是在非常慢的虚拟机中运行的,例如Travis CI有时也是如此)。 好吧,如果我们在编写代理时犯了一个错误或错误地描述了一个测试脚本,那么等待一个错误脚本的完成超过100毫秒是没有意义的。

因此,从run_for()返回之后,我们的脚本可以成功完成,也可以不成功完成。 因此,我们只需检查脚本的结果:

 REQUIRE(tests::completed() == sobj.scenario().result()); 

如果脚本未成功完成,则将导致我们的测试用例失败。

一些说明和补充


如果我们在普通的SObjectizer中运行以下代码:

 pinger_t * pinger{}; ponger_t * ponger{}; sobj.environment().introduce_coop([&](so_5::coop_t & coop) { pinger = coop.make_agent< pinger_t >(); ponger = coop.make_agent< ponger_t >(); pinger->set_target( ponger->so_direct_mbox() ); ponger->set_target( pinger->so_direct_mbox() ); }); 

然后,很可能Pinger和Ponger代理将设法交换消息并完成工作,然后再从Introduction_coop返回(多线程的奇迹就是这样)。 但是在由testing_env_t创建的测试环境中,这种情况不会发生,Pinger和Ponger代理耐心地等待直到我们运行测试脚本。 这是怎么发生的?

事实是,在测试环境中,代理似乎处于冻结状态。 即 注册后,它们出现在SObjectizer中,但是它们无法处理任何消息。 因此,在运行测试脚本之前,甚至没有为代理调用so_evt_start()。

当我们使用run_for()方法运行测试脚本时,测试脚本首先会解冻所有冻结的代理。 然后,脚本开始从SObjectizer接收有关代理发生什么情况的通知。 例如,Ponger代理收到Ping消息,而Ponger代理处理了该消息,但没有拒绝它。

当此类通知开始出现在测试脚本中时,脚本会尝试将其“尝试”到第一步。 因此,我们收到了Ponger接收并处理了Ping的通知-对我们来说是否有趣? 事实证明,这很有趣,因为该步骤的描述恰好说明了这一点:当Ponger对Ping做出反应时,它就起作用了。 我们在代码中看到的是:

 .when(*ponger & tests::reacts_to<ping>()) 

好啦 因此,第一步成功了,请转到下一步。

接下来是一条通知,告知特工Pinger对Pong做出了反应。 这就是第二步工作所需的内容:

 .when(*pinger & tests::reacts_to<pong>()) 

好啦 所以第二步成功了,我们还有其他东西吗? 不行 这意味着整个测试脚本已完成,您可以从run_for()返回控制。

原则上,这里是测试脚本的工作方式。 实际上,一切都有些复杂,但是当我们考虑一个更复杂的示例时,我们将涉及更复杂的方面。

餐饮哲学家榜样


在解决众所周知的任务“吃饭的哲学家”中,可以看到测试代理的更复杂的例子。 在演员上,可以通过多种方式解决此问题。 接下来,我们将考虑最简单的解决方案:演员和哲学家都以演员的形式代表,而哲学家必须为此而斗争。 每个哲学家思考了一会儿,然后尝试拿起左边的叉子。 如果成功,他将尝试在右边进行分叉。 如果成功,那么哲学家会吃上一段时间,然后放下叉子开始思考。 如果无法将插头插入右侧(即由另一位哲学家使用),则哲学家将插头返回左侧,并思考了更多时间。 即 从某种哲学家可能饿死太久的意义上来说,这不是一个好的解决方案。 但这很简单。 并具有证明代理商测试能力的范围。

可以在此处找到使用Fork和Philosopher代理实现的源代码,在本文中,我们不会将它们视为节省空间。

测试叉


餐饮哲学家对代理商的第一个测试将是对叉子的代理商。

该代理根据一个简单的方案工作。 他有两种状态:“自由”和“已采取”。 当代理处于“空闲”状态时,它会响应“接受”消息。 在这种情况下,代理将进入“已接受”状态,并以“已接受”响应消息进行响应。

座席处于“接受”状态时,它对“接受”消息的响应有所不同:座席的状态不变,并且“忙”作为响应消息发送。 同样在“采用”状态下,代理对“放置”消息作出响应:代理返回“空闲”状态。

在“释放”状态下,“放置”消息将被忽略。

我们将尝试通过以下测试案例来测试这一点:

 TEST_CASE( "fork" ) { class pseudo_philosopher_t final : public so_5::agent_t { public: pseudo_philosopher_t(context_t ctx) : so_5::agent_t{std::move(ctx)} { so_subscribe_self() .event([](mhood_t<msg_taken>) {}) .event([](mhood_t<msg_busy>) {}); } }; tests::testing_env_t sobj; so_5::agent_t * fork{}; so_5::agent_t * philosopher{}; sobj.environment().introduce_coop([&](so_5::coop_t & coop) { fork = coop.make_agent<fork_t>(); philosopher = coop.make_agent<pseudo_philosopher_t>(); }); sobj.scenario().define_step("put_when_free") .impact<msg_put>(*fork) .when(*fork & tests::ignores<msg_put>()); sobj.scenario().define_step("take_when_free") .impact<msg_take>(*fork, philosopher->so_direct_mbox()) .when_all( *fork & tests::reacts_to<msg_take>() & tests::store_state_name("fork"), *philosopher & tests::reacts_to<msg_taken>()); sobj.scenario().define_step("take_when_taken") .impact<msg_take>(*fork, philosopher->so_direct_mbox()) .when_all( *fork & tests::reacts_to<msg_take>(), *philosopher & tests::reacts_to<msg_busy>()); sobj.scenario().define_step("put_when_taken") .impact<msg_put>(*fork) .when( *fork & tests::reacts_to<msg_put>() & tests::store_state_name("fork")); sobj.scenario().run_for(std::chrono::milliseconds(100)); REQUIRE(tests::completed() == sobj.scenario().result()); REQUIRE("taken" == sobj.scenario().stored_state_name("take_when_free", "fork")); REQUIRE("free" == sobj.scenario().stored_state_name("put_when_taken", "fork")); } 

有很多代码,因此我们将分部分处理,跳过那些本应清晰的片段。

我们需要做的第一件事是替换真正的Philosopher代理。 Fork代理必须从某人接收消息并作出响应。 但是我们不能在这个测试案例中使用真正的Philosopher,因为真正的Philosopher代理具有自己的行为逻辑,他自己发送消息,并且这种独立性会在这里干扰我们。

因此,我们进行模拟 ,即 我们将代替它来代替真正的哲学家:一个空代理,它本身不发送任何内容,而仅接收已发送的消息,而没有进行任何有用的处理。 这是在代码中实现的伪哲学家:

 class pseudo_philosopher_t final : public so_5::agent_t { public: pseudo_philosopher_t(context_t ctx) : so_5::agent_t{std::move(ctx)} { so_subscribe_self() .event([](mhood_t<msg_taken>) {}) .event([](mhood_t<msg_busy>) {}); } }; 

接下来,我们从Fork代理和PseudoPhilospher代理创建协作,并开始确定测试用例的内容。

脚本的第一步是验证处于“空闲”状态(这是其初始状态)的“叉”是否不响应“放置”消息。 这是这种支票的写法:

 sobj.scenario().define_step("put_when_free") .impact<msg_put>(*fork) .when(*fork & tests::ignores<msg_put>()); 

引起注意的第一件事是冲击的构造。

需要她是因为我们的代理商Fork自己什么也不做,他只对收到的消息做出反应。 因此,有人应该向代理发送消息。 可是谁

但是脚本步骤本身会传递影响。 实际上,影响与通常的发送功能类似(格式相同)。

好吧,脚本步骤本身将通过影响发送消息。 但是他什么时候会做呢?

轮到他时,他会这样做。 即 如果脚本中的步骤是第一步,则在输入run_for之后立即执行影响。 如果脚本中的步骤不是第一步,则将在上一步执行后立即执行影响,脚本将继续处理下一步。

我们需要在这里讨论的第二件事是调用忽略。 此辅助函数表示,当代理处理消息时将触发该步骤。 即 在这种情况下,Fork代理必须拒绝处理Put消息。

让我们更详细地考虑测试场景的另一步骤:

 sobj.scenario().define_step("take_when_free") .impact<msg_take>(*fork, philosopher->so_direct_mbox()) .when_all( *fork & tests::reacts_to<msg_take>() & tests::store_state_name("fork"), *philosopher & tests::reacts_to<msg_taken>()); 

首先,在这里我们看到when_all而不是when。 这是因为要触发一个步骤,我们需要立即满足多个条件。 fork代理需要处理Take。 哲学家需要处理“采取”的回应。 因此,我们编写when_all而不是when。 顺便说一下,还有when_any,但是在今天考虑的例子中我们将不与他见面。

其次,我们还需要检查以下事实:在执行Take处理后,Fork代理将处于Taken状态。 我们按以下方式进行验证:首先,我们指出,当Fork代理完成处理Take时,应使用标签标签“ fork”保存其当前状态的名称。 此构造仅保留代理的状态名称:

 & tests::store_state_name("fork") 

然后,当脚本成功完成时,我们检查此保存的名称:
 REQUIRE("taken" == sobj.scenario().stored_state_name("take_when_free", "fork")); 

即 我们询问脚本:给我们提供与fork标签一起保存的名称,名为步骤take_when_free,然后将其与期望值进行比较。

在这里,也许是Fork代理的测试用例中可以说明的所有内容。 如果读者有任何疑问,请在评论中提问,我们将很高兴为您解答。

哲学家的成功脚本测试


对于Philosopher代理,我们将仅考虑一个测试用例-对于Philosopher可以同时使用叉子和进餐的情况。

该测试用例将如下所示:

 TEST_CASE( "philosopher (takes both forks)" ) { tests::testing_env_t sobj{ [](so_5::environment_params_t & params) { params.message_delivery_tracer( so_5::msg_tracing::std_cout_tracer()); } }; so_5::agent_t * philosopher{}; so_5::agent_t * left_fork{}; so_5::agent_t * right_fork{}; sobj.environment().introduce_coop([&](so_5::coop_t & coop) { left_fork = coop.make_agent<fork_t>(); right_fork = coop.make_agent<fork_t>(); philosopher = coop.make_agent<philosopher_t>( "philosopher", left_fork->so_direct_mbox(), right_fork->so_direct_mbox()); }); auto scenario = sobj.scenario(); scenario.define_step("stop_thinking") .when( *philosopher & tests::reacts_to<philosopher_t::msg_stop_thinking>() & tests::store_state_name("philosopher") ) .constraints( tests::not_before(std::chrono::milliseconds(250)) ); scenario.define_step("take_left") .when( *left_fork & tests::reacts_to<msg_take>() ); scenario.define_step("left_taken") .when( *philosopher & tests::reacts_to<msg_taken>() & tests::store_state_name("philosopher") ); scenario.define_step("take_right") .when( *right_fork & tests::reacts_to<msg_take>() ); scenario.define_step("right_taken") .when( *philosopher & tests::reacts_to<msg_taken>() & tests::store_state_name("philosopher") ); scenario.define_step("stop_eating") .when( *philosopher & tests::reacts_to<philosopher_t::msg_stop_eating>() & tests::store_state_name("philosopher") ) .constraints( tests::not_before(std::chrono::milliseconds(250)) ); scenario.define_step("return_forks") .when_all( *left_fork & tests::reacts_to<msg_put>(), *right_fork & tests::reacts_to<msg_put>() ); scenario.run_for(std::chrono::seconds(1)); REQUIRE(tests::completed() == scenario.result()); REQUIRE("wait_left" == scenario.stored_state_name("stop_thinking", "philosopher")); REQUIRE("wait_right" == scenario.stored_state_name("left_taken", "philosopher")); REQUIRE("eating" == scenario.stored_state_name("right_taken", "philosopher")); REQUIRE("thinking" == scenario.stored_state_name("stop_eating", "philosopher")); } 

相当大,但微不足道。 首先,检查哲学家已经完成思考并开始准备食物。 然后我们检查他是否尝试过左叉。 接下来,他应该尝试拿正确的叉子。 然后他应该吃饭并停止这项活动。 然后他必须把两个叉子都拿走。

总的来说,一切都很简单。 但是您应该专注于两件事。

首先,testing_env_t类(如其原型wrapped_env_t)允许您自定义SObjectizer Environment。 我们将使用它来启用消息传递跟踪机制:

 tests::testing_env_t sobj{ [](so_5::environment_params_t & params) { params.message_delivery_tracer( so_5::msg_tracing::std_cout_tracer()); } }; 

该机制使您可以“可视化”消息传递过程,这有助于调查代理行为(我们已经详细讨论过)。

其次,代理哲学家不是立即而是在一段时间后执行一系列动作。 因此,代理开始工作后,必须向自己发送未决的StopThinking消息。 因此,此消息应在几毫秒后到达代理。 我们通过为某个步骤设置必要的限制来表明这一点:

 scenario.define_step("stop_thinking") .when( *philosopher & tests::reacts_to<philosopher_t::msg_stop_thinking>() & tests::store_state_name("philosopher") ) .constraints( tests::not_before(std::chrono::milliseconds(250)) ); 

即 在这里,我们说我们对Philosopher代理对StopThinking的任何反应不感兴趣,而只对开始执行此步骤后不超过250毫秒的反应感兴趣。

not_before类型的限制告诉脚本,应忽略在指定的超时到期之前发生的所有事件。

还有一个形式为not_after的限制,它以另一种方式起作用:仅考虑在指定的超时时间到期之前发生的那些事件。

可以组合not_before和not_after约束,例如:

 .constraints( tests::not_before(std::chrono::milliseconds(250)), tests::not_after(std::chrono::milliseconds(1250))) 

但是在这种情况下,SObjectizer不会检查给定值的一致性。

您如何实现这一目标?


我想谈一谈所有工作原理。 毕竟,总的来说,我们面临着一个重大的意识形态问题:“如何从原则上测试代理商?” 还有一个较小的问题,已经是技术性的:“如何实施?”

而且,如果关于测试意识形态有可能会被您遗忘,那么对于实现而言,情况就更加复杂了。 有必要找到一种解决方案,该解决方案首先不需要对SObjectizer的内部进行彻底的更改。 其次,它被认为是可以在可预见且非常理想的短时间内实施的解决方案。

由于抽烟过程很困难,因此找到了解决方法。 为此,实际上只需要对SObjectizer的常规行为进行一次小的创新。 解决方案的基础是消息信封机制,该机制已在5.5.23版本中添加,我们已经讨论过

在测试环境中,每个发送的消息都包装在一个特殊的信封中。 当带有消息的信封被提供给代理进行处理(或者相反,被代理拒绝)时,测试方案就会意识到这一点。 多亏了这些信封,测试脚本才知道发生了什么,并可以确定脚本执行“工作”的时刻。

但是,如何使SObjectizer将每个消息包装在一个特殊的信封中?

这是一个有趣的问题。 他决定如下:发明了诸如event_queue_hook的概念。 这是一个特殊的对象,具有两个方法-on_bind和on_unbind。

当代理绑定到特定的调度程序时,调度程序将代理event_queue分发给该代理。 通过此event_queue,对代理的请求进入必要的队列,并可供调度程序处理。 当代理在SObjectizer中运行时,它具有指向event_queue的指针。 从SObjectizer中删除代理后,指向event_queue的指针将无效。

因此,从5.5.24版开始,代理在收到event_queue时必须调用event_queue_hook的on_bind方法。 代理将接收到的指针传递给event_queue的位置。 并且event_queue_hook可以返回相同的指针或另一个指针作为响应。 并且代理必须使用返回的值。

从SObjectizer中删除代理后,它必须在event_queue_hook上调用on_unbind。 在on_unbind中,代理传递on_bind方法返回的值。

整个厨房在SObjectizer内部执行,用户看不到任何东西。 而且,原则上,您可能根本不知道这一点。 但是SObjectizer的测试环境(相同的testing_env_t)恰好利用了event_queue_hook。 在testing_env_t内部,创建了event_queue_hook的特殊实现。on_bind中的此实现将每个event_queue包装在一个特殊的代理对象中。并且此代理对象已经将发送到代理的消息放入一个特殊的信封中。

但这还不是全部。您可能还记得,在测试环境中,必须冻结代理。这也可以通过提到的代理对象来实现。当测试脚本未运行时,代理对象会在家中存储发送给代理的消息。但是,在运行脚本时,代理对象会将所有先前累积的消息传输到代理的当前消息队列。

结论


最后,我想说两件事。

首先,我们实现了关于如何在SObjectizer中测试代理的观点。我的看法是因为周围没有那么多好的榜样。我们朝Akka.Testing看去。但是Akka和SObjectizer 太不同了,无法将Akka中可用的方法移植到SObjectizer。而且C ++不是Scala / Java,其中与内省相关的某些事情可以通过反射来完成。因此,我不得不想出一种可以在SObjectizer上使用的方法。

在5.5.24版中,第一个实验性实现可用。当然可以做得更好。但是,如何理解什么是有用的,什么是无用的幻想呢?不幸的是,什么都没有。您需要尝试一下,看看实际情况如何。

因此,我们制作了一个最低版本,您可以试用。我们建议为所有人做的事情:尝试,尝试并与我们分享您的印象。你喜欢什么,你不喜欢什么?也许缺少什么?

其次,2017年初所说的话变得更加相关
… , , , . - — . . . : , .

, , , — , .

因此,对于那些正在寻找现成的演员框架的人,我的建议是:不仅要关注思想的原创性和实例的美感。还要查看各种辅助性的东西,这些东西可以帮助您弄清应用程序中正在发生的事情:例如,找出现在有多少个actor,它们的队列大小是什么,如果消息没有到达接收者,那么它将去哪里……如果框架可以提供类似的东西,对您来说会更容易。如果没有,那么您将有更多工作。
当涉及到测试参与者时,以上所有这些都更加重要。因此,在为自己选择一个参与者框架时,要注意其中的内容和没有的内容。例如,我们已经在工具包中简化了测试:)

Source: https://habr.com/ru/post/zh-CN435606/


All Articles