Akka 实战:测试驱动开发 TDD

614 阅读6分钟

本文对应 Akka in Action 第三章的内容。

TDD 是 测试驱动开发(Test-Driven Development)的英文简称,是敏捷开发中的一项核心实践和技术,也是一种设计方法论。TDD 的原理是在开发功能代码之前,先编写单元测试用例代码,随后编写相关代码使得这些用例通过,然后循环添加功能,直至所有功能被开发完成。在开发轻量级项目时,这种敏捷开发的风格有很多优点:

  1. 系统可以随着详细的测试集一同发布。
  2. 开发人员可以清楚地意识到某个阶段已经结束了。
  3. 大部分时间代码都处于高质量状态。

Akka 系统中,Actor 构建于消息之上,因此它非常利于测试,开发者只需要通过发送消息来模拟行为,这和 TDD 的思路十分契合。

除了 TDD 之外,我们还可以将思路延伸到 BDD ( Behavior-Driven Development ):它通过编写 行为和规范 来驱动软件开发。BDD 的根基是语义简洁易懂的通用语言,或或者使用 DSL。使用 BDD 的团队 仅需要测试用例就能以用户故事的形式提供大量的功能文档。下面是截取自本章的代码片段,每个用例使用文本和 mustshould 等关键字构建规范,这非常容易解释,因此这可以让非技术人员,客户参与到需求的确认和验收当中。

"A Echo Actor" must {
  "Reply with the same message after sending the message to it" in {
        // TODO 具体实现
  }
}

我们使用 scalaTest 作为单元测试框架,它是一个 xUnit 风格的测试框架,上文的代码块是 WordSpec 编写方式。更详细的内容,见:ScalaTest

Actor 的测试要比通常的代码更困难,原因在于:

  1. 时间性 ( Timing ) :消息发送是异步的,难于知道何时断言单元测试中的期望值。
  2. 异步性 ( Asynchronicity ) :Actor 是在多个线程上并行执行的。多线程测试比单线程测试更加难以验证,并且需要并发原语,比如锁,锁存器和 Barrier 等。在 Akka 中,这是我们希望避免的东西。
  3. 无状态性 ( Statelessness ) :在测试中,我们希望能够访问 Actor 的状态,但是 Actor 的设计禁止这么做。
  4. 协议 / 整合 ( Collaboration / Integration ) :如果要对几个 Actor 集成测试,则需要窃听 Actor 的信息,并断言是否与期望值相同。

可见,只使用 ScalaTest 还不能直接测试 Akka 项目,好在 Akka 本身提供了 akka-testkit 模块。该模块提供多个测试工具,使得 Actor 测试变得容易很多。

  1. 单线程单元测试 —— 测试工具提供了 TestActorRef 来允许我们在单线程测试下访问底层的 Actor 状态,见后文的 SilentActor。
  2. 多线程单元测试 —— 测试工具提供了 TestKitTestProbe 来实现以下功能:从其它 Actor 接收响应,检查消息,设定特定消息到达的时间。Actor 通过常规的消息分发器运行在多线程环境中
  3. 多 JVM 测试 —— 这对测试远程 JVM 非常有用,我们在后续的分布式应用中会提到。

测试驱动开发 VS 行为驱动开发_weixin_33912246的博客-CSDN博客

本文基于 sbt 构建,所有的测试用例保存在 test/scala/aia.testdriven 当中。为了避免一些不必要的重复,下面声明一一个特质,它可以在测试结束后自动退出系统。

import akka.testkit.TestKit
import org.scalatest.{BeforeAndAfterAll, Suite}

// 自身特质,仅 TestKit with Suite 能够使用它。
trait StopSystemAfterAll extends BeforeAndAfterAll{
  this : TestKit with Suite =>
  override protected def afterAll(): Unit = {
    super.afterAll()
    system.terminate()
  }
}

在 sbt shell 中输入 test 命令可以一次性测试 test/ 目录下所有的用例,或者使用 testOnly <casePath> 测试指定的用例。下面是各种测试用例的通用结构:

// 向 TestKit 传入一个测试用的 ActorSystem(${name})
class FilteringActor01Test extends TestKit(ActorSystem("testSystem"))
with WordSpecLike			// WordSpec 风格编写用例
with MustMatchers			// must 断言
with StopSystemAfterAll {    // 测试结束后退出 akka 系统
 "An Actor" must {
   "finish the goal-1" in {
       fail("not implemented yet")
   }
   "finish the goal-2" in {
       fail("not implemented yet")
   }
 }
}

我们通过红-绿-重构 ( red-green-refactor ) 的风格逐步完善测试代码:

  1. 首先在测试实现之前确保测试失败 ( 红 )
  2. 完善代码使测试用例通过 ( 绿 )
  3. 对代码重构使其美观。

