📜 ⬆️ ⬇️

How to write unit tests for actors? SObjectizer approach

Actors simplify multi-threaded programming by avoiding the common shared variable state. Each actor has its own data that is not visible to anyone. Interact actors only through asynchronous messages. Therefore, the most nightmarish horrors of multithreading in the form of races and deadlocks when using actors are not terrible (although actors have their own troubles, but this is not about that now).

In general, writing multi-threaded applications using actors is easy and pleasant. This is also because the actors themselves are written easily and naturally. You could even say that writing the actor code is the easiest part of the job. But when the actor is written, a very good question arises: “How to check the correctness of his work?”

The question is really very good. We regularly ask it when we talk about actors in general and about SObjectizer in particular. And until recently, we could only answer this question in general terms.
')
But version 5.5.24 was released , in which experimental support for unit-testing capabilities of actors appeared. And in this article we will try to tell about what it is, how to use it and with the help of what it was implemented.

What are the tests for the actors?


We will look at the new features of SObjectizer in a couple of examples, along the way telling what's what. Source codes for the examples discussed can be found in this repository .

In the course of the story, the terms "actor" and "agent" will be used interchangeably. They denote the same thing, but in SObjectizer, the term “agent” is historically used, so the term “agent” will be used more often.

The simplest example with Pinger and Ponger


The example with the actors Pinger and Ponger is probably the most common example for actor frameworks. You can say a classic. Well, if so, then let's start with the classics.

So, we have a Pinger agent who, at the beginning of his work, sends a Ping message to a Ponger agent. Agent Ponger sends back a Pong message. This is how it looks in C ++ code:

// 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; } }; 

Our task is to write a test that checks that when registering these agents in SObjectizer, Ponger will receive a Ping message, and Pinger will receive a Pong message in response.

OK We write such a test using the doctest unit test framework and get:

 #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()); } 

It seems to be easy. Let's see what happens here.

First of all, we download descriptions of agent support test tools:

 #include <so_5/experimental/testing.hpp> 

All these tools are described in the so_5 :: experimental :: testing namespace, but in order not to repeat such a long name, we introduce a shorter and more convenient alias:

 namespace tests = so_5::experimental::testing; 

Next comes the description of a single test case (and more we don’t need here).

Inside the test case, there are several key points.

First, this is the creation and launch of a special test environment for SObjectizer:

 tests::testing_env_t sobj; 

Without this environment, the “test run” for agents will not work, but we'll talk about this a little bit below.

The testing_env_t class is very similar to the wrapped_env_t class in SObjectizer. Similarly, the SObjectizer is started in the constructor, and it stops in the destructor. So when writing tests, you don’t have to worry about starting and stopping SObjectizer.

Next, we need to create and register the Pinger and Ponger agents. In this case, we need to use these agents in determining the so-called. "Test script". Therefore, we separately save pointers to agents:

 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() ); }); 

And then we begin to work with the "test script."

A test script is a straightforward piece of steps that must be run from start to finish. The phrase “out of direct sequence” means that in SObjectizer-5.5.24, the steps of the script “work” in a strictly sequential manner, without any branches and cycles.

Writing a test for agents is the definition of a test script that must be executed. Those. All steps of the test script should work, starting from the very first and ending with the most recent.

Therefore, in our test case, we define a scenario in two steps. The first step verifies that the Ponger agent will receive and process the Ping message:

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

The second step verifies that the Pinger agent will receive a Pong message:

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

These two steps are quite enough for our test case, so after their definition we proceed to the execution of the script. Run the script and allow it to work no longer than 100ms:

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

One hundred milliseconds should be more than enough for two agents to exchange messages (even if the test is run inside a very deceptive virtual machine, as is sometimes the case on Travis CI). Well, if we make a mistake in writing agents or incorrectly describe a test script, then it makes no sense to wait for the completion of an erroneous script for more than 100ms.

So, after returning from run_for (), our script can either be successfully completed or not. Therefore, we simply check the result of the script:

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

If the script was not successfully completed, it will lead to the failure of our test case.

Some explanations and additions


If we run this code inside a normal 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() ); }); 

then, most likely, the agents Pinger and Ponger would have exchanged messages and would have completed their work before returning from introduce_coop (the wonders of multithreading are such). But inside the test environment, which is created thanks to testing_env_t, this does not happen, agents Pinger and Ponger wait patiently until we run our test script. How does this happen?

The fact is that inside the test environment, the agents appear to be in a frozen state. Those. after registration, they are present in SObjectizer, but they cannot process any of their messages. Therefore, even so_evt_start () is not called for agents before the test script is run.

