Стратегии юнит-тестирования Akka без макетов

ОСНОВНАЯ ИДЕЯ: Как мы можем провести модульное тестирование (или рефакторинг для облегчения модульного тестирования) актеров Akka с довольно сложной бизнес-логикой?

Я использовал Akka для проекта в моей компании (некоторые очень простые вещи находятся в производстве) и постоянно пересматривал моих актеров, изучал и экспериментировал с тест-пакетом Akka, чтобы посмотреть, смогу ли я сделать это правильно...

В основном, в большинстве прочитанных мною чтений говорится: "Чувак, все, что тебе нужно, это тест-кит. Если ты используешь издевательства, ты делаешь это неправильно!!" однако документы и примеры настолько легки, что я нахожу ряд вопросов, которые не охвачены (в основном, их примеры - довольно надуманные классы, которые имеют 1 метод и взаимодействуют с другими участниками, или только тривиальными способами, такими как ввод данных в конце метода). Кроме того, если кто-нибудь может указать мне набор тестов для приложения akka с любой разумной степенью сложности, я был бы очень признателен.

Здесь я сейчас, по крайней мере, попытаюсь подробно описать некоторые конкретные случаи и хотел бы узнать, что можно назвать подходом, сертифицированным "Акка" (но, пожалуйста, ничего смутного... Я ищу методологию стиля Ролана Куна, если бы он был когда-либо на самом деле углубиться в конкретные вопросы). Я приму стратегии, связанные с рефакторингом, но, пожалуйста, обратите внимание на мои опасения по поводу этого, упомянутые в сценариях.

Сценарий 1: боковые методы (метод вызывает другого в том же актере)

case class GetProductById(id : Int)
case class GetActiveProductsByIds(ids : List[Int])

class ProductActor(val partsActor : ActorRef, inventoryActor : ActorRef) extends Actor {
  override def receive: Receive = {
    case GetProductById(pId) => pipe(getProductById(pId)) to sender
    case GetActiveProductsByIds(pIds) => pipe(getActiveProductsByIds(pIds)) to sender
  }

  def getProductById(id : Int) : Future[Product] = {
    for {
      // Using pseudo-code here
      parts <- (partsActor ? GetPartsForProduct(id)).mapTo[List[Part]]
      instock <- (inventoryActor ? StockStatusRequest(id)).mapTo[Boolean]
      product <- Product(parts, instock)
    } yield product
  }

  def getActiveProductsByIds(ids : List[Int]) : Future[List[Product]] = {
    for {
      activeProductIds <- (inventoryActor ? FilterActiveProducts(ids)).mapTo[List[Int]]
      activeProducts <- Future.sequence(activeProductIds map getProductById)
    } yield activeProducts
  }
}

Итак, в основном здесь у нас есть то, что по сути является 2 метода выборки, один для единственного и один для нескольких. В единственном случае тестирование простое. Мы настроили TestActorRef, внедрили некоторые пробники в конструктор и просто убедились, что запускается правильная цепочка сообщений.

Мое беспокойство здесь исходит от метода множественных выборок. Он включает в себя этап фильтрации (для получения только идентификаторов активных продуктов). Теперь, чтобы проверить это, я могу настроить тот же сценарий (TestActorRef из ProductActor с пробниками, заменяющими актеров, вызываемых в конструкторе). Однако, чтобы протестировать поток передачи сообщений, я должен смоделировать все цепочки сообщений не только для ответа на FilterActiveProducts, но и для всех тех, которые уже были рассмотрены в предыдущем тесте метода "getProductById" (тогда это не совсем модульное тестирование)., это?). Понятно, что это может выйти из-под контроля с точки зрения количества необходимой имитации сообщений, и было бы намного легче проверить (с помощью имитаций?), Что этот метод просто вызывается для каждого идентификатора, который пережил фильтр.

Теперь я понимаю, что это можно решить путем извлечения другого субъекта (создайте ProductCollectorActor, который получает несколько идентификаторов, и просто обращается к ProductActor с единственным запросом сообщения для каждого идентификатора, который проходит фильтр). Тем не менее, я рассчитал это, и если бы мне пришлось делать такие извлечения для каждого сложного для тестирования набора методов, которые у меня есть, я получу десятки актеров для относительно небольшого количества объектов домена. Количество накладных расходов будет много, плюс система будет значительно более сложной (гораздо больше актеров просто выполняют то, что по существу является некоторыми композициями методов).