工程师必知的代码重构指南 - 知乎 (zhihu.com)

单向消息

在发完即弃的消息传递中,一个标准的测试流程是:

  1. 发送消息。
  2. 在合适的时间片内检验 Actor 是否完成了它的工作。

在这个过程中,我们不关注消息是如何传达的,谁来传达的。不是所有的 Actor 都会在完成任务后返回消息给 sender(),比如有些 Actor 在接收消息后只会 "悄悄地" 改变自己内部的状态。概括一下,Actor 存在三种变体:

  1. SilentActor:不会直接回复消息,但是会改变 Actor 自身内部的状态。
  2. SendingActor:接收消息并完成任务后向其它 Actor (s) 发送消息。
  3. SideEffectingActor:产生某种副作用 ( 指产生 log,控制台打印,数据库交互等 )。

SilentActor

第一个简单的测试用例从 SilentActor 开始:它将来自外界的 SilentMessage 解包后把数据添加到内部的 internalState 属性当中。

class SilentActor extends Actor {
  // SilentActor 的内部状态。  
  private var internalState: Vector[String] = Vector[String]()
  override def receive: Receive = {
    // 用于单线程测试  
    case SilentMessage(data) =>  internalState = internalState :+ data
    // 用于多线程测试  
    case GetState(ref) => ref ! this.internalState
  }
  
  // 对外界返回状态
  def state: Vector[String] = internalState
}

object SilentActor {
  case class SilentMessage(str: String)
  case class GetState(ref: ActorRef)
}

由于这类 Actor 不回传消息,因此需要借助其它的手段观测到它内部的细节。第一个测试在单线程环境下执行,此时可以使用 TestActorRef 来创建 ActorRef 而不使用 ActorSystem。通过它创建出的 TestActorRef 可以通过调用 underlyingActor 暴露 Actor 的内部细节。

  "A Silent Actor" must{
    "change state when it receives a message, single-thread." in {
      import aia.driven.SilentActor._
      val silentActor: TestActorRef[SilentActor] = TestActorRef[SilentActor]
      silentActor ! SilentMessage("whisper")     
      silentActor.underlyingActor.state must contain ("whisper")
    }
  }

注意,Actor 受多线程访问的保护,加上同一时刻 Actor 仅处理一条消息,因此设置 / 修改 internalState 的操作是线程安全的。内部状态最好以 var + 不可变数据结构的组合代替值 val ( value ) + 可变数据结构。这样,即便是 Actor 通过 get 方法将内部状态共享给其它 Actor 时,也可以防止意外地共享可变状态。

第二个测试在多线程环境下 ( 即通常我们编写的 Akka 代码环境 ) 运行。这时必须要引入另外一个 Actor 接收 SilentActor 的状态,这里使用 TestKit 提供的 testActor

// "A Silent Actor" must ...
    "change state when it receives a message, multi-thread." in {
        import aia.driven.SilentActor._
        // 这里的 system 指向 TestKit 中传入的 ActorSystem("testSystem")
        val silentActor: ActorRef = system.actorOf(Props[SilentActor])
        silentActor ! SilentMessage("whisper1")
        silentActor ! SilentMessage("whisper2")
        // testActor 是 TestKit 提供的。
        silentActor ! GetState(testActor)
        expectMsg(Vector("whisper1","whisper2"))
    }

注意,在测试代码中直接调用 ! 发消息,默认的发送者是 noSender ( 在源码中,它指向 null )。SilentActor 就不能通过 sender() 找到此类消息的发送者,所回传的消息会变成死信 dead letter。这导致测试用例无法捕获到消息,因此目前来说 testActor 是不可或缺的。不过,后文中会介绍如何通过 ImplicitSender 特质将 testActor 绑定到测试代码自身。

为了避免死信问题,测试代码获取 SilentActor 的内部状态的方式是:将 testActor 引用随着消息一同传递过去,SilentActor 通过 state 方法将内部状态返回给 testActor。随后,测试代码可以使用 expectMsg 或者 expectMsgPF 等方法检验 testActor 接收的消息。这个等待是同步的,如果 expectMsg 方法断言成功,则测试用例通过,否则失败。

超时设置有一个默认值 3s,它可以通过 akka.test.single-expect-default 做配置,另外拓展因子 ( dilation factor ) 可以在多环境下兼容不同性能的机器,比如对于配置较低的机器,等待时间也会更长一些。

SendingActor

SendingActor 是更常见的 Actor 类型,它们通过 props 方法持有另一个 ActorRef 来保持双向消息交互 ( 这两个 Actor 之间是平级的 )。注意,Props(...) 方法返回的是创建一个 ActorRef 的 配置 ( property )。props 被推荐声明在 Actor 伴生对象当中,这有效避免访问 Actor 的内部状态。创建 Props 的过程中,如果使用了 Actor 内部的属性,则会引起数据竞态。见: 在 Actor 伴生对象内提供 Props 的工厂方法 - CSDN