When we run the test script with the run_for () method, the test script first defrosts all frozen agents. And then the script begins to receive notifications from SObjectizer about what happens with the agents. For example, that Agent Ponger received a Ping message and that Agent Ponger processed this message, but did not reject it.

When such notifications begin to arrive at the test scenario, the script tries to “try on” them to the very first step. So, we have a notice that Ponger received and processed Ping - are we curious or not? It turns out that it is interesting, because in the description of the step that’s what it says: it works when Ponger reacts to Ping. What we see in the code:

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

OK So the first step worked, go to the next step.

A notification arrives next that Agent Pinger has reacted to Pong. And this is exactly what is needed for the second step to work:

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

OK So the second step worked, do we have something else? Not. This means that the entire test script is complete and you can return control from run_for ().

Here, in principle, how the test script works. In fact, everything is somewhat more complicated, but we will touch upon more complex aspects when we consider a more complex example.

Example "Dining Philosophers"


More sophisticated examples of testing agents can be seen in solving the well-known problem “Dining Philosophers” On the actors, this problem can be solved in several ways. Next, we will consider the most trivial solution: in the form of actors are presented both the philosophers themselves, and forks, for which the philosophers have to fight. Every philosopher thinks for a while, then tries to take the plug to the left. If it succeeds, he tries to take the plug to the right. If this succeeds, the philosopher eats for some time, then puts down the forks and begins to think. If it was not possible to take the plug on the right (i.e., it was taken by another philosopher), then the philosopher returns the plug on the left and thinks for some time. Those. this is not a good decision in the sense that some philosopher can starve for too long. But it is very simple. And it has room for demonstrating agent testing capabilities.

Source codes with the implementation of the agents Fork and Philosopher can be found here , in the article we will not consider them to save volume.

Test for Fork


The first test for agents from The Dining Philosophers is for agent Fork.

This agent works according to a simple scheme. He has two states: Free and Taken. When the agent is in the Free state, it responds to the Take message. In this case, the agent enters the Taken state and responds with a Taken response message.

When the agent is in the Taken state, it reacts to the Take message in a different way: the state of the agent does not change, and Busy is sent as a response message. Also in the Taken state, the agent responds to the Put message: the agent returns to the Free state.

In the Free state, the Put message is ignored.

This is what we will try to test with the following test case:

 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")); } 

There is a lot of code, so we will deal with it in parts, skipping those fragments that should already be understood.

The first thing we need here is to replace the real agent Philosopher. Agent Fork should receive messages from someone and reply to someone. But we cannot use the real Philosopher in this test case, because the real agent Philosopher has his own logic of behavior, he sends messages and this independence will hinder us here.

Therefore, we do mocking , i.e. we introduce instead of the real Philosopher its substitute: an empty agent who does not send anything himself, but only receives sent messages without any useful processing. This is the pseudo-philosopher implemented in the code:

 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>) {}); } }; 

Next, we create cooperation from the Fork agent and the PseudoPhilospher agent and begin to determine the contents of our test scenario.

The first step of the script is to verify that Fork, being in the Free state (and this is its initial state), does not respond to the Put message. Here is how this check is recorded:

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

The first thing that attracts attention is the impact construction.

It is needed because our agent Fork does nothing, he only reacts to incoming messages. Therefore, someone must send the message to the agent. But who?

But the very step of the script and sends through impact. In fact, impact is an analog of the usual function send (and the format is the same).

Great, the script step itself will send the message via impact. But when will he do it?

And he will do it when the turn comes to him. Those. if the first step in the script is, then the impact will be executed immediately after entering run_for. If the step in the script is not the first, then the impact will be executed as soon as the previous step is triggered and the script proceeds to process the next step.

The second thing we need to discuss here is the ignores call. This helper function says that the step is triggered when the agent is rendered from processing the message. Those. in this case, the Fork agent must refuse to process the Put message.

Consider another step of the test scenario in more detail:

 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>()); 

First, we see here when_all instead of when. This is because to trigger a step, we need to fulfill several conditions at once. It is necessary for the fork to handle Take. And you need Philosopher to process the response Taken. Therefore, we write when_all, not when. By the way, there is also when_any, but we will not see him in the examples considered today.

Secondly, we also need to check the fact that after processing the Take agent, the agent Fork will be in the state of Taken. We do the check as follows: we first indicate that as soon as the Fork agent finishes processing the Take, its current state name should be saved using the “fork” tag. This is the construction that instructs to save the name of the agent state:

 & tests::store_state_name("fork") 

