本文对应 Akka in Action 第三章的内容。
TDD 是 测试驱动开发(Test-Driven Development)的英文简称,是敏捷开发中的一项核心实践和技术,也是一种设计方法论。TDD 的原理是在开发功能代码之前,先编写单元测试用例代码,随后编写相关代码使得这些用例通过,然后循环添加功能,直至所有功能被开发完成。在开发轻量级项目时,这种敏捷开发的风格有很多优点:
- 系统可以随着详细的测试集一同发布。
- 开发人员可以清楚地意识到某个阶段已经结束了。
- 大部分时间代码都处于高质量状态。
Akka 系统中,Actor 构建于消息之上,因此它非常利于测试,开发者只需要通过发送消息来模拟行为,这和 TDD 的思路十分契合。
除了 TDD 之外,我们还可以将思路延伸到 BDD ( Behavior-Driven Development ):它通过编写 行为和规范 来驱动软件开发。BDD 的根基是语义简洁易懂的通用语言,或或者使用 DSL。使用 BDD 的团队 仅需要测试用例就能以用户故事的形式提供大量的功能文档。下面是截取自本章的代码片段,每个用例使用文本和 must
,should
等关键字构建规范,这非常容易解释,因此这可以让非技术人员,客户参与到需求的确认和验收当中。
"A Echo Actor" must {
"Reply with the same message after sending the message to it" in {
// TODO 具体实现
}
}
我们使用 scalaTest
作为单元测试框架,它是一个 xUnit
风格的测试框架,上文的代码块是 WordSpec
编写方式。更详细的内容,见:ScalaTest。
Actor 的测试要比通常的代码更困难,原因在于:
- 时间性 ( Timing ) :消息发送是异步的,难于知道何时断言单元测试中的期望值。
- 异步性 ( Asynchronicity ) :Actor 是在多个线程上并行执行的。多线程测试比单线程测试更加难以验证,并且需要并发原语,比如锁,锁存器和 Barrier 等。在 Akka 中,这是我们希望避免的东西。
- 无状态性 ( Statelessness ) :在测试中,我们希望能够访问 Actor 的状态,但是 Actor 的设计禁止这么做。
- 协议 / 整合 ( Collaboration / Integration ) :如果要对几个 Actor 集成测试,则需要窃听 Actor 的信息,并断言是否与期望值相同。
可见,只使用 ScalaTest 还不能直接测试 Akka 项目,好在 Akka 本身提供了 akka-testkit
模块。该模块提供多个测试工具,使得 Actor 测试变得容易很多。
- 单线程单元测试 —— 测试工具提供了
TestActorRef
来允许我们在单线程测试下访问底层的 Actor 状态,见后文的 SilentActor。 - 多线程单元测试 —— 测试工具提供了
TestKit
和TestProbe
来实现以下功能:从其它 Actor 接收响应,检查消息,设定特定消息到达的时间。Actor 通过常规的消息分发器运行在多线程环境中。 - 多 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 ) 的风格逐步完善测试代码:
- 首先在测试实现之前确保测试失败 ( 红 )
- 完善代码使测试用例通过 ( 绿 )
- 对代码重构使其美观。
单向消息
在发完即弃的消息传递中,一个标准的测试流程是:
- 发送消息。
- 在合适的时间片内检验 Actor 是否完成了它的工作。
在这个过程中,我们不关注消息是如何传达的,谁来传达的。不是所有的 Actor 都会在完成任务后返回消息给 sender()
,比如有些 Actor 在接收消息后只会 "悄悄地" 改变自己内部的状态。概括一下,Actor 存在三种变体:
- SilentActor:不会直接回复消息,但是会改变 Actor 自身内部的状态。
- SendingActor:接收消息并完成任务后向其它 Actor (s) 发送消息。
- 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 类型,以至于它还可以细分出一些变体:
Actor | description |
---|---|
MutatingCopyActor | Actor 将消息接收,并将修改后的副本传递给下一个 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
,它可以断言由某个活动触发的一系列消息都是符合期望的。
还有两个可用的方法:ignoreMsg
和 expectNoMsg
。ignoreMsg
接受一个 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 绑定 testActor
,EchoActor
的 sender()
总是指向 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()
就能够消息发送给测试代码。