Menu

[ru] Как может выглядеть unit-тестирование для агентов?

2018-12-03
2018-12-23
1 2 3 > >> (Page 1 of 3)
  • Yauheni Akhotnikau

    Результаты нескольких итераций были опубликованы в блоге eao197:

    Первый набросок
    Второй набросок

    Но в комментариях блога неудобно приводить и обсуждать фрагменты кода. Поэтому обсуждение переносится сюда.

    Новые варианты будут добавляться в комментарии к этой теме.

     
  • Yauheni Akhotnikau

    Вариант от 2018.12.03.

    Пример теста:

    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::delivery_case(*fork)
            .on_message<msg_take>(fork->so_direct_mbox(),
                [&] {
                    REQUIRE("taken" == tests::current_agent_name(*fork));
    
                    auto taken = tests::try_receive<msg_taken>(dummy_ch);
                    REQUIRE(taken.extracted());
                })
            .time_limit(1s)
            .impact<msg_take>(*fork, dummy_ch->as_mbox())
            .perform();
    
        tests::delivery_case(*fork)
            .on_message<msg_put>(fork->so_direct_mbox(),
                [&] {
                    REQUIRE("free" == tests::current_agent_name(*fork));
                });
            .time_limit(1s)
            .impact<msg_put>(*fork)
            .perform();
    }
    

    Вызов delivery_case(agent) говорит о том, что будет проверяться проверка того, как агент agent будет реагировать на доставку сообщения. Этот вызов возвращает специальный объект-билдере, который позволяет настроить тестовый случай.

    Метод .on_message<T> говорит, что если агент получает сообщение типа T, то агент должен:

    • среагировать на это сообщение (т.е. обработать его);
    • после обработки сообщения должна быть вызвана заданная в on_message лямбда, в которой выполняются тестовые проверки.

    В принципе, методов .on_message на один тестовый случай можно навесить несколько. Это может потребоваться, если агент в каком-то своем состоянии обрабатывает несколько разных сообщений.

    Метод time_limit указывает, что тестовый случай должен отработать за 1 секунду. Если в это время не уложились, то тестовый случай будет объявлен проваленным.

    Метод impact<Msg> указывает, что перед проверкой тестового случая нужно отослать указанное сообщение.

    Метод perform заставляет отработать то, что было задано в объекте-билдере. Т.е., на агента вешаются специальные перехватчики сообщений, которые будут контролировать обработку сообщений, заданных в on_message. Затем, если использовался метод impact, будет отослано стартовое сообщение. После чего perform будет ждать не более time_limit пока агент не получит одно из указанных в on_message сообщений.

     
  • Pavel

    Pavel - 2018-12-03

    perform может переименовать в run ?

     
    • Yauheni Akhotnikau

      Ну или в check. Это пока набросок. Менять можно в любую сторону.

      В этом подходе понятно, как ловить отправленные агенту сообщения. А вот как проверять то, что агент игнорирует сообщения... Вот это пока вопрос :(

       
      • Pavel

        Pavel - 2018-12-03

        А проверить есть ли в текущем состоянии у агента подписка на "сообщение" возможно? Была например so_has_subscription(..)?

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

         

        Last edit: Pavel 2018-12-03
        • Yauheni Akhotnikau

          Да, можно использовать so_has_subscription().

          Но хотелось бы иметь возможность записать как-то так, например:

          tests::delivery_check(*fork)
              .on_message<msg_take>(...)
              .should_ignore<msg_put>(...)
              ...
              .run();
          

          Ну и не всегда можно полагаться на white box тестирование, иногда может быть полезно написать тесты и на чужого агента, доступа к потрохам которого нет.

           
          • Pavel

            Pavel - 2018-12-03

            Но хотелось бы иметь возможность записать как-то так

            Так а внутри should_ignore нельзя использовать so_has_subscription для проверки?

             

            Last edit: Pavel 2018-12-03
            • Yauheni Akhotnikau

              Я не уверен, что для should_ignore вообще есть смысл делать какие-то дополнительные проверки. Т.е. повесить лямбду с проверками на on_message -- в этом я смысл вижу. А вот в такой же лямбде для should_ignore пока не вижу.

              Но если на should_ignore таки навешивать лямбду, то в этой лямбде можно будет вызвать любой публичный метод у агента.

               
              • Pavel

                Pavel - 2018-12-04

                Видимо это я не совсем понял цель should_ignore. Я так понимал, что это проверк что указанное сообщение "не пришло" (т.е. тут даже обработчик не навешивается, т.к. нет как такогого события)

                 
                • Yauheni Akhotnikau

                  Это проверка для случая, когда у агента, скажем, два состояния. В одном он сообщение обрабатывает, в другом нет. Проверка should_ignore проверяет, что когда агент во втором состоянии, то он действительно не обрабатывает сообщение.

                  Вот возьмем тот же пример с агентом fork. Изначально он находится в st_free. Следовательно, сообщение msg_put (положить) должно быть проигнорировано. Затем он переходит в st_taken. В этом состоянии на msg_put агент должен среагировать и перейти в st_free. Если затем еще раз отослать msg_put, то агент должен его вновь проигнорировать.

                  Такое поведение мы можем протестировать следующей последовательностью:

                  auto fork = make_agent<a_fork_t>(...);
                  
                  tests::delivery_check(*fork)
                      .should_ignore<msg_put>(fork->so_direct_mbox())
                      .impact<msg_put>(*fork)
                      .run();
                  
                  tests::delivery_check(*fork)
                      .on_message<msg_take>(fork->so_direct_mbox(),
                          [&]{ REQUIRE("taken" == tests::current_agent_state_name(*fork)); })
                      .impact<msg_take>(*fork, ...)
                      .run();
                  
                  tests::delivery_check(*fork)
                      .on_message<msg_put>(fork->so_durect_mbox(),
                          [&]{ REQUIRE("free" == tests::current_agent_state_name(*fork)); })
                      .impact<msg_put>(*fork)
                      .run();
                  
                  tests::delivery_check(*fork)
                      .should_ignore<msg_put>(fork->so_direct_mbox())
                      .impact<msg_put>(*fork)
                      .run();
                  
                   
  • Pavel

    Pavel - 2018-12-03

    Кстати на тему проверок "не поменял состояние", "не послал сообщение" и т.п. проверка вида "НЕ сделал что-то" возможно требует своего отдельного time_limit, а точнее time_hold. Время в течение которого "не происходит проверерямое событие". Чтобы не было так:
    послали агенту сообщение
    проверили, что он "НЕ послал в ответ" какое-то сообщение
    * а в следующее мгновенье после нашей проверки, он его послал (ведь у нас тут "асинхронщина")

    или "проверили, что он не перешёл в новое состояние", а в следющее мгновенье, он перешёл.
    А если мы говорим should_ignore<msg_put>(...).time_hold(2s) то избавляемся от этого.

     
    • Yauheni Akhotnikau

      Вот тут я не понимаю сценария, в котором это может быть. Можно пример, где time_hold был бы полезен?

       
      • Pavel

        Pavel - 2018-12-04

        Когда нужно проверить, что агент среагировал "НЕ раньше чем" через N секунд. Например, мы посылаем агенту какое-то сообщение и должны проверить, что агент посылает ответное "Не раньше" чем через 2 секунды. Т.е. это проверка, как агент реализует какую-то "задержку".

         
        • Yauheni Akhotnikau

          Как-то все еще не догоняю :(

           
          • Pavel

            Pavel - 2018-12-04

            Ну возможно для акторной архитектуры это просто редкий (а может действительно ненужный case).
            Предположим агент по своей логике получив сообщение X должен подождать 5 сек и послать сообщение Y (или сменить состояние). Т.е. по приходу сообщения он скорее всего сделает so_5::send_delayed на 5 сек, а потом send<Y>(..). Вот чтобы проверить это, мы можем (если можем) либо проверить что он заказал таймер, либо подождать 5 сек (time_hold) и убедиться, что в течение этого времени агент не посылал сообщение Y.
            Как то так )

             
            • Pavel

              Pavel - 2018-12-04

              Другой более конкретный (близкий мне) пример из АСУ. Предположим происходит какое-то событие, если в течение заданного времени оператор не реагирует, то мы должны сформировать какое-то сообщение и выполнить какие-то действия. hold_time позволяет нам провести проверку. что агент действительно подождал положенное время и только после этого предпринял какие-то действия.

               
              • Yauheni Akhotnikau

                Мне кажется, что здесь какой-то другой сценарий уже рассматривается. Пока что речь шла о том, чтобы, грубо говоря, ткнуть в агента палкой и посмотреть, как он ответит.

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

                До этого руки пока не дошли. И, есть сомнение, что это достижимо с помощью delivery_check.

                 
            • Yauheni Akhotnikau

              Мне думается, что здесь можно рассмотреть две ситуации.

              Первая. Мы отсылаем сообщение агенту, от выставляет сам себе таймерную заявку и нам нужно убедится, что когда таймерная заявка сработает, то агент должным образом на нее среагирует. Тогда с использованием delivery_check может получится что-то вроде:

              tests::delivery_check(agent)
                  // Ничего не делаем при получении сообщения, хотя могли бы
                  // проверить отсылку таймерного сообщения.
                  .on_message<do_something>(agent.so_direct_mbox(), []{})
                  .impact<do_something>(agent, ...)
                  .check();
              // Теперь ждем того, что через N секунд агент должен среагировать
              // на отложенное сообщение.
              tests::delivery_check(agent)
                  .on_message<delayed_msg>(agent.so_direct_mbox(), [&]{...})
                  .time_limit(std::chrono::seconds{N})
                  .check(); // Здесь нет impact, т.к. delayed_msg должно
                              // прилететь от таймера.
              

              Вторая. Мы воздействуем на какого-то агента, тот еще на кого-то, тот еще на кого-то и, в конце-концов, через какое-то время наш агент должен среагировать на отложенное сообщение. В этом случае мы, наверное, просто запишем вот так:

              tests::delivery_check(our_agent)
                  .on_message<delayed_msg>(our_agent.so_direct_mbox(), [&]{...})
                  .impact<some_initial_msg>(another_agent, ...)
                  .time_limit(std::chrono::seconds{N})
                  .check();
              
               
              • Pavel

                Pavel - 2018-12-04

                // Теперь ждем того, что через N секунд агент должен среагировать
                // на отложенное сообщение.

                Вот тут как раз "ошибка". Тут проверяется, что он среагирует не более чем за N секунд time_limit, а я хотел проверить, что не ранее, чем через time_hold. Т.е., что в течение этого времени он гарантированно НЕ будет ничего делать (например не сменит состояние или не пошлёт определённое сообщение). И для такой проверки требуется отдельный "синтаксис" или название.. Например check_hold<msg>(...).time_hold(..) или check_freeze<msg>() ну или что-то такое..

                 

                Last edit: Pavel 2018-12-04
                • Yauheni Akhotnikau

                  Скорее всего, тут нужно ограничивать время и сверху, и снизу. Например:

                  tests::delivery_check(our_agent)
                      .on_message<delayed_msg>(our_agent.so_direct_mbox(), [&]{...})
                      .impact<some_initial_msg>(another_agent, ...)
                      .not_before_than(std::chrono::seconds{N})
                      .time_limit(std::chrono::seconds{N+1})
                      .check();
                  
                   
                  • Pavel

                    Pavel - 2018-12-04

                    Да. Хорошая мысль! Так понятнее замысел.

                     
                    • Pavel

                      Pavel - 2018-12-04

                      Разве что, тут подразумевается, что должно в итоге придти some_initial_msg (если я правильно понял). А хочется иметь возможность проверить, что не приходило some_msg, не обязательно что оно в итоге придёт.
                      Или второй случай, проверка что не менялось состояние агента в течение заданного времени. Т.е. может всё-таки какой-то особы синтаксис

                      tests::delivery_check(our_agent)
                          .check_message_not_sent<some_msg>(std::chrono::seconds{N})
                          .check_state_not_change(std::chrono::seconds{N})
                          .check();
                      
                       
  • Yauheni Akhotnikau

    Идея от 2018.12.04.

    Суть в том, чтобы внутри unit-теста определить "сценарий", который затем "проигрывается" и результат "проигрыша" сценария затем можно проверить отдельными конструкциями REQUIRE и/или ASSERT.

    Сценарий состоит из шагов. Шаги должны вполняться последовательно и каждый следующий шаг должен произойти только после исполнения предыдущего шага.

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

    В двух последующих комментариях будут показаны наброски, как посредством сценариев можно протестировать агентов fork_t и philosopher_t из примера dining_philosophers.

     
    • Yauheni Akhotnikau

      Вот так может выглядеть тестирование агента fork_t:

      TEST_CASE("fork check")
      {
          tests::wrapped_env_t sobj;
      
          so_5::agent_t * fork{};
          so_5::agent_t * philosopher{};
          sobj.env().introduce_coop([&](so_5::coop_t & coop) {
              fork = coop.make_agent<fork_t>();
      
              auto p = coop.define_agent();
              p.event(p, [](so_5::mhood_t<msg_taken>) {});
              p.event(p, [](so_5::mhood_t<msg_busy>) {});
              philosopher = p.agent_ptr();
          });
      
          auto scenario = tests::make_scenario();
      
          scenario.define_step("put_when_free")
              .impact<msg_put>(*fork)
              .ignores<msg_put>(*fork);
      
          scenario.define_step("take_when_free")
              .impact<msg_take>(*fork, philosopher->so_direct_mbox())
              .receives<msg_taken>(*philosopher)
              .store_state_name(*fork, "fork");
      
          scenario.define_step("take_when_taken")
              .impact<msg_take>(*fork, philosopher->so_direct_mbox())
              .receives<msg_busy>(*philosopher);
      
          scenario.define_step("put_when_taken")
              .impact<msg_put>(*fork)
              .store_state_name(*fork, "fork");
      
          auto result = scenario.run_for(100ms);
      
          REQUIRE(result.completed());
          REQUIRE("taken" == result.stored_state_name("take_when_free", "fork"));
          REQUIRE("free" == result.stored_state_name("put_when_taken", "fork"));
      }
      

      Сперва создается тестовая кооперация, в которую входит нормальный агент fork_t и имитация агента-философа. Эта имитация необходима для того, чтобы

      a) иметь mbox, на который будут отсылаться ответные сообщения и
      b) можно было контролировать сам факт пришествия ответных сообщений.

      Сценарий определяет несколько воздействий:

      • на сообщение msg_put в начальном состоянии агент-fork реагировать не должен;
      • на сообщение msg_take в начальном состоянии агент-fork должен среагировать изменением своего состояния и ответным сигналом msg_taken;
      • на повторое сообщение msg_take в занятом состоянии агент-fork должен среагировать ответным сигналом msg_busy;
      • на сообщение msg_put в занятом состоянии агент-fork должен среагировать изменением состояния.

      Для того, чтобы проверить факты изменения состояния мы предписываем соответствующим шагам сохранить у себя имя текущего состояния агента (состояние определяется после того, как агент закончит обработку сообщения, указанного в receives).

      Сценарий проигрывается и затем то, что было сохранено в сценарии можно проверить посредством REQUIRE. Т.е. все assertions выполняются уже после того, как сценарий отработал.

       
    • Yauheni Akhotnikau

      Вот так может выглядеть один тестовый случай для агента philosopher_t:

      TEST_CASE("philosopher check [take both forks]")
      {
          tests::wrapped_env_t sobj;
      
          so_5::agent_t * philosopher{};
          so_5::agent_t * fork{};
          sobj.env().introduce_coop([](so_5::coop_t & coop) {
              auto available_fork = coop.define_agent();
              available_fork.event(available_fork, [](so_5::mhood_t<msg_take> cmd) {
                      so_5::send<msg_taken>(cmd->m_who);
                  });
              fork = available_fork.agent_ptr();
      
              philosopher = coop.make_agent<philosopher_t>(
                      "philosopher",
                      available_fork.direct_mbox(),
                      available_fork.direct_mbox());
          });
      
          auto scenario = tests::make_scenario();
      
          scenario.define_step("stop_thinking")
              .not_before_than(250ms)
              .receives<philosopher_t::msg_stop_thinking>(*philosopher)
              .store_state_name(*philosopher, "philosopher");
      
          scenario.define_step("take_left")
              .receives<msg_take>(*fork);
      
          scenario.define_step("left_taken")
              .receives<msg_taken>(*philosopher)
              .store_state_name(*philosopher, "philosopher");
      
          scenario.define_step("take_right")
              .receives<msg_take>(*fork);
      
          scenario.define_step("right_taken")
              .receives<msg_taken>(*philosopher)
              .store_state_name(*philosopher, "philosopher");
      
          scenario.define_step("stop_eating")
              .not_before_than(250ms)
              .receives<philosopher_t::msg_stop_eating>(*philosopher)
              .store_state_name(*philosopher, "philosopher");
      
          scenario.define_step("return_forks")
              .receives<msg_put>(*fork)
              .receives<msg_put>(*fork);
      
          auto result = scenario.run_for(1s);
      
          REQUIRE(result.completed());
          REQUIRE("wait_left" == result.stored_state_name("stop_thinking", "philosopher"));
          REQUIRE("wait_right" == result.stored_state_name("left_taken", "philosopher"));
          REQUIRE("eating" == result.stored_state_name("right_taken", "philosopher"));
          REQUIRE("thinking" == result.stored_state_name("stop_eating", "philosopher"));
      }
      

      Здесь проверяется сценарий, когда агент-философ получает в свое распоряжение обе вилки.

      Для этого создается тестовая кооперация, в которую входит нормальный агент-философ и имитатор агента-fork. Этот имитатор всегда отсылает в ответ msg_taken, т.е. имитирует поведение свобойдной вилки.

      В этом сценарии можно обратить внимание на следующие вещи.

      Во-первых, конструкция not_before_than на шагах "stop_thinking" и "stop_eating". Эта конструкция указывает, что для нормального выполнения шага требуется, чтобы прошло указанное время после предыдущего шага (или после начала работы сценария для случая "stop_thinking").

      Во-вторых, отсутствие конструкции impact в шагах сценария. Т.е. здесь сценарий только проверяет реакцию агента, но сам на агента не воздействует.

      В-третьих, на последнем шаге ожидается поступление двух сообщений msg_put.

       

      Last edit: Yauheni Akhotnikau 2018-12-04
1 2 3 > >> (Page 1 of 3)

Log in to post a comment.