And then, when the script is completed successfully, we check this saved name:
 REQUIRE("taken" == sobj.scenario().stored_state_name("take_when_free", "fork")); 

Those. we ask the script: give us the name that was saved with the “fork” tag for the step named “take_when_free”, then compare the name with the expected value.

Here, perhaps, all that could be noted in the test case for the agent Fork. If readers have any questions, ask in the comments, we will be happy to answer.

Philosopher Successful Script Test


For the Philosopher agent, we will consider only one test case - for the case when Philosopher can take both forks and eat.

This test case will look like this:

 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")); } 

Quite voluminous, but trivial. First we check that Philosopher has finished thinking and started preparing for food. Then we check that he tried to take the left fork. Next, he should try to take the right fork. Then he must eat and stop this activity. After which he must put both plugs taken.

In general, everything is simple. But it should focus on two things.

First, the testing_env_t class, like its prototype, wrapped_env_t, allows you to configure the SObjectizer Environment. We will use this to enable the message delivery tracing mechanism:

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

This mechanism allows you to "visualize" the message delivery process, which helps in the process of handling the behavior of agents (we have already talked about this in more detail ).

Secondly, the agent Philosopher does not perform a series of actions immediately, but after some time. So, starting to work, the agent should send itself a pending StopThinking message. This message should come to the agent after a few milliseconds. What we specify when setting for a particular step the desired restriction:

 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)) ); 

Those. here we say that we are not interested in any reaction of the Philosopher agent to StopThinking, but only that which occurred no earlier than 250ms after the start of the processing of this step.

The restriction of the form not_before instructs the script that all events that occur before the expiration of the specified timeout should be ignored.

There is also a limitation of the form not_after, it works the other way around: only those events that occur before the specified timeout has expired.

Restrictions not_before and not_after can be combined, for example:

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

but in this case the SObjectizer does not check the consistency of the given values.

How did you manage to implement it?


I would like to say a few words about how it all turned out to make work. After all, by and large, we had one big ideological question "How to test agents in principle?" And one smaller question, already technical: "How to implement it?"

And if, regarding the ideology of testing, it was possible to get out somehow, then here about the implementation the situation was more complicated. It was necessary to find such a solution, which, firstly, would not require a cardinal alteration of the insides of SObjectizer. And, secondly, it should have been a solution that could be implemented in the foreseeable and, highly desirable, short time.

As a result of the difficult process of bamboo smoking, a solution was found. To do this, it took, in fact, to make only one small innovation in the regular behavior of SObjectizer. And the basis of the solution is the envelope mechanism for messages, which was added in version 5.5.23 and which we already talked about .

Inside the test environment, each message sent is wrapped in a special envelope. When the envelope with the message is given to the agent for processing (or, on the contrary, is rejected by the agent), the test script becomes aware of this. It is thanks to the envelopes that the test script knows what is happening and can determine the moments when the steps of the script “work”.

But how to make SObjectizer wrap each message in a special envelope?

That was an interesting question. He decided as follows: it was invented such a thing as event_queue_hook . This is a special object with two methods - on_bind and on_unbind.

When an agent binds to a specific dispatcher, the dispatcher issues a personal event_queue to the agent. Through this event_queue, requests for the agent are placed in the required queue and become available to the dispatcher for processing. When an agent is working inside a SObjectizer, it has a pointer to an event_queue. When an agent is removed from SObjectizer, its pointer to event_queue is reset.

So, starting from version 5.5.24, when receiving event_queue, the agent must call the on_bind method of event_queue_hook. Where the agent should send the received pointer to event_queue. And the event_queue_hook in response can return either the same pointer, or another pointer. And the agent must use the returned value.

When an agent is removed from SObjectizer, it must call on_unbind on event_queue_hook. In on_unbind, the agent passes the value that was returned by the on_bind method.

This whole kitchen runs inside the SObjectizer, and the user doesn’t see anything of this. And, in principle, you may not know about it at all. But the test environment of SObjectizer, the same testing_env_t, is used by the event_queue_hook. Inside testing_env_t, a special implementation of event_queue_hook is created. on_bind event_queue -. - .

. , . -. , - . , - .

Conclusion


.

-, SObjectizer- , . , . Akka.Testing . Akka SObjectizer SObjectizer , Akka. C++ Scala/Java, - , , . , SObjectizer.

5.5.24 , . . , , ? , . , , .

, . : , . , ? - ?

-, 2017- :
… , , , . - — . . . : , .

, , , — , .

, : . , , : , , , , , … - , . , .
. , , , . , , :)

Source: https://habr.com/ru/post/435606/


All Articles