主要想法:我们如何对具有相当复杂业务逻辑的 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 倍。本质上,代码都将围绕测试结构编写。
我对过度设计的测试也非常谨慎,因为如果他们正在测试复杂的消息序列而不是方法调用(我不确定除了使用模拟之外如何期待简单的语义调用),测试可能会成功但不会真的成功核心方法功能的真正单元测试。相反,它将只是代码(或消息传递系统)中控制流构造的直接反映。
所以也许是我对单元测试的要求太多了,但是如果你有一些智慧,请让我直截了当!