第十章:Scala之单元测试

主要内容:

  1. ScalaCheck自动化
  2. 使用JUnit来测试Scala代码
  3. 使用依赖注入编写更好的测试
  4. Specs行为驱动开发
  5. 基于actor系统测试

截至目前为止,所展示的代码都没有单元测试——为什么我们现在要关心这个问题?围绕代码写测试,而又不提及它做了什么,是希望你更专注于Scala语言本身。现在,本章的目标是希望编写自动化的Scala单元测试,用于更好地构建高质量的软件。

编写精良1的代码,从你编程单元测试开始。通常感性认识是很难编写单元测试,但这章要改变这种心态。我将为你展示,如测试驱动开发和持续集成的练习开始,如何在你的Scala项目中编写测试。测试驱动开发(test-driven development,TDD)就是指在你编写代码之前编写测试。我知道这有点落后,但我保证,这章结束后它将更有意义。你将学习的测试,更多的是设计一个测试,正如设计你的软件一样。你的设计工具将会是代码——更特别地,测试的代码。

我将由介绍自动化测试和开发者如何在真实环境中使用来开始。有两种自动化测试:一种是你自己写的,一种是从你的代码中生成的。首先要介绍的是代码生成的测试,使用ScalaCheck工具,因为它比较容易。Sala是一个强静态类型语言,利用这点,诸如ScalaCheck这样的工具可以为你构建在类型上的函数或类生成单元测试。ScalaCheck是自动化测试的一种很好实现。但要完全信服自动化测试带来的好处,你需要手动编写它们。