Props 的参数是懒加载的,这意味着相关的 ActorRef 只会在 Akka 系统需要时被创建。关于 Scala 懒加载的内容,见:探究 Scala 非严格求值与流式数据结构 - 掘金 (juejin.cn)

下面的 SendingActor 接收一个乱序列表,然后回传它的有序排列:

class SendingActor(receiver: ActorRef) extends Actor {
  override def receive: Receive = {
    case SortEvents(unsorted) =>receiver ! SortedEvents(unsorted.sortBy(_.id));
  }
}

object SendingActor {
  def props(ref: ActorRef) = Props(new SendingActor(ref))
  case class Event(id: Long)  
  case class SortEvents(unsorted: Vector[Event])
  case class SortedEvents(sortedEvents: Vector[Event])
}

下面的逻辑并不难理解:发送一个乱序的 Vector[Event],并断言最终得到一个有序 Vector[Event]。这次使用 expectMsgPF 接收一个偏函数:它可以接收多种消息类型并进行断言。注意这里的断言使用了 be(...) 方法,它衔接在 must 后面,相当于 mustEqual

"A Sending Actor " must {
  "send a message to another actor when it's finished processing." in {
    val props: Props = SendingActor.props(testActor)
    val sendingActor: ActorRef = system.actorOf(props, "sendActor")

    val size = 1000
    val maxInclusive = 10000

    def randomEvents(): Vector[Event] = {
      for {_ <- 0 until size} yield Event(Random.nextInt(maxInclusive))
    }.toVector
    // ----创建乱序 id 的 Event 向量---- //
    val unsorted: Vector[Event] = randomEvents()
    val sortEvents: SortEvents = SortEvents(unsorted)

    sendingActor ! sortEvents
    expectMsgPF() {
      case SortedEvents(events) =>
        // must( events.size == size)
        events.size must be(size)
        // must(unsorted.sortBy(_.id) == events)
        unsorted.sortBy(_.id) must be(events)
    }
  }
}

SendingActor 是普遍 Akka System 中普遍存在的 Actor 类型,以至于它还可以细分出一些变体:

Actordescription
MutatingCopyActorActor 将消息接收,并将修改后的副本传递给下一个 Actor。
ForwardActor只简单转发消息的 Actor。
TransformingActor将消息转换为另一个类型的 Actor。
FilteringActor根据设定选择性接收消息的 Actor。
SequencingActor根据接收到一条消息创建出多条消息并逐个发送的 Actor。

刚才的例子属于 MutatingCopyActor。ForwardActor 和 TransformingActor 都可以使用类似的方法进行测试。而 FilteringActor 稍微不同一些,因为测试用例需要连续地向它发送并接收消息。下面是一个实例:此 FilteringActor 借助一个 buffer 过滤重复的消息 id ,只有在接到不重复的消息时才会给 testActor 回传消息。

class FilterActor(nexus : ActorRef,bufferSize : Int) extends Actor {
  private var buffer : Vector[String] = Vector[String]()
  override def receive: Receive = {
    case UniqueEvent(id) =>
      if(!buffer.contains(id)){
        buffer = buffer :+ id
        nexus ! id
        if(buffer.size > bufferSize) {buffer = buffer.tail}
      }
  }
}

object FilterActor {
  def props(nexus : ActorRef,bufferSize : Int): Props = Props(new FilterActor(nexus, bufferSize))
  case class UniqueEvent(id : String)
}

在测试时,有效消息 id 的范围在 1 - 5 之间,但是消息数大于 5,测试期望被过滤回传后的消息 id 是不重复的。如果 id 还以递增的顺序被记忆,那么最终经过 .toList 处理后应该得到 List("1", "2", "3", "4", "5")

当前的测试用例使用 receiveWhile 方法取出 testActor 连续收到的消息,当接收到 id 为 6 的消息之后进行断言。

"A Filtering Actor" must {
    "get List(1,2,3,4,5) after sending the sequential and repetitive msg" in {
        val ref: ActorRef = system.actorOf(FilterActor.props(testActor, 5))
        val idList = List(1,2,3,3,2,1,4,5,6)
        for{i <- idList}{ref ! UniqueEvent(s"${i}")}

        //用于接收多条消息。接收到 6 时结束
        val gets : List[String]  = receiveWhile(){
            case id : String if id.toInt <= 5 => id
        }.toList

        gets must be(List("1","2","3","4","5"))
    }
}

receiveWhile 方法也可以用于测试 SequencingActor,它可以断言由某个活动触发的一系列消息都是符合期望的。