В стороне: встроенная (статическая) логика

Один из способов, с помощью которого я пытался решить эту проблему, заключается в перемещении inline (в основном всего, что является чем-то большим, чем очень простой поток управления) в компаньон или другой одноэлементный объект. Например, если в вышеуказанном методе был метод, который должен был отфильтровать продукты, если они не соответствуют определенному типу, я мог бы сделать что-то вроде следующего:

object ProductActor {
  def passthroughToysOnly(products : List[Product]) : List[Toy] =
    products flatMap {p => 
      p.category match {
        case "toy" => Some(p)
        case _ => None
      }
    }
}

Это может быть проверено модульно в изоляции довольно хорошо, и может фактически позволить тестировать довольно сложные модули, пока они не вызывают других актеров. Я не большой поклонник того, чтобы помещать их вдали от логики, которая их использует (я должен поместить это в действующего актера, а затем проверить, вызвав лежащий в основе актер?).

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

Где это все рушится для меня здесь -

Сценарий 2: рекурсивные алгоритмы

case class GetTypeSpecificProductById(id : Int)

class TypeSpecificProductActor(productActor : ActorRef, bundleActor : ActorRef) extends Actor {
  override def receive: Receive = {
    case GetTypeSpecificProductById(pId) => pipe(getTypeSpecificProductById(pId)) to sender
  }

  def getTypeSpecificProductById(id : Int) : Future[Product] = {
    (productActor ? GetProductById(id)).mapTo[Product] flatMap (p => p.category match {
        case "toy" => Toy(p.id, p.name, p.color)
        case "bundle" => 
          Bundle(p.id, p.name, 
            getProductsInBundle((bundleActor ? GetProductIdsForBundle(p.id).mapTo[List[Int]))
      }
    )
  }

  def getProductsInBundle(ids : List[Int]) : List[Product] =
    ids map getProductById
}

Так что да, здесь есть немного псевдокода, но суть в том, что теперь у нас есть рекурсивный метод (getProductId вызывает getProductsById в случае пакета, который снова вызывает getProductId). С помощью насмешек есть моменты, когда мы можем отключить рекурсию, чтобы сделать вещи более тестируемыми. Но даже это сложно из-за того, что в определенном сопоставлении с образцом в методе есть вызовы актеров.

Это действительно идеальный шторм для меня... извлечение соответствия для случая "комплекта" в нижестоящий субъект может быть многообещающим, но это также означает, что нам теперь нужно иметь дело с циклической зависимостью (субъекту bundleAssembly нужен тип SpecificActor, который требует bundleAssembly...).

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

замечания

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

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

Так что, может быть, я прошу слишком много юнит-тестирования, но, пожалуйста, если у вас есть какая-то мудрость, поправьте меня!

2 ответа

Я не согласен с вашим утверждением: "Я не большой поклонник того, чтобы отодвигать их от логики, которая их использует".

Я считаю, что это является важным компонентом модульного тестирования и организации кода.

Джейми Аллен из Effective Akka утверждает следующее об экстернализации бизнес-логики (выделено мной):

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

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

package businessLogic

object ProductGetter {
  def passthroughToysOnly(products : List[Product]) : List[Toy] =
    products flatMap {p => 
      p.category match {
        case "toy" => Some(p)
        case _ => None
    }
  }
}

Это позволяет изменить методологию параллелизма на Futures, Java-потоки или даже еще не созданную библиотеку параллелизма без необходимости реорганизовывать мою бизнес-логику. Пакеты бизнес-логики становятся "что" в коде, библиотеки akka - "как".

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

Решение вашей конкретной проблемы: я бы удалил getActiveProductsByIds от актера. Если пользователь Актера хочет получать только активные продукты, оставьте его для фильтрации идентификаторов в первую очередь. Ваш актер должен сделать только одну вещь: GetProductById, Снова процитирую Аллена:

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

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

На днях я читал об этом и нашел предложение, которое раньше не пробовал: использовать шаблон наблюдателя. Идея состоит в том, чтобы оставить ваших актеров, которые будут заботиться только об обмене сообщениями (которые вам не нужно проверять, команда Akka сделает это за вас;) и транслировать события подписчикам. Таким образом, ваша логика полностью изолируется от актеров, что значительно облегчает тестирование.

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

Другие вопросы по тегам