пятница, 30 ноября 2018 г.

[prog.c++] Unit-тестирование агентов в SObjectizer: вторая итерация

Продолжение темы, начатой здесь. Кому не интересно, можно смело не читать.

Показанный в предыдущем посте пример претерпел изменения и теперь выглядит вот так:

TEST_CASE("fork check")
{
   namespace tests = so_5::extra::testing;

   tests::wrapped_env_t sobj;

   auto fork = make_agent<fork_t>(sobj);
   auto dummy_ch = create_mchain(sobj.env());

   tests::check_delivery(
         tests::impact<msg_take>(*fork, dummy_ch->as_mbox()),
         [&](auto & dc) {
            REQUIRE(1u == dc.handlers_count());
            
            REQUIRE("taken" == tests::current_agent_name(*fork));

            auto taken = tests::try_receive<msg_taken>(dummy_ch);
            REQUIRE(taken.extracted());
         });

   tests::check_delivery(
         tests::impact<msg_take>(*fork, dummy_ch->as_mbox()),
         [&](auto & dc) {
            REQUIRE(1u == dc.handlers_count());
            
            REQUIRE("taken" == tests::current_agent_name(*fork));

            auto busy = tests::try_receive<msg_busy>(dummy_ch);
            REQUIRE(busy.extracted());
         });

   tests::check_delivery(
         tests::impact<msg_put>(*fork),
         [&](auto & dc) {
            REQUIRE(1u == dc.handlers_count());
            
            REQUIRE("free" == tests::current_agent_name(*fork));
         });

   tests::check_delivery(
         tests::impact<msg_put>(*fork),
         [&](auto & dc) {
            REQUIRE(0u == dc.handlers_count());
         });
}

Что здесь изменилось?

Во-первых, сделана попытка представить, как оформлять unit-тесты для SObjectizer-а с помощью уже существующих фреймворков для этих целей. В частности здесь используется API Catch2 и doctest. ИМХО, возможность делать unit-тесты для SObjectizer-а посредством готовых и популярных инструментов, вроде Catch2/doctest/GTest/Boost.Test и пр., намного предпочтительнее, чем превращение so_5::extra::testing в подобный фреймворк.

Во-вторых, первый вариант, показанный пару дней назад, требовал разработки специализированного SObjectizer Environment, который был нужен для контроля того, что происходит внутри SObjectizer-а. А так же для создания специализированных реализаций mbox-ов, чтобы эти реализации можно было использовать в тестах.

Но такой кастомный SOEnv -- это неслабый кусок работы. Как в первоначальной реализации, так и в последующем сопровождении. А создание кастомных mbox-ов неявным образом -- это хорошо для случая, когда используются только штатные MPMC и MPSC mbox-ы. Но вот со сторонними mbox-ами, в том числе и из состава so_5_extra, уже засада.

Поэтому второй пример написан так, чтобы по максимуму переиспользовать в тестах штатные возможности самого SObjectizer-а.

В частности, для того, чтобы проверить факт отсылки сигналов msg_taken и msg_busy в этом тесте вместо какого-то специального mbox-а с хитрой функциональностью, используется обычный mchain. В сообщение msg_take отсылается mchain в виде mbox-а.

   tests::check_delivery(
         tests::impact<msg_take>(*fork, dummy_ch->as_mbox()),
         [&](auto & dc) {
            REQUIRE(1u == dc.handlers_count());
            
            REQUIRE("taken" == tests::current_agent_name(*fork));

            auto taken = tests::try_receive<msg_taken>(dummy_ch);
            REQUIRE(taken.extracted());
         });

Агент fork отвечает в этот mchain. И мы можем проконтролировать, что же лежит в mchain-е. Для этого мы пытаемся извлечь из канала сигнал msg_taken, если он там есть. Выполняется это посредством вспомогательной функции:

template<typename Msg>
extraction_result_t<Msg>
try_receive(const so_5::mchain_t & mchain);
где extraction_result_t может представлять из себя что-то вроде (что-то вроде аналога std::optional, но заточенного под специфику SObjectizer-а):
template<typename Msg>
class extraction_result_t {
   so_5::intrusive_ptr_t<Msg> m_msg;
   bool m_extracted;
   ...
public:
   ...
   bool extrected() const noexcept { return m_extracted; }
   ...
   const Msg & msg() const noexcept;
};

Т.е. try_receive пытается взять из mchain-а сообщение указанного типа. Если сообщения в mchain-е нет, то метод extraction_result_t::extracted() будет возвращать false. Что можно будет проверить обычным assertion-ом из тестового фреймворка.