还有两个可用的方法:ignoreMsgexpectNoMsgignoreMsg 接受一个 PartionalFunction[Any,Boolean] 类型的偏函数,它声明在 receiveWhile 之前,使得 testActor 可以在接受消息的同时进一步筛选出特定信息。

// PF 接收 [Any,Boolean] 类型,返回 true 的消息会被忽略。
// 在调用 receiveWhile() 之前调用。
ignoreMsg {
    case id : String if id.toInt == 3 => true
    case _ => false
}

//用于接收多条消息。
val gets : List[String]  = receiveWhile(){ case id : String if id.toInt <= 5 => id}.toList

// 消息 "3" 被 testActor 忽略了。
gets must be(List("1","2","4","5"))

expectNoMsg 则表示在设定时间片内 testActor 不应当接受到任何消息,否则断言不通过。

// 设定忽略所有消息。
ignoreMsg { case _ => true }

// 开始接受消息
receiveWhile(){ case id : String if id.toInt <= 5 => id}.toList

// 没有任何消息到达。
expectNoMsg()

有时我们需要一个能够同时群发消息,并接收的 Actor,这种情况下使用 TestProbe() 更加方便。无需再为每一个测试的 Actor 绑定 testActorEchoActorsender() 总是指向 TestProbe 的 ActorRef。

class EchoActor extends Actor {
  override def receive: Receive = {
    case msg => sender() ! msg
  }
}

val probe: TestProbe = TestProbe()

val probeRef: ActorRef = probe.ref
val rec1: ActorRef = system.actorOf(Props[EchoActor], "echo-actor-01")
val rec2: ActorRef = system.actorOf(Props[EchoActor], "echo-actor-02")

probeRef.tell("Hello",rec1)
probeRef.tell("World",rec2)

probe.receiveN(2).toList must be(List("Hello","World"))

[转] Akka 使用系列之二: 测试 - Pekkle - 博客园 (cnblogs.com)

SideEffectingActor

下面是一个 SideEffectingActor 的演示:它只负责简单地将消息记录到日志中 ( 这里引入了 ActorLogging 特质 )。

// 拓展日志记录功能
class Greeter extends Actor with ActorLogging {
  override def receive: Receive = {case Greeting(msg) => log.info("Hello {}",msg)}
}

object Greeter {case class Greeting(message : String)}

为了拓展日志记录功能,需要从一个包含事件监听器 akka.testkit.TestEventListener 的配置中创建系统。

// ActorSystem(SideEffecting)
object SideEffectActor01Test {
  // 从包含测试事件监听器的配置中创建系统
  val testSystem: ActorSystem = {

    val config: Config = ConfigFactory.parseString(
      """
        |akka.loggers = [akka.testkit.TestEventListener]
        |""".stripMargin
    )
    ActorSystem("testSystem",config)
  }
}

这个例子在单线程中测试,因此不妨将它的配置 Props 绑定在的指定的分发器上。使用 EventFilter 对象可以对日志信息进行检测并过滤,如下面的代码块要求日志需要记录 2 次 Hello World! 信息。测试代码写在 intercept 闭包 内部。

"The Greeter" must {
    "say Hello World! when a Greeting(\"World!\")is sent to it" in {
        // 指定消息分发器
        val dispatcherId: String = CallingThreadDispatcher.Id
        val greeter: Props = Props(new Greeter()).withDispatcher(dispatcherId)

        // system 即传入的 testSystem
        val greetRef: ActorRef = system.actorOf(greeter)

        // 对事件进行监听。
        // 日志信息由 akka.testkit.TestEventListener 获取。
        // 不满足发生次数则测试失败。
        EventFilter.info(message = "Hello World!",occurrences = 2).intercept {
            greetRef ! Greeting("World!")
            greetRef ! Greeting("World!")
        }
    }
}

双向消息的便捷形式

在 SendingActor 类型的消息测试中已经介绍过双向消息发送的例子,通常的方法是创建 Actor Props 的时候传入 ActorRef。在本测试中,将使用 ImplicitSender 特质。该特质将隐式的消息发送者替换成了 testActor 引用。

class EchoActorTest extends TestKit(ActorSystem("testSystem"))
  with WordSpecLike
  with MustMatchers
  with ImplicitSender
  with StopSystemAfterAll {
  "A Echo Actor" must {
    "Reply with the same message after sending the message to it" in {
      val ref: ActorRef = system.actorOf(Props[EchoActor])
      ref ! "Hello?"
      expectMsg("Hello?")
    }
  }
}

class EchoActor extends Actor { override def receive: Receive = { case msg => sender() ! msg } }

EchoActor 仍然只做消息的简单回传。不同的是,它无需再通过构造器绑定一个监听器 ActorRef,只需要通过 sender() 就能够消息发送给测试代码。