本章的主要重点是自动化测试。很多测试工具都可以用于Scala代码之中,但本章仅使用两个工具:JUnit(www.junit.org) 和 Specs (http://etorreborre.github.com/specs2/ )。

如果你是一个Java开发者,并曾经使用过JUnit,在Scala中使用它则会很容易。Specs是一个用于Scala并由Scala编写的单元测试工具,它在你的Scala代码中提供了更多的表达式。我将带你编写这些测试,让该工具为你可用,以及设计技术让你可以使你的设计进行测试。你设计的可测试属性,决定了编写测试的难易程度。我将向你展示如何在Scala中实现依赖注入。

依赖注入是一个设计模式,以使得开发者更容易测试他们的代码。作为一门混编语言,Scala提供了大量的抽象技术,你可以使用这些技术实现依赖注入。本章将探索所有这些内容。我也将向你展示如何使用一个框架,如Spring,Java的一个流行的依赖注入框架,在Scala中的实现。

编写自动化测试通常说很难实现,但事实上你使用了正确的工具和技术它将变得容易。不带更多的等待,让我们由问题开始:什么是自动化?它们是如何在软件开发处理中融合?

10~1〖Importance of automated testing〗P284

我不关心你认为你设计写得如何好,如果在5分钟内我不能进入你的设计,并任意编写测试,它就没有你想象的那么好,无论你是否清楚,你将为它付出代价。——Michael Feathers

自动化测试用于测试那些用于记录的、预编写的,能够被机器运行而不用手动干涉。允许你运行这些测试的工具称为 自动化测试工具 。前面提及过,有两种自动化测试:一是自己写的,一是由工具生成的。不管自动化测试如何创建,重要的是应该明白它们是如何运行的。为了体会它带来的好处,让我们探索下自动化测试如何融入到敏捷软件开发2 (agile software development)处理中。机会是你已经在做自动化测试,如果没有,这些测试仍然有很大的价值。

在敏捷软件开发进程中,团队不会分析和设计应用程序;他们使用演化设计[3] (evolutionary design)来构建。在这些开发进程,开发者仅设计今天的,不考虑明天的事情。他们只设计今天所知道的程序,并且明白今天的设计决定到了明天或许就是错误的。他们逐步实现程序的功能。在这种模式,应用程序的设计发展和经历了很多次的改变。两个重要的问题需要回答:

  • 自动化测试应该为发展中的设计做些什么?
  • 为什么发展中的设计,比起应用程序预先的设计要好?

第一个问题在本章来说比较重要。自动化测试是重要的,因为你的应用程序经历了多次的改变,你可能脱离了原来的功能。在这种不断变化的环境中,你不可能继续手动进行测试。你需要重复运行自动化测试,以确保你的应用表现为你所期待的,以及不会发生不期望的事情。

第二个问题是为什么演化设计更好。为什么不预先设计好应用,这样我们就不用频繁地去改变?在某些情况下,你会预先设计,如商业产品的集成,你不需要过渡控制它的源代码。

但更多的情况下是,你需要应付日益改变的需求。敏捷软件开发尝试减少这些改变带来的花销、修正这些预先设计,但面对需求的改变,这很难实现。最终变成了维护和更改预设计4的开销。

你不能认为你实现了应用的所有功能,或者你应用上的各种各样的组件都能彼此正常工作。应用做得越大,预设计就变得越困难。敏捷处理强调更多的增量的实现,你构建了一点点,那么就测试一点点,并带客户重现这些功能,以让他们给反馈。自动化测试,并给予反馈以确保应用工作,这步是至关重要的。

自动化测试仅帮你发现问题,它们也作为应用的文档工作。如果你想要理解一个类或组件的行为,你可以查阅相关的单元测试。10.6小节将向你展示如何使用Specs开发可执行的文档。传统方式的文档的问题是,文档陈旧的很快,随着代码的改变我们大多数都会忘记去更新。但如果你扮演文档角色的测试,随着代码的改变,也将保持最新,因为每一个代码的改变都会预先于测试处理。

有很多种自动化测试类型,举几个来说:基于规范的(specification-based)、单元的、集成的、功能的、以及回归测试(regression)。本章专注于规范测试和单元测试的驱动测试软件。其它类型的测试同样扮演了重要的角色,但超出了本章的范围。在规范测试中,应用的行为表述为一个可执行的描述,测试生成工具打破这种描述。另一方面,单元测试就像是你编写了设计,以及验证。

如果你没有做过任何自动化测试,可能需要花些时间来习惯。但也不用担心,过早关心只会得不偿失。你将会迅速改变,因为现在你的测试需要提供反馈。

我会从使用ScalaCheck开始讨论如何生成自动化测试,以此你可以了解自动化测试带来的好处,当你在Scala项目中学习编写这些测试时。

Specification Based Testing Technique is also known as Behavior Based Testing and Black Box Testing techniques because in this testers view the software as a black-box. As they have no knowledge of how the system or component is structured inside the box. In essence, the tester is only concentrating on what the software does, not how it does it.

Both Functional Testing and Non-Functional Testing is type of Specification Based Testing.

Specification Based Test Design Technique uses the specification of the program as the point of reference for test data selection and adequacy. A specification can be any thing like a written document, collection of use cases, a set of models or a prototype.

10~2〖Automated test generation using ScalaCheck〗P286

ScalaCheck是一个用于Scala和Java的测试工具,生成的测试数据建立在属性规范上。它的基本原理是,定义一个属性指定代码片段的行为,ScalaCheck将自动生成随机的测试数据,以检测这些属性是否为true。这里不要把ScalaCheck的属性跟JavaBean的属性弄混。在ScalaCheck,一个属性是一个可测试的单元。为了在ScalaCheck中创建一个新的属性,你必须创建一个语句来描述你想要测试的行为。例如,下面我声明了reverse方法定义在String类中:

1
2
val anyString = "some string value"
anyString.reverse.reverse == anyString

我想要在一个String实例中,reverse被调用两次,并得到相同的结果断言。ScalaCheck的任务就是由生成的随机测试数据伪造这些语句。下面我们小试一下。创建一个名为scalacheck的目录,添加build.sbt文件到该目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
name := "ScalaCheckExample"
version := "1.0"
organization := "Scala in Action"
scalaVersion := "2.10.0"
resolvers ++= Seq(
"Sonatype Snapshots" at "http://oss.sonatype.org/content/repositories/
snapshots",
"Sonatype Releases" at "http://oss.sonatype.org/content/repositories/
releases"
)
libraryDependencies ++= Seq (
"org.scalacheck" %% "scalacheck" % "1.10.0" % "test"
)
// append options passed to the Scala compiler
scalacOptions ++= Seq("-deprecation", "-unchecked", "-feature")

项目会自动下载ScalaCheck依赖到项目中(别忘了reload 和 update)。你也可以在 (http://code.google.com/p/scalacheck/downloads/list) 下载最新版本。本章的所有例子都将使用到SBT,因为需要频繁的构建和编译。下一个小节,你将创建你的第一个ScalaCheck测试,并验证reverse方法的断言。

1021〖Testing the behavior of a string with ScalaCheck〗P287

要在ScalaCheck中创建一个新的属性,你需要使用org.scalacheck.Prop特质。ScalaCheck中的属性,由Prop特质的实例表示。这里有几种方式创建ScalaCheck属性的一个实例,其中一个方式是使用org.scalacheck.Prop.forAll。

forAll是一个工厂方法,它用于创建一个用于ScalaCheck的一个属性。该方法接收一个Boolean的函数参数,它可以接收任何类型的参数,只要有生成器。生成器(Generators)是有ScalaCheck用于生成测试数据的组件。(本节后面会介绍更多关于生成器的内容) 下面是一段属性的语法:

1
Prop.forAll((a: String) => a.reverse.reverse == a)

上面这段代码的意思是:对于所有的字符串,表达式 (a: String) => a.reverse.reverse == a 应该始终为true。ScalaCheck会使用生成器为String类型生成任意的字符类型数据来验证该语句。要在SBT中运行该属性,你需要将它包装在类内部。org.scalacheck.Properties表示了ScalaCheck的一个集合属性,SBT内置支持运行Properties:

1
2
3
4
5
6
7
8
9
package checks
import org.scalacheck._

object StringSpecification extends Properties("String") {

property("reverse of reverse gives you same string back") = {
Prop.forAll((a: String) => a.reverse.reverse == a)
}
}

Figure 10.1

将上述代码保存在 StringSpecification.scala文件,置于src/test/scala目录中,并在SBT命令提示符中运行test动作。如果设置正确下,你会发现ScalaCheck会尝试100次来篡改属性,直到失败。

100次测试后,它应该安全地告诉我们这个属性始终为true。让我们添加另外一个属性来验证两个字符x和y。表达式 x.startWith(y) 应该等于 x.reverse.endsWith(y.reverse)。下面是代码:

1
2
3
property("startsWith") = Prop.forAll { (x: String, y: String) =>
x.startsWith(y) == x.reverse.endsWith(y.reverse)
}

这个为true吗?继续并尝试看看,ScalaCheck会不会出错。测试100次后,该属性为true。下面我们创建一个不总是为true的属性,看看ScalaCheck能否捕获到。这个语句是:对于任意两个字符x和y,表达式 x > y 等于 x.reverse < y.reverse。下面是该表述:

1
2
3
property("string comparison") = Prop.forAll {(x: String, y: String) =>
x > y == x.reverse > y.reverse
}

在中情况,ScalaCheck会失败并显示哪个表达式不能为true。输出可能不总是可见的,因为ScalaCheck使用了从Char.MIN_VALUEChar.MAX_VALUE的值。下面为该String 范类完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package checks
import org.scalacheck._

object StringSpecification extends Properties("String") {

property("reverse of reverse gives you same string back") = {
Prop.forAll((a: String) => a.reverse.reverse == a)
}

property("startsWith") = Prop.forAll { (x: String, y: String) =>
x.startsWith(y) == x.reverse.endsWith(y.reverse)
}

property("string comparison - WILL FALL") =
Prop.forAll { (x: String, y: String) =>
x.compare(y) == x.reverse.compare(y.reverse)
}
}

上述代码你为String类创建了一个范式,当然,你不需要指定该类的具体行为,但你可以看到ScalaCheck范式如何工作的。继承了Properties特质,以使得能够在SBT下运行。每个需要被验证的语句包装在ScalaCheck的属性中。你使用Prop.forAll工厂方法来创建一个新的属性,即传递一个函数,该函数的语句能够被ScalaCheck捕获并验证。ScalaCheck通过内建的生成器,传递任意的测试数据,并执行该函数。

现在,我希望你已经理解ScalaCheck属性是如何被创建,以及如何用于测试Scala行为代码。

注意 自动化测试不是来源于ScalaCheck,而是来源于一个叫做QuickCheck5的测试工具,这个工具用于Haskell语言。有时这些工具也叫做 基于泛型的单元测试工具(specification-based unit testing tools)。你需要在属性词法内提供一个范式的的类或方法。这种基于范式的单元测试工具重度依赖于类型系统的正确性。因为Scala和Java都是静态类型语言,ScalaCheck有很好的方式来创建泛型,以及添加到你的项目中。

下小节讨论ScalaCheck生成器,当时机成熟,你可以为新类型创建自定义的生成器。

1022〖ScalaCheck generators〗P289

上一小节,你编写了第一个ScalaCheck范式,不用理会生成器,为什么现在要讲?原因是,你不用担心生成器,对于String类型(其它类型也一样6),ScalaCheck知道如何生成测试数据,但如果你创建了新的类型怎么办?这时要自己实现。好消息是ScalaCheck提供了所有的你需要生成器构建块。

ScalaCheck的生成器代表了生成测试数据,org.scalacheck.Gen类代表了这些数据。把生成器认为是接收某些生成参数、以及返回某些生成的值的函数。对于一些组合参数,该生成器可能不会生成任何值。一个代表类型 T 的生成器,可以由一个类型为 Gen.Params => T 的函数表示。ScalaCheck库中早已装载有各种各样的生成器,但其中一个特别重要:arbitrary生成器。它是一个特殊的生成器,它支持任意类型、可以生成任意值。在前面小节中,你用于String范式测试的生成器就是这个生成器。要运行任意范式,ScalaCheck需要一个生成器来生成测试数据,因此生成器在ScalaCheck中扮演了重要的角色。下小节为你展示如何创建这些自定义生成器。

1023〖Working with ScalaCheck〗P290

本小节将为你展示一个使用ScalaCheck的真实案例。在该案例中,你不需要为String类型编写范式,而是你将创建的类型。与其创建新的类型,让我们看看scala.Either 这个类。它和你创建的、或项目中处理的类型的复杂性,十分接近。在Scala,Either类型允许你表示两种可能的值:Left和Right。通常,按照惯例,Left表示失败、Right表示成功。

注意:查阅Either类型7scaladoc 感知认识下你可以用该类型做些什么。

本节,将会为Either的API的方法添加范式测试。首先,我列出你需要测试的范式。明显地不是一个详尽的列出来,但却是一个好的起点:

  1. Either会有值Left或Right,但不会同时出现。
  2. 左折叠(fold on Left)产生的值由Left组成。
  3. 右折叠(fold on Right)产生的值由Right组成。
  4. 交换(swap)返回Left到Right的值,反之亦然。
  5. getOrElse作用在Left,返回Left值,右边(Right)则返回给定的参数。
  6. forAll作用于Right,如果是左边(Left)返回true;右边(Right)返回应用给定函数的值。

这些范式的复杂度,往下越复杂,但你将看到如何轻松地实现这些范式。

首先,为Either类型,创建一个自定义的ScalaCheck生成器,因为还没有Either类型的内置生成器。在ScalaCheck中创建新的生成器,和组合已有的生成器一样简单。简单起见,仅为Left和Right创建可以生成Int的生成器(之后会介绍如何参数化这个生成器)。为Left创建一个新的生成器,使用存在的生成器为Int值创建Left实例:

1
2
3
import Gen._
import Arbitrary.arbitrary
val leftValueGenerator = arbitrary[Int].map(Left(_))

上述代码片段创建了一个Int类型生成器的新实例,并映射为Left创建值。类似地,创建Right实例,使用下面代码:

1
val rightValueGenerator = arbitrary[Int].map(Right(_))

要成功地创建Either类型的实例,你需要任意生成Left或Right的实例。为了解决这类问题,ScalaCheck Gen对象装载了辅组方法 如 oneOf 或 frequency,称为组合。它允许你组合多个生成器。例如,你可以使用 Gen.oneOf组合leftValueGenerator 和 rightValueGenerator 来创建Either类型的生成器。oneOf 确保 Left 和 Right值是任意生成的:

1
implicit val eitherGenerator = oneOf(leftValueGenerator, rightValueGenerator)

通过定义生成器为一个 implicit val,你不需要传递它给ScalaCheck属性——ScalaCheck会自动拾取。这里你定义的生成器仅生成Int值,如果你需要处理其它类型,你也可以这样定义:

1
2
3
4
5
implicit def arbitraryEither[X, Y](implicit xa: Arbitrary[X],
ya: Arbitrary[Y]): Arbitrary[Either[X, Y]] =
Arbitrary[Either[X, Y]](
oneOf(arbitrary[X].map(Left(_)), arbitrary[Y].map(Right(_)))
)

为Left或Right定义的生成器都被参数化,因此它们将接收任何类型的、用于arbitrary定义的参数。

你也可以用Gen.frequency 来对每个独立的生成器和使用获得更多的控制。如果你想要 leftValueGenerator 比 rightValueGenerator高出 75%的使用次数,你可以使用Gen.frequency 这样实现:

1
implicit val eitherGenerator = frequency((3, leftValueGenerator), (1, rightValueGenerator))

生成器已经创建了。让我们移步到你的第一个范式。这个范式容易实现——你需要做的是检查Left和Right是不是同时出现。这种情况下,你需要使用Either类型的 isLeft 和 isRight 方法,这些方法将在Either是否包含类型时返回true或false。

1
property("isLeft or isRight not both") = Prop.forAll((e: Either[Int, Int]) => e.isLeft != e.isRight)

如果 isLeft 和 isRight 相等,你的范式不通过,因为明显Left和Right在同一时刻不会同时有值。

对于第二个范式和第三个范式,使用定义在Either类型中的方法 fold:

1
2
property("left value") = Prop.forAll{(n: Int) => Left(n).fold(x => x, b => error("fail")) == n }
property("Right value") = Prop.forAll{(n: Int) => Right(n).fold(b => error("fail"), x => x) == n }

如果访问错误的值,两种情况下都会出现错误。fold方法定义如下,它仅提供合适的函数参数:

1
2
3
4
def fold[X](fa: A => X, fb: B => X) = this match {
case Left(a) => fa(a)
case Right(b) => fb(b)
}

Customizing the number of tests generated by ScalaCheck

ScalaCheck 提供了配置选项,允许你控制如何验证你的属性。如果你想生成超过100个成功的测试,你可以通过SBT传递ScalaCheck参数给你的测试。技巧就是使用SBT的 test-only 动作。它允许你提供测试名作为参数,以及传递额外的测试参数。如果你不指定任何测试名,它会运行SBT环境下的所有测试。这里你可以改变默认的设置,设置默认最小成功次数从100到500,即在SBT中传递测试参数:

1
test-only -- -s 500

通过 -s (ScalaCheck-specific configuration),你设置了最小成功测试次数,它有ScalaCheck在属性成功之前生成。查阅ScalaCheck文档,了解更多配置选项。

第四个范式有一点困难,但没有不能解决的。根据该范式,Either类型的方法swap可以交换Left和Right的值,反之亦然。这里你可以使用模式匹配来检测值属于Left,还是Right。例如,如果它是Left,交换之后,它的值应该在右边;反之Right时也一样:

1
2
3
4
5
property("swap values") = Prop.forAll{(e: Either[Int, Int]) => e match {
case Left(a) => e.swap.right.get == a
case Right(b) => e.swap.left.get == b
}
}

下面列出Either类型的完整范式的代码,包括范式5和6:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
object EitherSpecification extends Properties("Either") {

import Arbitrary.arbitrary
import Gen._

val leftValueGenerator = arbitrary[Int].map(Left(_))
val rightValueGenerator = arbitrary[Int].map(Right(_))
implicit val eitherGenerator = oneOf(leftValueGenerator, rightValueGenerator)

property("isLeft or isRight not both") = Prop.forAll((e: Either[Int, Int]) => e.isLeft != e.isRight)

property("left value") = Prop.forAll { (n: Int) => Left(n).fold(x => x, b => sys.error("fail")) == n }

property("Right value") = Prop.forAll { (n: Int) => Right(n).fold(b => sys.error("fail"), x => x) == n }

property("swap values") = Prop.forAll { (e: Either[Int, Int]) => e match {
case Left(a) => e.swap.right.get == a
case Right(b) => e.swap.left.get == b
}
}

property("getOrElse") = Prop.forAll { (e: Either[Int, Int], or: Int) => e.left.getOrElse(or) == (e match {
case Left(a) => a
case Right(_) => or
})
}

property("forall") = Prop.forAll { (e: Either[Int, Int]) =>
e.right.forall(_ % 2 == 0) == (e.isLeft || e.right.get % 2 == 0)
}
}

上述代码使用ScalaCheck提供的构建块,为类型Either创建了一个生成器。Arbitrary.arbitrary是这些构建块中的一个,它们用于创建自定义生成器。使用它,你可以创建包含Left和Right值的Either类型。之后,使用Gen对象提供的组合,创建Either类型的生成器。

有大量的Scala开源库,如Scalaz、Lift使用了ScalaCheck用于它们类的测试。你可以下载这些代码,查看各种各样的使用方式。

对于Java基础代码,使用ScalaCheck也可以方便进行测试。因为Scala和Java是相互操作的,你不需要为Java代码做任何特殊的。ScalaCheck也支持Java集合类测常规测试。

你可以想象出,ScalaCheck是一个强大的框架。例如,用20-25行代码,你管理生成600个测试。以及创建自定义生成器的能力,我确保你的项目中会用到ScalaCheck的价值的地方。

对于已实现的新功能会怎样?你不确定测试在类、特质、函数应该是什么样。下个小节介绍一个设计技术,叫做 驱动测试开发,它可能会解决你的问题。

10~3〖Test-driven development cycle〗P294

测试驱动开发8 (Test-driven development,TDD),是使用测试来驱动软件开发的一门技术。最开始可能会误导,因为测试通常和软件的验证相联系。对软件进行测试,确保工作时是我们所期望的。它更像是软件发布所做的最后一件事情。TDD 则完全相反,它在软件周期中间环节进行测试。在敏捷软件开发中,TDD是最多的、或者说是最重要的实践之一。你不仅可以在敏捷开发中体会到TDD的好处,在任何处理中都可以用到。记住:TDD是一个设计工具。最终你会得到一个测试套件,但它更多的是次级效应。让我们开始理解TDD如何工作的,我将解析为什么它工作。

下图概述了TDD作为一个开发实践是如何工作的。首尾相连的测试,总是以一个失败开始。一个互联的测试,从头到尾执行着你的应用。这意味着测试可以使一个HTTP请求通过一个浏览器,以及检测响应。你可以编写一连串的单元测试来将问题分成小块,并使这些测试通过。当测试失败时你仅写了生产代码,当测试通过时仅进行重构。

Figure 10.2

让我们思考下面这些特性请求。作为一名财务分析,你想要计算一小部分的产品,以正确给客户开账单:

验收标准:

  • 一个百分比乘积,为消费+百分额。例如,150(消费)+20%=$180
  • 所有以B开头的ID,应该使用一个外部的价格系统获得价格。

这里,如果你选择第一条验收标准,你的工作应该实现该标准的一个测试。实现这个测试,你头脑中会出现下面这些问题:

  • 应该在什么地方实现这些计价逻辑?
  • 应该创建一个trait或开始一个简单的函数?
  • 函数应该接收什么参数?
  • 应该怎样测试输出内容?
  • 是否要访问数据库或文件系统来获取这些消费内容?

该案例,你已经开始思考设计。但这时候,你的专注应该在手头的工作单元。这意味着你正在做验收标准。TDD最普遍的问题是提取最简单的解决方案以让它可能工作。这里最简单的解决方案是创建一个接收产品代码的函数,可以在一个Map中获取,并返回经验收标准公式计算的结果。可能,使用Map来作为查阅设计,在下一轮测试不会为true。如果发生,你会改变必要的代码,并从某些持久化存储中查找。做最简单的事情使工作可能实现,然后在应用中增加设计。

一旦你的测试跑起来了,你就有机会重构和清理。重构(Refactoring)是指你可以在已有代码的基础上提高设计,并且不改变原来的行为。这种 测试-编码-重构(test-code-refactor)在每个特性或步骤实现中重复循环。有时候这种循环被称为 红-绿-重构(red-green-refactor)循环。当测试失败,得到红色状态;测试通过后变为绿色。TDD是一个开发实践,它需要些时间慢慢习惯。所以你要做过一些例子,对它会变得明朗起来。

好消息是,Scala社区提供了大量的测试工具使用。我会专注于两款最流行的测试工具:JUnit和Specs。JUnit是Java开发者中最流行的,同时也容易地在Scala代码中使用。但大多数Scala编程人员使用Specs来对它们的Scala代码进行测试。

因为开始编写测试,你需要构建一个测试套件。如果你不经常使用,你不会从中获取到它所带来的便利。下个小节讨论设置一个持续集成环境9,以及其优势。

1031〖Setting up your environment for TDD〗P296

一旦你或你的团队熟悉了TDD,你需要一个工具了来检出最后托管的源代码,并在VCS提交后,运行所有的测试。这样确保了软件应用一直可以工作。一个持续集成(CI,continuous integration)工具将自动为你处理这些工作。几乎已有的CI工具都可以工作于Scala项目。表 10.1 展示了一些Scala工具,一些可能会用到你的Scala项目中。

name Description
Jenkins CI 开源的持续继承工具,可以自动构建和测试你的项目。你可以配置它指向你的代码控制,并在托管每次更新时执行构建。实际上,所有的CI工具都有这些功能。你也可以为你的应用使用其它任何流行的CI工具。
Jenkins SBT plugin 允许你通过Jenkins执行SBT构建动作,以及使用Jenkins配置SBT。对于CI工具不提供SBT支持但支持Maven,你可以在SBT中使用make-pom命令来生成一个POM文件。
Code coverage 代码覆盖率(code coverage)是一个自动测试下的源代码测量指标。代码覆盖率工具帮助你标识哪些区域的代码没有被测试。几乎所有的Java代码覆盖率工具都可以用于Scala,但用于诸如SBT的构建工具,将会更好。

TIP SBT工具在市场上仍然很少占有率。如果有测试工具或CI环境不能很好地在SBT下工作。你可以使用Maven作为你的构建工具。Maven中有提供了Scala插件10,允许你编译和运行Scala测试。你也可以在SBT项目中使用make-pom动作来生成一个.POM文件。

我这里提及了为数不多的工具,你可以在你的项目中使用这些工具进行持续的反馈循环。围绕Scala的工具集一直在发展中,因此尝试一些工具,使用其中一个配对在你的项目中。下小节解释如何使用JUnit来测试你的Scala代码。

1032〖Using JUnit to test Scala code〗P296

JUnit是一个Java编写的测试框架。这个流行的测试框架被用于很多Java项目中。如果你之前使用过JUnit测试工具进行Java代码测试,我很高兴告诉你你也可以在Scala中使用JUnit。要在SBT项目中使用JUnit,添加如下依赖:

1
libraryDependencies += "junit" % "junit" % "4.10" % "test"

默认地,SBT不会识别JUnit风格的test case,因此你需要添加额外的依赖,让SBT识别JUnit:

1
libraryDependencies += "com.novocode" % "junit-interface" % "0.8" % "test"

工具junit-interface11实现了SBT的测试接口使得SBT可以运行JUnit测试案例。reload 和 update SBT项目后,你便可以添加JUnit测试案例,并在SBT控制台使用test动作运行这些测试。这样做带来的好处是如果你要为Scala项目引入Java代码,或者同时有Java和Scala项目12在SBT中构建,JUnit都可以同样适用。

JUnit是编写自动化测试的一个好的开始,但它对于Scala项目不是一个合适的测试工具,因为它仍然不能友好地理解Scala。有大量开源的Scala测试工具可以用于编写富有表现力的测试。小节10.6会进入一个Scala大多数开发者使用的测试工具,叫做Specs,但现在更多的是让我们理解一个重要的概念——依赖注入(dependency injection),它可以帮助用于测试应用的设计。

10~4〖Better tests with dependency injection〗P297

依赖注入(DI,dependency injection)是一个设计模式,指从依赖处理中分离行为。这种模式帮助用于设计编写实现本质上的高度解耦。让我们看看一个简单例子,以理解DI是如何工作的。

这个例子是一个多种计价规则的商品计价。典型地,任何计价系统会有数百个规则,但为了简单,我仅介绍两个:

  • cost-plus为现有价格加上百分比消费
  • 从外部计价资源中获取商品价格

有了这些的规则,计算价钱应该如下实现:

Figure 10.3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
sealed class CalculatePriceService {
val costPlusCalculator = new CostPlusCalculator()
val externalPriceSourceCalculator = new ExternalPriceSourceCalculator()
val calculators = Map(
"costPlus" -> calculate(costPlusCalculator) _,
"externalPriceSource" -> calculate(externalPriceSourceCalculator) _)
def calculate(priceType: String, productId: String): Double = {
calculators(priceType)(productId)
}
private[this] def calculate(c: Calculator)(productId: String): Double =
c.calculate(productId)
}
trait Calculator {
def calculate(productId: String): Double
}
class CostPlusCalculator extends Calculator {
def calculate(productId: String) = {
...
}
}
class ExternalPriceSourceCalculator extends Calculator {
def calculate(productId: String) = {
...
}
}

cost-plus规则由costPlusCalculator实现,外部计价规则由externalPriceSourceCalculator处理。两个计数器都实现了Calculator特质。类CalculatePriceService使用了这些计数器作用在priceType参数。现在priceType有两种可能的值 costPlus 和 externalPriceSource。让我们关联这个例子到DI上。CalculatePriceService的行为是使用合适的计数器来决定给的你给产品的价钱。同时这个类也解决了依赖注入问题。在管理你的依赖上有没有其它问题?

注意 依赖注入是控制反转的具体形式,控制反转(inversion of control)指的是反转获得依赖的处理。

是的,有一些潜在的问题,特别是软件升级时。如果你的客户决定使用一个不同的外部计价资源来计算价格,或者针对一部分用户重新定义了cost-plus规则会发生什么?这时候出现了计价器的不同实现。这在某些场合可能适用,但如果作为一个组件以实现项目共享,则会有问题。

使用依赖注入,你可以轻松解决这个问题。如果依赖的计数器可以传递给CalculatePriceService,然后这个服务可以轻松配置各种不同的计数器实现。它将变得简单,你可以通过构造器传递这些calculators:

1
2
3
4
5
6
7
8
9
10
11
sealed class CalculatePriceService(val costPlusCalculator: Calculator, val externalPriceSourceCalculator: Calculator) {

val calculators = Map(
"costPlus" -> calculate(costPlusCalculator) _,
"externalPriceSource" -> calculate(externalPriceSourceCalculator) _)

def calculate(priceType: String, productId: String): Double = {
calculators(priceType)(productId)
}
private[this] def calculate(c: Calculator)(productId: String): Double = c.calculate(productId)
}

比起上一个代码,唯一不同的是,现在两个计数器的实例通过构造器参数传递。这里,service职责是决定了依赖 price calculator和注入这些依赖到 service中。这使得service层高度解耦,因为它不用关心 costPlusCalculator 或 externalPriceSourceCalculator是如何被创建或实现的。这也给你设计上的自由,因为现在你可以轻松合并这些你客户要求的改变内容,实现不同的计价规则。

1041〖Techniques to implement DI〗P299

DI在测试中可以做什么?在单元测试中,一个要点是理解你正在测试的单元。你测试的CalculatePriceService下的系统,不是costPlusCalculator 或 externalPriceSourceCalculator。但如果你不孤立这些calculators,测试会终止。使用一个集成测试是可以的,但不仅仅是测试CalculatePriceService的行为。在这个小例子中,可能很难看出区别,但在大型应用中不孤立这些依赖,你为每个所需要测试的组件,会一次一次初始化终止系统。如果你想要编写简单的、可管理的单元测试,孤立显得尤为重要。

第二个问题是,在紧耦合系统中的测试速度。快速的测试很重要。记住你的测试就是你的反馈机制,因此如果运行很慢,反馈就很慢。例如,每个calculators会访问数据库或web服务,这些都会减慢你的测试。

定义 Test double 是一个常规的涵盖性术语,指你系统测试下所依赖的,一个具体的等价的测试组件。

理想的情况下,你会为每个计数器创建一个测试版本,这样你可以专注于你的当前系统下的测试验证,即这里的CalculatePriceService。该计数器的测试版本下,你可以返回一个硬编码价格、或者使用一个内存数据库来加速。对于测试数据,你有更多的控制。TDD的一个关键方面是测试的可重复运行。如果你的测试重度依赖于外部数据,测试会变得很脆弱,因为外部数据会改变以及打破你的测试。

注意 一个好的单元测试的措施是,它应该没有副作用,就和编写一个纯函数一样。

如果你遵循TDD作为你设计的驱动,你不应该过多担心耦合性问题——你的测试会强制你进行解耦设计。你会注意到你的函数、类、以及方法都遵循一个DI模式。

往下的小节讨论了在Scala中实现依赖注入的几种方式。表10.2为其清单。

Technique Description
Cake pattern Handles dependency using trait and abstract members
Structural typing 使用结构类型来管理依赖,Scala结构类型在一个类型安全管理器中提供了鸭子类型。鸭子类型(Duck typing)是一个动态类型风格,表示对象当前的行为由方法和属性决定
Implicit parameters 使用隐式参数来管理依赖,这样可以不用显式传递。依赖受作用范围控制。
Functional programming style 使用函数科里化控制依赖。函数科里化是指将带多个参数的函数,转换为多个接收一个参数的函数,并链接在一起。
Using a DI framework Most of the techniques mentioned here will be home-grown. I show you how to use a DI framework.

这些技术可以帮助编写更容易测试的代码,并为Scala提供了一个可伸缩的处理方案。让我们以熟悉的CalculatePriceService 为例,实现上述表格中提到的每项技术。

1042〖Cake pattern〗P301

一个蛋糕模式13 (cake pattern)是一门在应用中构建多个间接层来帮助管理依赖的技术。蛋糕模式构建了三个抽象技术,描述如下表。

Name Description
Abstract members 提供了一种方式抽象具体组件类型。使用抽象类型,你创建的组件可以不依赖具体类型,类型的信息由其它使用它的组件提供。
Self type annotation 允许你重新定义 this ,由组件声明依赖。使用特质混入,你可以注入依赖的各种实现。
Mixin composition 混入允许你使用Scala特质重载和添加新的功能。

这些概念在chapter7中覆盖,因此让我们看看蛋糕模式如何帮助你将CalculatePriceService 从计数器中进行解耦,并使它更容易测试。第一件事你需要做的是提取出服务中的实例,并命名Calculators:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
trait Calculators {
val costPlusCalculator: CostPlusCalculator
val externalPriceSourceCalculator: ExternalPriceSourceCalculator
trait Calculator {
def calculate(productId: String): Double
}
class CostPlusCalculator extends Calculator {
def calculate(productId: String) = {
...
}
}
class ExternalPriceSourceCalculator extends Calculator {
def calculate(productId: String) = {
...
}
}
}

特质Calculator背后的思想是,一个组件命名空间提供所有的calculator给应用。相似地,让我们为CalculatePriceService创建一个组件命名空间,并通过自身类型声明它的依赖给Calculators:

1
2
3
4
5
6
7
8
9
10
11
12
trait CalculatePriceServiceComponent {this: Calculators =>
class CalculatePriceService {
val calculators = Map(
"costPlus" -> calculate(costPlusCalculator) _
"externalPriceSource" -> calculate(externalPriceSourceCalculator) _)
def calculate(priceType: String, productId: String): Double = {
calculators(priceType)(productId)
}
private[this] def calculate(c: Calculator)(productId: String):Double =
c.calculate(productId)
}
}

你使用了自类型 this: Calculators 重定义了this。它静态地确保了不能创建CalculatePriceService 而不混入 Calculators特质。好处是你可以随意地同时引用costPlusCalculator和externalPriceSourceCalculator。自类型会确保它们在运行时可用。

你必须知道为什么两个计数器同时在Calculators特质内声明为抽象。因为你要控制这些计数器是被创建。上面测试提及过,你不需要使用计数器;实际上你需要使用一个假的或TestDouble版本的计数器。同时,你需要在真是产品模式下的计数器。这正是trait混入因素。对于产品某事,你可以通过组合所有真是版本组件来创建一个计价系统,如下:

1
2
3
4
5
object PricingSystem extends CalculatePriceServiceComponent
with Calculators {
val costPlusCalculator = new CostPlusCalculator
val externalPriceSourceCalculator = new ExternalPriceSourceCalculator
}

计价系统由真实的costPlusCalculator和externalPriceSourceCalculator初始化,对于测试的计价可以创建使用假的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13

trait TestPricingSystem extends CalculatePriceServiceComponent
with Calculators {
class StubCostPlusCalculator extends CostPlusCalculator {
override def calculate(productId: String) = 0.0
}
class StubExternalPriceSourceCalculator extends
ExternalPriceSourceCalculator {
override def calculate(productId: String) = 0.0
}
val costPlusCalculator = new StubCostPlusCalculator
val externalPriceSourceCalculator = new StubExternalPriceSourceCalculator
}

这里的TestPricingSystem,使用TestDouble实现计数器,因此可以帮助编写围绕测试的计价服务。在你的测试中,使用的TestPricingSystem代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.junit.Assert._
import org.junit.Test

class CalculatePriceServiceTest extends TestPricingSystem {

@Test
def shouldUseCostPlusCalculatorWhenPriceTypeIsCostPlus() {
val calculatePriceService = new CalculatePriceService
val price = calculatePriceService.calculate("costPlus", "some product")
assertEquals(5.0D, price, 0D)
}

@Test
def shouldUseExternalPriceSourceCalculator() {
val calculatePriceService = new CalculatePriceService
val price = calculatePriceService.calculate("externalPriceSource", "dummy")
assertEquals(10.0D, price, 0D)
}
}

你混入了计价系统到你的测试类中。这会自动使用测试内的假的计价器实现。这将简化你的测试,并让你更专注于CalculatePriceService上。两个测试都测试了CalculatePriceService是否使用了正确的计数器。

这种常规技术被Scala开发者用于管理依赖。在小项目,应该装配诸如PricingSystem 、TestPricingSystem这样的依赖实现,但对于大型项目,这样会变得难于管理。对于大型项目来说,更多的是使用DI框架,使你完全从业务逻辑中分离创建对象和注入对象。

1043〖Structural typing〗P303

在Scala中,结构类型是由自身结构来描述类型的一种方式。前面章节创建了Calculators特质作为所有计数器的一个命名空间,CalculatePriceService使用它用于独立的计数。这种结构下有两个抽象特质 vals:costPlusCalculator 和 externalPriceSourceCalculator,因为CalculatePriceService 不会关心这些内容。为了创建一个结构捕获这些信息,要确保Scala把它看作一个新的类型:

1
2
3
4
type Calculators = {
val costPlusCalculator: Calculator
val externalPriceSourceCalculator: Calculator
}

该代码由指定的结构创建了一个新的类型Calculators。type关键字在Scala中用于创建新的类型或类型别名。现在你可以使用这个类型注入到计数器的各种实现中:

1
2
3
4
5
6
7
8
9
10
class CalculatePriceService(c: Calculators) {
val calculators = Map(
"costPlus" -> calculate(c.costPlusCalculator) _ ,
"externalPriceSource" -> calculate(c.externalPriceSourceCalculator) _)
def calculate(priceType: String, productId: String): Double = {
calculators(priceType)(productId)
}
private[this] def calculate(c: Calculator)(productId: String):Double =
c.calculate(productId)
}

当使用一个结构类型,你不需要为其命名——你可以在行内使用,即如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class CalculatePriceService(c: {
val costPlusCalculator: Calculator
val externalPriceSourceCalculator: Calculator
}) {
val calculators = Map(
"costPlus" -> calculate(c.costPlusCalculator) _,
"externalPriceSource" -> calculate(c.externalPriceSourceCalculator) _)

def calculate(priceType: String, productId: String): Double = {
calculators(priceType)(productId)
}
private[this] def calculate(c: Calculator)(productId: String): Double = c.calculate(productId)
}

在这里类型构造器的参数被定义在行内。Scala中这种结构类型的优点是它是不可变的(mutable)以及类型安全的(type-safe)。Scala编译器会确保CalculatePriceService中的构造器参数同时实现了变量costPlusCalculator和externalPriceSourceCalculator的抽象。再一次,你可以创建两个类型的配置——一个用于测试环境,另一个用于开发环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
object ProductionConfig {
val costPlusCalculator = new CostPlusCalculator
val externalPriceSourceCalculator = new ExternalPriceSourceCalculator
val priceService = new CalculatePriceService(this)
}

object TestConfig {
val costPlusCalculator = new CostPlusCalculator {
override def calculate(productId: String) = 5.0D
}

val externalPriceSourceCalculator = new ExternalPriceSourceCalculator {
override def calculate(productId: String) = 10.0D
}
val priceService = new CalculatePriceService(this)
}

基于你所做的,你可以灵活地进行合适的配置。这是我处理依赖方式中最喜欢的一个,因为它容易和简单。在内部,结构类型实现了反射,因此相比其它方法会较慢。有时这是可以接受的,但当使用结构类型时要意识到这点。

1044〖Implicit parameters〗P305

隐式参数提供了参数被发现的方式。只用这种技术,让Scala编译器注入合适的依赖到你的代码中。ScalaCheck使用了隐式参数来决定一个合适的生成器用于property。要声明一个隐式参数,你需要用关键字implicit标识。

下面例子将计数器作为一个参数注入到CalculatePriceService中,并标识为implicit:

1
2
3
4
class CalculatePriceService(
implicit val costPlusCalculator: CostPlusCalculator,
implicit val externalPriceSourceCalculator: ExternalPriceSourceCalculator
)

隐式参数的美丽之处在于,如果在创建CalculatePriceService实例时不提供这些参数,Scala编译器会查找"implicit" 值匹配当前编译范围内的参数。如果编译器查找合适的隐式值时失败,则出现编译错误。

创建一个ProductionServices对象,用于定义这些产品代码的隐式值:

1
2
3
4
object ProductionServices {
implicit val costPlusCalculator = new CostPlusCalculator
implicit val externalPriceSourceCalculator = new ExternalPriceSourceCalculator
}

要为隐式参数提供值,需要让每个值标识为implicit——否则编译器不能识别。你还需要将这个对象导入到运行的代码中,如下:

1
2
3
4
object ProductionConfig {
import ProductionServices._
val priceService = new CalculatePriceService
}

类似地,对于测试环境,创建一个对象并提供该services的一个测试的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
object TestServices {
implicit val costPlusCalculator = new CostPlusCalculator {
override def calculate(productId: String) = 0.0
}

implicit val externalPriceSourceCalculator = new ExternalPriceSourceCalculator {
override def calculate(productId: String) = 0.0
}
}

object TestConfig {
import ProductionServices._
val priceService = new CalculatePriceService
}

你不必总是为隐式参数使用值,因为你可以显式地传递参数。随着应用变得庞大,使用implicit来处理依赖,你可以避免无法控制的情况,除非是像上面配置对象一样组装在一起。否则,你的隐式声明和导入会被分散在代码中,并使得难于调试编译问题。注意隐式参数解决方案依赖于类型。取而代之的是定义costPlusCalculator和externalPriceSourceCalculator作为Calculator的一个类型,你应该提供更多具体的类型。有时这种约束会使的构建一个可伸缩的设计变得非常严谨。

1045〖Dependency injection in functional style〗P306

依赖注入背后的思想是控制反转14 (Inversion of Control)。它从外部传递依赖,而不是由一个组件控制。当使用函数,DI早已经自动发生。如果你把一个函数看作是一个组件,把依赖看作是它的参数。这使得函数自然而然地可测试。如果你创建函数科里化,你也可以用其它模式隐藏依赖。函数科里化(Function currying)是一门函数转换技术,它接收多个参数,转化为单个参数的方法链。下面是仅使用函数的Calculators接口:

1
2
3
4
5
trait Calculators {
type Calculator = String => Double
protected val findCalculator: String => Calculator
protected val calculate: (Calculator, String) => Double = (calculator, productId) => calculator(productId)
}

类型Calculator为接收产品ID,返回价格的别名,即 String => Double。函数findCalculator由价钱类型决定计数器。最后的calculate是一个函数,它接收一个Calculator实例和productId用于计算产品价格。这和你早期设计的接口十分相似,但这里仅仅表示为函数。

你可以通过调用curried方法,将calculate函数转换为一个curried函数,这个curried定义在Scala的所有函数类型中:

1
val f: Calculator => String => Double = calculate.curried

curried方法接收带n个参数的函数,并转换为带一个参数的n个函数。在这里创建了一个接收Calculator参数的函数,并计算productId的价格。这样做的好处是,你现在有一个函数知道如何计算价钱,但对用户吟唱了Calculator。下面是Calculators实现的例子测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
object TestCalculators extends Calculators {
val costPlusCalculator : String => Double = productId => 0.0
val externalPriceSource: String => Double = productId => 0.0

override protected val findCalculator = Map(
"costPlus" -> costPlusCalculator,
"externalPriceSource" -> externalPriceSource
)

def priceCalculator(priceType: String): String => Double = {
val f: Calculator => String => Double = calculate.curried
f(findCalculator(priceType))
}
}

方法priceCalculator返回一个函数,它接收productId,返回该产品的价格,并封装依赖进行计算。这个例子就是如何使用函数式编程实现依赖注入。

1046〖Using a dependency injection framework: Spring〗P307

Scala抽象成员、自类型和混入提供了比Java更抽象的技术,但但依赖注入框架提供的下列附加服务,在这些抽象技术中并不可用:

  • 在对象初始化和从业务逻辑的创建,有明显的分割。这些框架提供了一个分离的生命周期,来创建应用初始化依赖的一部分。这里,代码装配组件变为透明。
  • 这些框架可以在其它各种各样的框架中帮助使用。例如,你计划使用已有的Java Web框架,一个依赖框架作为依赖注入到你的Scala对象汇中。
  • 大部分框架,如Spring、Guice,提供了面向切面编程15 (AOP),支持处理切点行为,如事务、日志。

好消息是,你可以在你的Scala项目中使用任何Java DI框架。本小节向你展示如何使用Spring框架16应用到你的Scala项目中,作为一个DI框架。

Spring框架允许你以多种方式配置依赖。我将向你展示使用外部XML配置文件来配置使用。在Spring中,所有依赖被称为bean,因为所有对象遵循Java-Bean17转换。据此,一个类应该提供一个默认的构造器,并且类的属性带有get、set和is方法进行访问。

要令一个属性(property)为bean属性,Scala提供了一个便利的注解 @BeanProerty 。这个注解告诉Scala编译器自动生成getter和setter方法。下面例子清单展示了该便利性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package scala.book

import scala.reflect._

sealed class CalculatePriceService {
@BeanProperty var costPlusCalculator : Calculator = _ // ①
@BeanProperty var externalPriceSourceCalculator: Calculator = _ // ②

def calculators = Map(
"costPlus" -> calculate(costPlusCalculator) _,
"externalPriceSource" -> calculate(externalPriceSourceCalculator) _)

def calculate(priceType: String, productId: String): Double = {
calculators(priceType)(productId)
}
private[this] def calculate(c: Calculator)(productId: String): Double = c.calculate(productId)
}

这里的CalculatePriceService版本看起来和 10.3 小节的如出一辙,除了这里的costPlusCalculator 和 texternalPriceSourceCalculator被 @BeanProperty 注解声明为bean属性之外。这个 @BeanProperty 注解会为 costPlusCalculator 和 externalPriceSourceCalculator 属性生成下列getters 和 setters 方法:

1
2
3
4
5
6
7
def getCostPlusCalculator: Calculator = this.costPlusCalculator
def setCostPlusCalculator(c: Calculator) { this.costPlusCalculator = c }
def getExternalPriceSourceCalculator: Calculator =
this.externalPriceSourceCalculator
def setExternalPriceSourceCalculator (c: Calculator) {
this. externalPriceSourceCalculator = c
}

这两个计价器都已称为bean,它们提供了一个默认的构造器。唯一需要做的是装配依赖到服务中,在Spring中,你可以通过指定一个配置文件实现,如下面清单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation=
"http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-2.0.xsd">

<bean id="costPlusCalculator" class="scala.book.CostPlusCalculator"/>
<bean id="externalPriceSourceCalculator" class="scala.book.ExternalPriceSourceCalculator"/>

<bean id="calculatePriceService" class="scala.book.CalculatePriceService">
<property name="costPlusCalculator" ref="costPlusCalculator" />
<property name="externalPriceSourceCalculator" ref="externalPriceSourceCalculator" />
</bean>
</beans>

这是一个标准的Spring配置文件,CalculatePriceService被定义了依赖。将该文件保存为application-context.xml文件,并置于SBT项目中的src/main/resources文件夹。该文件用于初始化应用bean。类似地,你也可以有一个测试版的配置,置于src/test/resources下面作为假的计价实现。并创建假的测试实例。现在先在SBT中加入框架的依赖项:

1
2
3
4
val spring = "org.springframework" % "spring" % "2.5.6"
val springTest = "org.springframework" % "spring-test" % "2.5.6"
val junit = "junit" % "junit" % "4.4" % "test"
val junitInterface = "com.novocode" % "junit-interface" % "0.5" % "test"

这里同时添加了Spring 和 Spring test框架依赖。因为现在还没有学习有关Specs的内容,让我们使用JUnit作为测试工具。再一次说明,junitInterface是Java对SBT的测试接口,已使得能够运行JUnit单元测试。

要对CalculatePriceService进行测试,你可以在测试内部使用Spring来配置beans重载合适的calculator。要在JUnit中使用Spring,可以添加如下注解测试声明:

1
2
@RunWith(classOf[SpringJUnit4ClassRunner])
@ContextConfiguration(locations = Array("classpath:/application-context.xml"))

RunWith注解允许JUnit测试访问定义在application context文件里面的beans。ContextConfiguration让你指定那个配置文件用于初始化beans。如果你有一个测试版的配置文件,则指明。在测试内,如果你用@Resource注解声明了CalculatePriceService的一个变量,Spring会创建并注入它的一个实例到测试中。这里是使用Spring配置的JUnit骨架:

1
2
3
4
5
6
@RunWith(classOf[SpringJUnit4ClassRunner])
@ContextConfiguration(locations = Array("classpath:/application-context.xml"))
class CalculatePriceServiceTest {
@Resource
var calculatePriceService: CalculatePriceService = _
}

CalculatePriceService的实例会由Spring框架注入到测试中。这里,测试类被设置用于测试计算价格。下面是cost-plus计价和external的完整实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import javax.annotation.Resource

import org.junit.Assert._
import org.junit.Test
import org.junit.runner.RunWith
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner

@RunWith(classOf[SpringJUnit4ClassRunner])
@ContextConfiguration(locations = Array("classpath:/application-context.xml"))
class SpringExampleTest {

@Resource
var calculatePriceService: CalculatePriceService = _

@Test
def shouldUseCostPlusCalculatorWhenPriceTypeIsCostPlus() {
val fakeCostPlusCalculator = new Calculator {
def calculate(productId: String) = 2.0D
}
calculatePriceService.setCostPlusCalculator(fakeCostPlusCalculator)
val price = calculatePriceService.calculate("costPlus", "some product")
assertEquals(2.0D, price, 0D)
}

@Test
def testShouldReturnExternalPrice() {
val fakeExternalPriceSourceCalculator = new Calculator {
def calculate(productId: String) = 5.0D
}
calculatePriceService.setExternalPriceSourceCalculator(fakeExternalPriceSourceCalculator)
val price = calculatePriceService.calculate("externalPriceSource", "dummy")
assertEquals(5.0D, price, 0D)
}
}

你声明了你的JUnit测试允许使用RunWith和指定配置文件来创建Spring bean。在大型项目中推荐使用一个测试版本的配置文件,该配置文件实现了假的依赖。你可以看到,不需要改动太多就可以在Scala中使用Spring框架。对于Java的其它依赖注入框架也一样道理。使用DI框架有些超前,但对于大型项目来说是值得的——除非你使用某些Scala框架提供依赖管理的本地支持。

本章讲解了大量的内容,我确保你所学习的技术会帮助你编写更解耦的、可测试的Scala系统。

下小节概述另外一个测试工具,叫Specs。JUnit是一个快速上手和运行的测试工具,但现在是时候使用基于Scala的测试框架,它更有表现力、以及更容易使用。

10~5〖Behavior-driven development using Specs2〗P312

行为驱动开发(BDD,Behavior-driven development),是从利益出发点描述行为实现一个应用。不久前我讨论了有关测试驱动开发,那为什么还讨论BDD?它和TDD有什么不同?

答案当然是不同了。BDD18做的是TDD相反的事情。第一是,BDD定义中完全没有谈及测试。这样做的目的是,TDD的一个设计缺陷是,一些人把用在测试上的时间比用在解决业务问题上更多。事实上,它推荐从利益层面看待应用。最终在一个项目中作BDD会有下面两个重要结果:

  • 快速传值(Delivering value quickly)——因为你从利益层面上看待应用,理解和传值是快速的。它帮助你理解问题,以及推荐合适的解决方案。
  • 行为专注(Focus on behavior)——这是最重要的提升,因为时至今日,你实现的行为,正是你利益共同者想要的。专注于行为也减少了花费在预期设计、分析和文档上的付出,它很少给项目添加值。

为了使开发者和利益共同者在同一个页面,你需要通用语言19 (Ubiquitous language),一个所有人在描述一个应用的行为时所说的通用语言。你也需要一个工具,这样你可以解析这些行为,并编写自动化规范对这些行为断言。

注意 test 和 specification 使用同义,但 specification 是一个更好的方式用来对 利益共同者讨论行为。考虑下面例子的一个 specification。

在BDD,开发时仍然遵循测试驱动开发声明周期。唯一改变的,看这些测试或规范的方式。是时候看一些BDD,接下来的小节,将向你介绍大多数Scala开发者使用的BDD工具:Specs2

1051〖Getting started with Specs2〗P313

Specs220 是Scala的BDD库,由Scala编写,实际上BDD库被用于Scala开发者。开始使用Specs最简单的方式是,添加它的一个依赖到你的SBT项目中。添加下面代码到SBT的build.sbt文件:

1
2
scalaVersion := "2.10.0"
libraryDependencies += "org.specs2" %% "specs2" % "2.3.1" % "test"

如果你想要使用其它版本的Specs,请确保它对当前Scala版本是否适用。reload、update你项目,便可以使用Specs。精彩的部分是SBT知道如何自然地运行Specs规范。编写第一个Specs规范,使用前面小节相同的计价服务。为CalculatePriceService创建一个空的规范:

1
2
3
package scala.book
import org.specs._
class CalculatePriceServiceSpecification extends Specification

要创建一个Specs规范,你总是需要导入org.specs2.mutable._并继承Specification特质。接下来,指定计价服务的行为:

1
2
3
4
5
6
7
8
package scala.book
import org.specs2.mutable._
class CalculatePriceServiceSpecification extends Specification {
"Calculate price service" should {
"calculate price for cost plus price type" in {}
"calculate price for external price source type" in {}
}
}

你向规范添加了一个结构。首先你使用了should方法定义系统,由两个服务的行为描述。Specs框架添加如shouldin方法,使用了隐式转换,这样你的规范变得更富有表现力和可读。当你在SBT中运行测试动作,你会看到如图10.4的输出。

Figure 10.4

如果你有一个ANSI终端窗口,Specs会展示不同的测试颜色。因为我没有实现任何specification,在图10.4中,它是黄色的。(如果实现了规范,绿色表示测试通过,红色表示失败)。

这里使用了cake pattern里面实现的service。在10.5.1小节中,创建了两个版本的service,一个是用于真实计价,另一个用于测试。下面是其测试版的CalculatePriceService:

1
2
3
4
5
6
7
8
9
10
trait TestPricingSystem extends CalculatePriceServiceComponent with Calculators {
class StubCostPlusCalculator extends CostPlusCalculator {
override def calculate(productId: String) = 5.0D
}
class StubExternalPriceSourceCalculator extends ExternalPriceSourceCalculator {
override def calculate(productId: String) = 10.0D
}
val costPlusCalculator = new StubCostPlusCalculator
val externalPriceSourceCalculator = new StubExternalPriceSourceCalculator
}

两个计价器都返回硬编码价钱。要使用这个版本的计价系统,你需要在specification混入,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import cakepattern._
import org.specs2.mutable._

class CalculatePriceServiceSpecification extends Specification with TestPricingSystem {
"Calculate price service" should {
"calculate price for cost plus price type" in {
val service = new CalculatePriceService
val price: Double = service.calculate("costPlus", "some product")
price must beEqualTo(5.0D)
}
"calculate price for external price source type" in {
val service = new CalculatePriceService
val price: Double = service.calculate("externalPriceSource", "some product")
price must be_==(10.0D)
}
}
}

must方法使用了隐式转换,这样specification上具有易读性。该例子证明了使用Specs编写一个富有表现力的规范是多么容易。下个小节探讨Specs特性内容。

1052〖Working with specifications〗P315

要使Specs高效工作,你需要习惯规范和它的可用匹配器。匹配器是添加期望值的方式。beEqualTomust be_==是这些匹配器的例子。Specs装载了大量的内建匹配器,你可以从Specs文档21中找到完整的列表。

前面小节你看了一个规范例子。现在向你展示一个变异的例子。根据你所描述的行为,选择合适的一个。

Specs规范的基本形式是,继承Specification特质:

1
2
3
4
5
6
package variousspecs
import org.specs2.mutable._
object MySpec extends Specification {
"example1" in {}
"example2" in {}
}

查看这些规范的一种方式是以组的方式描述。典型地,当你编写规范时,你会有一个描述行为的组件;这是在规范下的系统,你可以在规范下组织每个组的例子:

1
2
3
4
5
6
object SUSSpec extends Specification {
"my system" should {
"do this" in {}
"do that" in {}
}
}

你也可以嵌套例子,并把它添加的行为描述之中。如cost-plus计价系统:

1
2
3
4
5
6
7
8
9
10
"calculate price for cost plus price type" in {
val service = new CalculatePriceService
val price: Double = service.calculate("costPlus", "some product")
price must beEqualTo(5.0D)

"for empty product id return 0.0" in {
val service = new CalculatePriceService
service.calculate("costPlus", "") must beEqualTo(0.0D)
}
}

另外一种在Specs声明规范的方式是使用 DataTable22。数据表可以让你执行例子中的集合测试数据。例如,你描述了一个cost-plus规则的计价例子,要对此进行测试,仅仅只测一条数据还不够。为了合适地描述这种行为,你需要一系列数据用于规则运算。Specs的DataTable便应运而生。它可以让你以表格的形式指定例子数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
object CostPlusRulesSpec extends Specification with DataTables {

def applyCostPlusBusinessRule(cost: Double, serviceCharge: Double) = {
cost + (cost * 0.2) + serviceCharge
}

"cost plus price is calculated using 'cost + 20% of cost + given service charge' rule" in {
"cost" | "service charge" | "price" |>
100.0 ! 4 ! 124 |
200.0 ! 4 ! 244 |
0.0 ! 2 ! 2 | { (cost, serviceCharge, expected) =>
applyCostPlusBusinessRule(cost, serviceCharge) must be_==(expected)
}
}
}

示例描述了规则,数据表则帮助捕获数据,并用来验证applyCostPlusBusinessRule方法。表格的第一行为标题,以易读性目的使用。第二行开始往下,则是例子数据,由一个闭包调用组成。在闭包内,你执行applyCostPlusBusinessRule方法来检验结果。为了在Specification使用DataTable,你需要混入DataTables特质。表格中的 > 是必须的,相当于一个命令,> 使得表格作为例子执行的一部分。

Specs的DataTable对于创建集合数据的测试非常有帮助。你也可以使用ScalaCheck来为Specs的DataTable生成测试数据。

下小节将探索如何在异步通讯中进行自动化测试。在第9章已学习的actors会作为消息系统的一个具体例子。现在让我们看看如何围绕它编写测试。

10~6〖Testing asynchronous messaging systems〗P317

本章前面说过有关同步系统的测试和例子的创建,即是测试时调用系统,当系统处理完一个动作后,控制回到测试上面。但在异步“即发即弃”系统上,当系统正在执行时,控制也会回到测试上。该测试上,你不会得到任何反馈信息。为了克服这个问题,开发者将业务逻辑从消息层中提取出来,分离测试。这种方式的缺点就是,你不能从头到尾地测试。例如,为了验证一个actor是否给另一个actor发送了一个消息,你需要编写一个集成测试,它向一个actor发送消息,并等待它回复。围绕异步系统的集成测试,通常规则是检测无效的系统状态、或者等待带有timeout超时的通知。

为异步系统编写自动化测试是比较新的技术,以至于这方面的工具仍然处于维护之中。这里值得一提的工具是Awaitility23,它提供了一个非常好的测试DSL用于异步系统的测试。让我们看看Awaitility的一个处理例子。假设你有一个订单处理服务,用于异步地将订单保存到数据中,你通过发送一个PlaceOrder消息对订单进行放置。下面是虚拟的actor订单服务:

1
2
3
4
5
6
7
8
9
10
11
12
package example.actors
import akka.actor.Actor

case class PlaceOrder(productId: String, quantity: Int, customerId: String)

class OrderingService extends Actor {
def act = {
react {
case PlaceOrder(productId, quantity, customer) =>
}
}
}

在规范内,你将使用Awaitility的await方法进行等待,直到订单保存到数据库。如果定义没有保存到数据库,你会知道在处理消息时,哪里出了问题。下面是ordering service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.specs2.mutable._
import example.actors._


import com.jayway.awaitility.scala._
import com.jayway.awaitility.Awaitility._

class OrderingServiceSpecification extends Specification with AwaitilitySupport {
"Ordering system" should {
"place order asynchronously" in {
val s = new OrderingService().start
s ! PlaceOrder("product id", 1, "some customer id")
await until { orderSavedInDatabase("some customer id") }

done
}
}
def orderSavedInDatabase(customerId: String) = true

}

这里的例子发送了一个异步消息给订单服务,并进入等待状态,知道订单保存到数据中。Awaitility的默认超时是10秒,你可以容易地通过重载await方法设置自定义timeout。在orderSavedInDatabase内,你可以检查数据源中是否保存了给定的customer id。

Awaitility没有提供任何的基础架构,帮助你测试异步系统,但它确实使你的例子可读。

10~7〖Summary〗P318

本章覆盖讲述了一个重要的主题,高质量软件的开发。用一个新的编程语言,并尝试使用它来构建一个大型的应用是困难的。在新的语言或编程环境,通常的障碍是编写自动化测试。本章则介绍了Scala项目下的一些测试工具使用。

最先介绍的是ScalaCheck自动化测试工具。并学习了如何在ScalaCheck定义规范,创建自定义测试数据生成器。ScalaCheck是一个很好的测试。

你学习了关于敏捷软件开发和其中的测试驱动开发。探索了TDD如何对依赖构建、如何帮助改进设计。要在Scala项目中练习TDD,你需要工具支持。我讲解了如何设置一个持续集成的环境、如何使用SBT作为构建工具。并列出了用于Scala项目的一些常见工具。

使用自动化测试构建应用,要求你的设计是可测试的。一个可测试设计的至关重要的特点是控制反转,它被用于Java、Ruby及其它语言中。Scala既是面向对象的、也是函数式的,拥有更多选项来创建抽象。小节10.5介绍了Scala的依赖注入。诸如自类型(self type)、抽象成员(abstract members)不仅可以用于抽象,事实上,你可以将这些抽象思想用于构建可复用的组件。

在TDD中,最常见的错误是,开发者把过多的精力放在测试上,而忽略了最重要的,应用的行为。BDD弥补了这个疑惑,使开发回到行为和客户协作上。这里介绍了Specs工具,它让你编写富有表现力的规范。我提及过你可以使用JUnit来测试你的Scala代码,但注意这并不推荐。使用Scala规范/测试工具将会提供你的测试的可读性,并帮助提高和其它Scala tools的集成。

下个章节将讨论函数式编程。你前面看到一些Scala的函数式编程的特性以及一些例子,但11章以函数式编程概念将这些特性紧密联系在一起,这样你可以编写更可靠的、更正确的Scala程序。


  1. “Manifesto for Software Craftsmanship,” http://manifesto.softwarecraftsmanship.org.
  2. “Agile software development,” http://en.wikipedia.org/wiki/Agile_software_development.
  3. Martin Fowler, “Is Design Dead?,” May 2004, http://martinfowler.com/articles/designDead.html.
  4. “Waterfall model,” http://en.wikipedia.org/wiki/Waterfall_model.
  5. “Introduction to QuickCheck,” modified Oct 25, 2012, http://www.haskell.org/haskellwiki/Introduction_to_QuickCheck.
  6. “ScalaCheck user guide,” updated April 12, 2012, http://code.google.com/p/scalacheck/wiki/UserGuide.
  7. Scala Either, http://mng.bz/106L.
  8. “Test-driven Development,” http://en.wikipedia.org/wiki/Test-driven_development.
  9. Martin Fowler, “Continuous Integration,” May 1, 2006, http://martinfowler.com/articles/continuousIntegration.html.
  10. maven-scala-plugin, version 2.14.2, Aug. 4, 2010, http://scala-tools.org/mvnsites/maven-scala-plugin/.
  11. Stefan Zeiger, szeiger/junit-interface, https://github.com/szeiger/junit-interface.
  12. SBT, https://github.com/harrah/xsbt.
  13. Martin Odersky and Matthias Zenger, “Scalable Component Abstractions,” presented at OOPSLA’05, Oct. 16-20, 2005, http://lamp.epfl.ch/~odersky/papers/ScalableComponent.pdf
  14. Martin Fowler, “Inversion of Control Containers and the Dependency Injection Pattern,” Jan. 23, 2004, http://martinfowler.com/articles/injection.html.
  15. “Aspect-oriented programming,” http://en.wikipedia.org/wiki/Aspect-oriented_programming.
  16. “The IoC container,” Spring Framework, http://static.springsource.org/spring/docs/2.5.x/reference/beans.html.
  17. “JavaBeans,” http://en.wikipedia.org/wiki/JavaBean.
  18. David Chelimsky, et al., The RSpec Book: Behaviour-Driven Development with RSpec, Cucumber, and Friends, Pragmatic Bookshelf, 2010, www.pragprog.com/book/achbd/the-rspec-book.
  19. “UbiquitousLanguage,” http://martinfowler.com/bliki/UbiquitousLanguage.html.
  20. Specs 2, http://etorreborre.github.com/specs2/.
  21. Specs, MatchersGuide, “How to add expectations to your examples,” http://code.google.com/p/specs/wiki/MatchersGuide.
  22. Specs, “How to use Data Tables,” updated March 30, 2010, http://code.google.com/p/specs/wiki/AdvancedSpecifications.
  23. Awaitility, http://code.google.com/p/awaitility/.