Еще один момент, на который имеет смысл обратить внимание -- это наличие функции check_delivery и ее важность. Дело в том, что одним из основных инструментов для unit-тестирования агентов является отсылка сообщения в какой-то mbox с последующей проверкой того, что же произошло в результате обработки сообщения. Но фокус в том, что отсылка сообщения -- это асинхронная операция. Фактически она выполняется на контексте другой рабочей нити. Поэтому, если мы просто сделаем вызов so_5::send(), а затем сразу начнем проверять, что изменилось, то мы можем оказаться в двух ситуациях: либо сообщение уже доставлено и обработано к моменту выхода из so_5::send(), либо этого еще не произошло. В первой ситуации мы можем продолжать тест. Но вот во второй нам нужно дождаться доставки сообщения до получателя. Но как это сделать?

И вот тут и приходит на помощь функция check_delivery. Она выполняет so_5::send() и ждет, пока обработка сообщения завершится. Когда обработка завершается, то вызывается лямбда с действиями по проверке последствий доставки. В эту лямбду передается ссылка на объект некого (пока гипотетического) типа delivery_completion_t. У этого объекта, в частности, можно узнать, сколько обработчиков было вызвано при обработке сообщения.

Пока предполагается, что реализация check_delivery будет построена на использовании message envelopes. Но это все еще преждевременные детали реализации.


Еще несколько мыслей, которые пока еще не оформились в конкретные предложения.

Во-первых, для каких-то тестов нужно следить за тем, что отсылается в mbox. Чтобы mbox, с одной стороны, выполнял свою основную работу. Но, с другой стороны, чтобы было поднять информацию о том, что именно в mbox отсылают.

Возможно, для этого можно предоставить специальную обертку. Что-то вроде:

auto traced_wrapper = tests::make_traced_wrapper(env.create_mbox());
tests::check_delivery(
      tests::impact<msg_take>(*fork, traced_wrapper),
      [&](auto & dc) {
         REQUIRE(1u == traced_wrapper->occurences_of<msg_taken>());
      });

Т.е. создаем обертку вокруг другого mbox-а и используем эту обертку как mbox. Но при этом можем заглянуть в историю того, что через этот mbox было отослано.

Во-вторых, следить за отсылкой отложенных и периодических сообщений можно посредством специального объекта timer_tracer (название гипотетическое). Что-то вроде:

tests::timer_tracer ttracer;
so_5::wrapped_env_t sobj(
      [](so_5::environment_t &){},
      [&](so_5::environment_params_t & params) {
         params.timer_thread(ttracer.make_timer_thread());
      });
...
tests::check_delivery(tests::impact<some_msg>(...),
      [&](auto & dc) {
         auto info = ttracer.find_periodic<my_msg>(some_mbox);
         REQUIRE(info.found());
         REQUIRE(info.delay() <= 250ms);
      });

Т.е. мы создаем timer_tracer из которого потом берем реализацию timer_thread для SObjectizer-а. Эта реализация timer_thread будет давать нам доступ к текущим таймерным заявкам (а может не только к текущим, но и к тем, которые уже были обработаны). Что позволяет нам в показанном выше примере найти информацию об отложенном сообщении типа my_msg, которое должно было быть отправлено в some_mbox. И если мы информацию о таком сообщении нашли, то можем проверить величину задержки.

Причем, можно сделать так, чтобы timer_tracer не нужно было создавать вручную. Можно включить в so_5::extra::testing какой-то класс wrapped_env_t, который уже будет содержать timer_tracer внутри себя. Что позволит писать вот так:

tests::wrapped_env_t sobj;
...
tests::check_delivery(tests::impact<some_msg>(...),
      [&](auto & dc) {
         auto info = sobj.timer_tracer().find_periodic<my_msg>(some_mbox);
         REQUIRE(info.found());
         REQUIRE(info.delay() <= 250ms);
      });

В-третьих, в тестах агентов, которые отсылают отложенные/периодические сообщения, может быть неудобно использовать нормальное время. Ну, скажем, агент отослал себе на 20 минут отложенное сообщение. И мы хотим проверить, как агент на него среагирует. Не ждать же ради этого 20 минут? ;)

Поэтому в unit-тестах может быть желательно управлять скоростью течения времени (как звучит, а?). Например, ускорить. Скажем так:

tests::timer_tracer ttracer;
ttracer.set_speedup_factor(5.5);
so_5::wrapped_env_t sobj(
      [](so_5::environment_t &){},
      [&](so_5::environment_params_t & params) {
         params.timer_thread(ttracer.make_timer_thread());
      });

И таймерные события будут происходит в 5.5 раз быстрее.

Или даже так:

tests::timer_tracer ttracer;
so_5::wrapped_env_t sobj(
      [](so_5::environment_t &){},
      [&](so_5::environment_params_t & params) {
         params.timer_thread(ttracer.make_timer_thread());
      });
...
so_5::send_delayed<my_msg>(sobj.env(), some_mbox, 15m, ...);
ttracer.execute_nearest();

Т.е. отослали отложенное на 15 минут сообщение, а потом дали команду таймеру перейти к обслуживанию ближайшей заявки, как будто эти 15 минут уже прошли.


В общем, не смотря на то, что какие-то вещи начинают приобретать, пусть расплывчатые, но очертания, непонятностей и белых пятен пока еще слишком много.

Комментариев нет: