7

主要想法:我们如何对具有相当复杂业务逻辑的 Akka 演员进行单元测试(或重构以促进单元测试)?

我一直在我公司的一个项目中使用 Akka(一些非常基本的东西正在生产中),并且一直在不断重构我的演员,研究和试验 Akka 测试套件,看看我是否能做对......

基本上,我读过的大部分书都是随便说的“伙计,你需要的只是测试包。如果你使用的是模拟,那你就错了!!” 但是文档和示例非常简单,以至于我发现了许多未涵盖的问题(基本上,它们的示例是非常人为的类,它们具有 1 个方法并且不与其他参与者交互,或者仅以微不足道的方式,例如方法结束时的输入输出)。顺便说一句,如果有人可以向我指出具有任何合理复杂性的 akka 应用程序的测试套件,我将不胜感激。

在这里,我现在至少会尝试详细介绍一些具体案例,并且想知道人们会称之为“Akka 认证”方法(但请不要含糊其词……我正在寻找 Roland Kuhn 风格的方法论,如果他是曾经真正深入到具体问题)。我会接受涉及重构的策略,但请注意我对场景中提到的这一点的焦虑。

场景1:横向方法(在同一个actor中调用另一个方法)

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 个 fetch 方法,一个是单数,一个是多个。在单一情况下,测试很简单。我们设置了一个 TestActorRef,在构造函数中注入一些探针,并确保正确的消息链正在触发。

我在这里的焦虑来自于多重 fetch 方法。它涉及一个过滤步骤(仅获取有效的产品 ID)。现在,为了对此进行测试,我可以设置相同的场景(ProductActor 的 TestActorRef 用探针替换构造函数中调用的参与者)。但是,为了测试消息传递流,我必须模拟所有消息链接,不仅是对 FilterActiveProducts 的响应,而且是所有已经被先前的“getProductById”方法测试覆盖的消息链(那时不是真正的单元测试) , 是吗?)。显然,就必要的消息模拟量而言,这可能会失控,并且更容易验证(通过模拟?)这个方法只是为过滤器中幸存的每个 ID 调用。

现在,我知道这可以通过提取另一个参与者来解决(创建一个获取多个 ID 的 ProductCollectorActor,并简单地调用 ProductActor,并为每个通过过滤器的 ID 发出一个消息请求)。但是,我已经计算过了,如果我要对我拥有的每个难以测试的同级方法集进行这样的提取,我最终会为相对少量的域对象生成数十个参与者。样板开销的数量会很多,而且系统会相当复杂(更多的参与者只是执行本质上是一些方法组合)。

旁白:内联(静态)逻辑

我试图解决这个问题的一种方法是将内联(基本上任何不仅仅是一个非常简单的控制流的东西)移动到伴随对象或另一个单例对象中。例如,如果上述方法中有一个方法是过滤掉产品,除非它们匹配某种类型,我可能会执行以下操作:

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)。通过模拟,我们可以在某些地方切断递归以使事情更可测试。但即使这样也很复杂,因为在方法中的某些模式匹配中存在参与者调用。

这对我来说真的是一场完美的风暴......将“bundle”案例的匹配提取到较低的actor中可能是有希望的,但这也意味着我们现在需要处理循环依赖(bundleAssembly actor需要typeSpecificActor,它需要bundleAssembly ...)。

这可以通过纯消息模拟来测试(创建存根消息,我可以在其中判断它们将具有什么级别的递归并仔细设计此消息序列),但是如果需要比单个额外参与者调用更多的逻辑,这将非常复杂且更糟捆绑类型。

评论

提前感谢您的帮助!我实际上对最小的、可测试的、精心设计的代码充满热情,并且我担心如果我尝试通过提取来实现所有目标,我仍然会遇到循环问题,仍然无法真正测试任何内联/组合逻辑并且我的代码将是对于微小的单一到极端责任演员来说,它比使用大量样板文件要冗长 1000 倍。本质上,代码都将围绕测试结构编写。

我对过度设计的测试也非常谨慎,因为如果他们正在测试复杂的消息序列而不是方法调用(我不确定除了使用模拟之外如何期待简单的语义调用),测试可能会成功但不会真的成功核心方法功能的真正单元测试。相反,它将只是代码(或消息传递系统)中控制流构造的直接反映。

所以也许是我对单元测试的要求太多了,但是如果你有一些智慧,请让我直截了当!

4

2 回答 2

2

我不同意您的说法“我不喜欢将这些远离使用它们的逻辑”。

我发现这是单元测试和代码组织的重要组成部分。

Jamie Allen 在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 库成为“如何”。

如果您隔离业务逻辑,那么所有接收方法都将成为将消息发送到外部函数的简单“路由器”。因此,如果您使用单元测试来测试您的业务逻辑,那么您需要对 Actor 进行的唯一测试就是确保案例模式正确匹配。

解决您的具体问题:我会getActiveProductsByIds从演员中删除。如果 Actor 的用户只想获得活跃的产品,则让他们先过滤 id。你的演员应该只做一件事:GetProductById. 再次引用艾伦的话:

让演员执行附加任务非常容易——我们只需将新消息添加到其接收块中,以允许其执行更多不同类型的工作。但是,这样做会限制您组合参与者系统和定义上下文分组的能力。让您的演员专注于单一类型的工作,并在此过程中让自己灵活地使用它们。

于 2016-02-28T12:13:13.900 回答
0

首先,这是一个非常有趣的问题。总的来说,Akka 文档非常好,测试部分有许多有见地的注释,以避免常见的陷阱并建议最佳实践。

前几天我在读这个,发现了一个我以前没有尝试过的建议:使用观察者模式。这个想法是让你的 Actor 只关心消息传递(你不需要测试,Akka 团队会为你做这件事;)并向订阅者广播事件。通过这种方式,您的逻辑与 Actor 完全隔离,因此更容易测试。

注意:我还没有在生产系统中尝试过这个,但是因为你提到你的生产系统中只有非常基本的东西,所以这可能值得一试。

于 2016-02-29T09:37:14.650 回答