ScalaTest测试框架

ScalaTest 是一个开源测试框架,旨在令测试更加高效。其开发者是Bill Venners(Artima主编)。Bill Venners这样描述ScalaTest:


ScalaTest是比JUnit和TestNG更加高阶的测试编写工具,这个Scala应用在JVM上运行,可以测试Scala以及Java代码。除了与JUnit和TestNG的深层集成外,还支持Ant任务,与maven集成,并包括了流行的Java mocking框架JMock、EasyMock以及Mockito的语法增强。通过JUnit集成,ScalaTest可以轻松地在Eclipse、NetBeans以及IntelliJ IDEA等IDE,以及Infinitest等生产工具中使用。


本文将简单介绍如何使用 ScalaTest 测试框架对我们的Scala代码进行测试。

部分例子来自于 《Testing in Scala》 Daniel Hinojosa O’REILLY

阅读本文,最好对下面列出的内容有一些了解:

有了对 SBTScala 的一些了解作为前置知识,看下面的内容会比较轻松。(部分单词为了表达上的准确并没有翻译成中文)

简单的例子

下面这个例子来自于Testing in Scala一书,先来直观的感觉一下ScalaTest这个测试框架。

例子中用到的类说明:
Artist类:

// Artist类有两个属性 firstNam表示名字 和 lastName姓氏
class Artist(val firstName: String, val lastName: String)

Albumo类:

// Album 有三个属性 title表示专辑名称 year表示发行年份 artist表示专辑作者
class Album(val title: String, val year: Int, val artist: Artist)

测试代码如下:

import org.scalatest.{FunSpec, ShouldMatchers}
class AlbumTest extends FunSpec with ShouldMatchers {
describe("An Album") {
it("can add an Artist object to the album") {
val album = new Album("Thriller", 1981, new Artist("Michael", "Jackson"))
album.artist.firstName should be("Michael")
}
}
}

在终端输入test-only AlbumTest回车,得到如下输出:

[info] AlbumTest:
[info] An Album
[info] - can add an Artist object to the album
[info] Run completed in 197 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 0 s, completed May 18, 2015 9:47:05 PM

根据输入信息可以知道通过测试为1个,则说明我们要验证的can add an Artist object to the album这个操作是可以执行的。

有了直观的认识,下面来详细的说明ScalaTest这个测试框架。

在SBT项目中使用ScalaTest

本文中讨论的项目都以SBT构建,相关知识可以在 SBT官网 学习。

要在SBT的项目中使用ScalaTest测试框架,需要在build.sbt文件中添加ScalaTest的依赖。

可以使用

libraryDependencies += "org.scalatest" %% "scalatest" % "2.2.4"

让SBT自动寻最合适的编译版本或者使用

libraryDependencies += "org.scalatest" % "scalatest_2.10.4" % "2.2.4"

手动指定编译版本来添加依赖。

这里的区别仅在于%% "scalatest"% "scalatest_2.10.4",关于区别的详情,请参看 SBT官网

Machers

在前面的测试代码

import org.scalatest.{FunSpec, ShouldMatchers}
class AlbumTest extends FunSpec with ShouldMatchers {
describe("An Album") {
it("can add an Artist object to the album") {
val album = new Album("Thriller", 1981, new Artist("Michael", "Jackson"))
album.artist.firstName should be("Michael")
}
}
}

里,我们作出了一个断言:Thriller这张专辑的作者的firstName是Michael。可以看到在代码的第7行有一个should,这就是ScalaTest中的两大类Macher之一的Should Macher,另一类是Must Macher,用must来表示。

Macher的类型

前面讲到ScalaTest中的Macher分为两大类--Should MacherMust Macher。它们的区别仅在测试报告中体现,所以在后面不同情形下的Macher的说明中,只以Should Macher为例。

Simple Macher

Simple Macher就是在两个值之间使用一个断言,如下例:

val list = 2 :: 4 :: 5 :: Nil
list.size should be(3)

这里使用了Should Macher,来判断list.size是否和3相等。如果list.siz的大小不为3,则测试无法通过。

这里有几点需要注意的:

  • 右边的值需要使用圆括号()括起来
// 这种写法会导致编译错误
list.size should be 3
  • 可以将be替换为equal
// 这种写法和 list.size should be(3) 等价
list.size should equal(3)
  • 在ScalaTest中基本不使用==!=进行条件断言

如果上面的代码写成:

list.size == 5

这样写只会验证list.size == 5这是表达式是true或者false,并不会进行断言的验证,因而不会有TestFailedException异常抛出,测试将继续运行。

String Macher

String Macher为字符串断言提供了一些有用的方法,利用这些方法可以判断一个字符串是否包含另一个字符串、一个字符串以某个字符串开头或结尾、一个字符串是否能匹配一个正则表达式等。如下面的例子:

val string = """I fell into a burning ring of fire.
I went down, down, down and the flames went higher"""
// 以 "I fell" 字符串开头
string should startWith("I fell")
// 以 "higher" 字符串结尾
string should endWith("higher")
// 不以 "My favorite friend, the end" 字符串结尾
string should not endWith "My favorite friend, the end"
// 包含 "down, down, down" 字符串
string should include("down, down, down")
// 不包含 "Great balls of fire" 字符串
string should not include ("Great balls of fire")
// 以匹配正则表达式 "I.fel+" 的字符串开头
string should startWith regex ("I.fel+")
// 以匹配正则表达式 "h.{4}r" 的字符串结尾
string should endWith regex ("h.{4}r")
// 不以匹配正则表达式 "\\d{5}" 的字符串结尾
string should not endWith regex("\\d{5}")
// 包含匹配正则表达式 "flames?" 的字符串
string should include regex ("flames?")
// 完全匹配正则表达式 "I(.|\n|\S)*higher"
string should fullyMatch regex ("""I(.|\n|\S)*higher""")

Relational Operator Macher

ScalaTest框架支持关系运算符,如下面的例子:

val answerToLife = 42
answerToLife should be < (50)
answerToLife should not be > (50)
answerToLife should be > (3)
answerToLife should be <= (100)
answerToLife should be >= (0)
answerToLife should be === (42)
answerToLife should not be === (400)

上面的例子中,要解释的应该只有===这个运算符了,这个运算符验等它左边的部分是否等于右边的部分。在前面说过,==只是验证值是否相等并不会验证断言,因此在涉及验证是否相等时最好使用should beshould equal===

Floating-point Macher

浮点数在JVM中实际上是很复杂的,考虑一个算式0.9 - 0.8,在我们看来结果应该是0.1,实际上在REPL中执行这个运算,会得到如下的结果:

scala> 0.9 - 0.8
res0: Double = 0.09999999999999998

显然,计算结果是有误差的。在ScalaTest框架中,提供了一个plusOrMinus方法来给断言提供一个误差允许范围。如下面的例子:

// 允许右边的范围在 0.1 - 0.01 到 0.1 + 0.01 之间
(0.9 - 0.8) should be (0.1 plusOrMinus .01)
// 允许右边的范围在 40 - 0.3 到 40 + 0.3 之间
(0.4 + 0.1) should not be (40.00 plusOrMinus .30)

实际上上面例子的第4行 0.4 + 0.1 在REPL中会输出一个准确的值 0.5

Reference Macher

在Scala中,==运算符不会验证引用是否相等,要验证引用是否相等,在ScalaTest中提供了theSameInstanceAs方法,如下面的例子:

val garthBrooks = new Artist("Garth", "Brooks")
val chrisGaines = garthBrooks
garthBrooks should be theSameInstanceAs (chrisGaines)
val debbieHarry = new Artist("Debbie", "Harry")
garthBrooks should not be theSameInstanceAs(debbieHarry)

Iterable Macher

对于Scala的可遍历集合类型,ScalaTest框架提供了多种进行断言的方法。如下面的例子:

List() should be('empty)
8 :: 6 :: 7 :: 5 :: 3 :: 0 :: 9 :: Nil should contain(7)

上面代码的第一行使用了一个Scala的符号--'empty,Scala中的符号是不可变的占位符。

Seq and Traversable Macher

对于SeqTraversable类型的Scala变量,SalaTest提供了lengthsize这两个Macher来判定它们的大小(长度)。如下面的例子:

(1 to 9) should have length (9)
(20 to 60 by 2) should have size (21)

实际上要据Scala文档,lengthsize是等价的,使用哪个完全看你的偏好。

Map Macher

而对于Map类型的Scala变量,ScalaTest提供了一些特殊的方法,可以用来判断一个key或者value是否在Map中。如下面的例子:

val map = Map("Jimmy Page" -> "Led Zeppelin", "Sting" -> "The Police", "Aimee Mann" -> "Til\' Tuesday")
// map中应该包含值为 "Sting" 的key
map should contain key ("Sting")
// map中应该包含值为 "Led Zeppelin" 的value
map should contain value ("Led Zeppelin")
// map中应该不包含值为 "Brian May" 的key
map should not contain key("Brian May")

Compound Macher

ScalaTest中的andor方法可以用来在测试中使用组合的断言。如下面的例子:

val redHotChiliPeppers = List("Anthony Kiedis", "Flea", "Chad Smith", "Josh Klinghoffer")
// redHotChiliPeppers变量中应该包含 "Anthony Kiedis" 不应该包含 "John Frusciante" 和 "Dave Navarro"
redHotChiliPeppers should (contain("Anthony Kiedis") and (not contain ("John Frusciante") or contain("Dave Navarro")))

在使用组合的Macher时,圆括号()的使用可能会造成一此困扰,下面是一些规则:

  • andor的断言必须使用圆括号()包围起来
  • 断言的右边必须使用圆括号()包围起来

以下面的例子来说明上面两条规则:

// 这会导致编译错误
redHotChiliPeppers should not contain "The Edge" or contain "Kenny G"
// 这也会导致编译错误
redHotChiliPeppers should not (contain "The Edge" or contain "Kenny G")
// 这是正确的写法
redHotChiliPeppers should not (contain ("The Edge") or contain ("Kenny G"))

除了上面的两条规则,还有一点需要注意的:使用组合Macherandor并不是短路的。换句话说,就是所有的子句都会被验证。如下面的例子:

var total = 3
redHotChiliPeppers should not (contain ("The Edge") or contain {total += 6; "Kenny G"})
total should be (9)

如果发生短路total should be (9)这里肯定不能通过,not contain ("The Edge")已经是true,则or运算没必要再运行。但执行完这个测试发现total的值已经是9,说明此时并没有发生短路

Scala中有一个Option类型,其值可以为SomeNone,因此,在Scala中基本不会使用null来做处理。ScalaTest是支持Java的,因此在有些情况下需要用到null。如下面的例子:

gorillaz should (not be (null) and contain ("Damon Albarn"))

上面的例子中如果gorillaznull则会抛出NullPointerException异常。更好的最法是将组合Macher拆开,变成下面的形式:

gorillaz should not be (null)
gorillaz should contain ("Damon Albarn")

经过上面的处理,如果gorillaznull,测试不会通过,但其它的测试不会抛出NullPointerException异常。

Property Macher

ScalaTest也提供了一个很不错的方式来验证对象的属性,如下面的例子:

import scala.collection.mutable.WrappedArray
val album = new Album("Blizzard of Ozz", 1980, new Artist("Ozzy", "Osbourne"))
album should have (
'title ("Blizzard of Ozz"),
'year (1980),
'artist (new Artist("Ozzy", "Osbourne"))
)

属性Macher可以将对象的属性取出来,然后对这些属性进行断言。这里将属性取出来实际上是使用了对象的getter方法,所以需要保证在对象中有getter方法并且能调用到。

java.util.Collection.machers

ScalaTest是Java友好的,因而它可以像在Scala集合上一样在Java集合上做断言,下面的例子使用了一些在之前用到的方法。

import java.util.{List => JList, ArrayList => JArrayList, Map => JMap, HashMap => JHashMap}
val jList: JList[Int] = new JArrayList[Int](20)
jList.add(3); jList.add(6); jList.add(9)
val emptyJList: JList[Int] = new JArrayList[Int]()
emptyJList should be('empty)
jList should have length (3)
jList should have size (3)
jList should contain(6)
jList should not contain (10)
val backupBands: JMap[String, String] = new JHashMap()
backupBands.put("Joan Jett", "Blackhearts")
backupBands.put("Tom Petty", "Heartbreakers")
backupBands should contain key ("Joan Jett")
backupBands should contain value ("Heartbreakers")
backupBands should not contain key("John Lydon")

上面例子中的一些Scala语法在这里就不多说了,可以看到在ScalaTest在Java集合上的操作和在Scala集合上的操作是一样的。

Must Macher

在前面的一些例子中,都是使用Should Machershould这个关键字,实际上可以把前面的should都换成must。如下面的一些例子:

val list = 2 :: 4 :: 5 :: Nil
list.size must be(3)
val string = """I fell into a burning ring of fire.
I went down, down, down and the flames went higher"""
string must startWith regex ("I.fel+")
string must endWith regex ("h.{4}r")
val answerToLife = 42
answerToLife must be < (50)
answerToLife must not be >(50)
val garthBrooks = new Artist("Garth", "Brooks")
val chrisGaines = garthBrooks
val debbieHarry = new Artist("Debbie", "Harry")
garthBrooks must be theSameInstanceAs (chrisGaines)
(0.9 - 0.8) must be(0.1 plusOrMinus .01)
List() must be('empty)
1 :: 2 :: 3 :: Nil must contain(3)
(1 to 9).toList must have length (9)
(20 to 60 by 2).toList must have size (21)
val map = Map("Jimmy Page" -> "Led Zeppelin", "Sting" -> "The Police", "Aimee Mann" -> "Til\' Tuesday")
map must contain key ("Sting")
map must contain value ("Led Zeppelin")
map must not contain key("Brian May")
val redHotChiliPeppers = List("Anthony Kiedis", "Flea", "Chad Smith", "Josh Klinghoffer")
redHotChiliPeppers must (contain("Anthony Kiedis") and (not contain ("John Frusciante") or contain("Dave Navarro")))

上面的这些例子只是把之前的例子中的should换成了must,这完全是等价的。正如之前说过的,Should MacherMust Macher的不同之处只在测试报告中体现。

异常处理

ScalaTest中,有两中方式来验证异常的抛出和捕获。intercept blockevaluating block

intercept block
intercept block这种方式把任何可能抛出异常的代码放入一个intercept代码块中,如果代码块没有抛出异常,则测试失败。如下面的例子:

"An album" should {
"throw an IllegalArgumentException if there are no acts when created" in {
intercept[IllegalArgumentException] {
new Album("The Joy of Listening to Nothing", 1980, List())
}
}
}

上面的例子表示在创建Album时这么写会捕获一个IllegalArgumentException.而使用了一个List()只是为了说明在创建Album时不创建Artist,实际上代码在编译时就会报错。

evaluating block
evaluating block这种方式将可能抛出异常的代码放入一个evaluating代码块中,使用一个shouldmust加上一个produce关键字来指明异常的类型。如下面的例子:

val thrownException = evaluating {new Album("The Joy of Listening to Nothing", 1980, List())} must produce [IllegalArgumentException]
thrownException.getMessage() must be ("An Artist is required")

intercept blockevaluating block其实做的是同样的事情,但是使用evaluating block方式可以捕获到抛出的异常。如果一次调用可能抛出多个不同的异常,那么这种方法的好处就是可以捕获到异常然后判断出抛出的是哪个异常。如果引起某个异常的原因有多个,如上例中的IllegalArgumentException可能是Artist未创建造成的,也可能是year这个参数不合法造成的。那么在这种情况下要确保异常的信息是An Artist is required,这就只能使用evaluating block这种方式了。

Informers

Informer在ScalaTest中跟debug语句很相似,但它可以放在任何地方来输出一些跟测试相关的信息。使用Informer是很简单的,只要调用info(String)这上方法就好了。如下面的例子:

import org.scalatest.{FunSpec, ShouldMatchers}
class AlbumTest extends FunSpec with ShouldMatchers {
describe("An Album") {
it("can add an Artist object to the album") {
val album = new Album("Thriller", 1981, new Artist("Michael", "Jackson"))
info("Test firstName should be Michael")
album.artist.firstName should be("Thriller")
}
}
}

运行测试,得到如下输出结果:

[info] AlbumTest:
[info] An Album
[info] - can add an Artist object to the album
[info] + Test firstName should be Michael
[info] Run completed in 231 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 0 s, completed May 19, 2015 9:19:28 PM

可以和简单的例子中的输出结果进行比较,发现在第4行多出了一个+ Test firstName should be Michael,这里就是Informer的输出了,以+开头。

GivenWhenThen

在了解了Informer之后,GivenWhenThen就比较简单了。实际上,任何一个过程者可以被描述为Given--When--ThenGiven相当于所给的前置条件,When相当于产生了某个动作或处于某种条件下,Then表示前面两个条件产生的结果。如下面的例子:

import core.{Artist, Album}
import org.scalatest.{GivenWhenThen, ShouldMatchers, FunSpec}
class AlbumSpec extends FunSpec with ShouldMatchers with GivenWhenThen {
describe("An Album") {
it("can add an Artist to the album at construction time") {
Given("The album Thriller by Michael Jackson")
val album = new Album("Thriller", 1981, new Artist("Michael", "Jackson"))
When("the album\'s artist is obtained")
val artist = album.artist
Then("the artist obtained should be an instance of Artist")
artist.isInstanceOf[Artist] should be(true)
and("the artist's first name and last name should be Michael Jackson")
artist.firstName should be("Michael")
artist.lastName should be("Jackson")
}
}

运行上面的测试,将产生如下的结果:

[info] AlbumSpec:
[info] An Album
[info] - can add an Artist to the album at construction time
[info] + Given The album Thriller by Michael Jackson
[info] + When the album's artist is obtained
[info] + Then the artist obtained should be an instance of Artist
[info] + And the artist's first name and last name should be Michael Jackson
[info] Run completed in 216 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 0 s, completed May 19, 2015 9:31:47 PM

可以看到GivenWhenThenand里面的字符串都是以Informer的形式输出的,使用一个and将测试的内容分开了,加强了可读性。而GivenWhenThen是一个特质,可以被混入任何的类。GivenWhenThen使测试变得结构化,使得在测试时可以很好的组织思想。

待测试

待测试(Pending Test)这个思想我觉得在实际中会用的比较多。pending是一个占位符,可以将尚未实现或定义的测试以pending来填充。Pending Test实际上就是利用pending来将测试标记为TODO的。如下面的例子:

class AlbumSpec extends FunSpec with ShouldMatchers with GivenWhenThen {
describe("An Album") {
it("can add an Artist to the album at construction time") {pending}
it("can add opt to not have any artists at construction time") {pending}
}
}

运行测试,得到如下结果:

[info]AlbumSpec:
[info]An Album
[info]- can add an Artist to the album at construction time (pending)
[info]- can add opt to not have any artists at construction time (pending)

可以看到,测试都被标记为了pending

我们可以将pending关键字一直放在测试的最下面,直到一个测试完全的写完。如下面的例子:

class AlbumSpec extends FunSpec with ShouldMatchers {
describe("An Album") {
it("can add an Artist to the album at construction time") {
val album = new Album("Thriller", 1981, new Artist("Michael", "Jackson"))
info("Making sure that Michael Jackson is indeed the artist of Thriller")
pending
}
it("can add opt to not have any artists at construction time") {pending}
}
}

运行测试,如我们所料,将输出下面的结果:

[info]AlbumSpec:
[info]An Album
[info]- can add an Artist to the album at construction time (pending)
[info] + Making sure that Michael Jackson is indeed the artist of Thriller
[info]- can add opt to not have any artists at construction time (pending)

忽略测试

可能有这么一种情境:某个测试案例,可能由于生产代码被修改而处于一种可有可无的状态。如果留着,在进行测试的时候浪费执行时间,如果删除又怕在后期还要使用到。此时可以使用ignore来标记该测试,这样在执行test指令时将不会运行它,但同时又将它保存下来了。如下面的例子:

import org.scalatest.{FunSpec, ShouldMatchers}
class AlbumTest extends FunSpec with ShouldMatchers {
describe("An Album") {
it("can add an Artist object to the album") {
val album = new Album("Thriller", 1981, new Artist("Michael", "Jackson"))
album.artist.firstName should be("Michael")
}
ignore("can add a Producer to an album at construction time") {
new Album("Breezin\'", 1976, new Artist("George", "Benson"))
//TODO: Figure out the implementation of an album producer
}
}
}

运行测试,将得到如下的输出:

[info]AlbumSpec:
[info]An Album
[info]- can add an Artist to the album at construction time
[info]- can add a Producer to an album at construction time !!! IGNORED !!!

这是因为第二个测试can add a Producer to an album at construction time中的itignore给替代了,在运行测试时,它将被忽略。在上面的输出结果中的反映就是在测试名后面加上了!!! IGNORED !!!。如果要恢复这个测试,只需要将ignore替换成it就好了。

标记

标记(Tagging)功能给测试加上标签,这样就可以分组运行测试了。标记可以在下面这些场景中运用:

  • 你想跳过某些很费时的测试
  • 某些测试是检查一些相关的功能 需要在一起执行
  • 你想给测试分成单元测试综合测试验收测试等分类时

不同的测试接口对标记都有自己的实现,但都是使用字符串来进行分类标记。如下面的例子:

it("can add an Artist to the album at construction time", Tag("construction")) {
// 其它代码
}

上面的例子是在FunSpec接口中的实现,给can add an Artist to the album at construction time这个测试添加了construction的标记。

在SBT中运行特定标记的测试也有一些需要注意的地方:

  • SBT的test命令暂时还不能支持运行指定标签的测试

SBT支持多种测试框架,要使test命令能够按指定标签执行测试,则需要所有SBT支持的测试框架都支持标签功能,现在ScalaTestSpecs2都支持了标签,但ScalaCheck目前并不支持标签功能。

  • SBT的test-only命令是支持执行指定标签的测试的

可以用下例中的方式使用test-only命令来运行指定的测试:

test-only AlbumTest -- -n construction

在待测试类名的后面加上--再加上n再加上标签,来指行指定的测试(有多个标签 则需要用双引号"将标签包围起来)。如果要排除某个标签,将前面说的n换成l即可。

Specifications

FunSpec

下面的FunSpec整合了前面说到的InformerGivenWhenThenpendingignoretag

import org.scalatest.matchers.ShouldMatchers
import org.scalatest.{Tag, GivenWhenThen, FunSpec}
class AlbumSpecAll extends FunSpec with ShouldMatchers with GivenWhenThen {
describe("An Album") {
it("can add an Artist to the album at construction time", Tag("construction")) {
given("The album Thriller by Michael Jackson")
val album = new Album("Thriller", 1981, new Artist("Michael", "Jackson"))
when("the artist of the album is obtained")
artist = album.artist
then("the artist should be an instance of Artist")
artist.isInstanceOf[Artist] should be(true)
and("the artist's first name and last name should be Michael Jackson")
artist.firstName should be("Michael")
artist.lastName should be("Jackson")
info("This is still pending, since there may be more to accomplish in this test")
pending
}
ignore("can add a Producer to an album at construction time") {
//TODO: Add some logic to add a producer.
}
}
}

上面的例子中,SlbumSpecAll继随了类FunSpec混入特质ShouldMachersGivenWhenThen。在前面提到过,ScalaTest中有很多形式的测试类,上面例子中的FunSpec就是其中之一。执行上面的测试,将得到下面的输出:

[info] AlbumSpecAll:
[info] An Album
[info] - can add an Artist to the album at construction time (pending)
[info]
+ Given The album Thriller by Michael Jackson
[info]
+ When Artist of the album is obtained
[info]
+ Then the Artist should be an instance of Artist
[info]
+ And the artist's first name and last name should be Michael Jackson
[info]
+ This is still pending, since there may be more to accomplish in this
test
[info] - can add a Producer to an album at construction time !!! IGNORED !!!
[info] Passed: : Total 2, Failed 0, Errors 0, Passed 0, Skipped 2

注意上面的输出结果,包含pending关键字的测试将被Skip

也可以只执行有某个标记的测试:

test-only AlbumSpecAll -- -n construction
[info] AlbumSpecAll:
[info] An Album
[info] - can add an Artist to the album at construction time (pending)
[info] + Given The album Thriller by Michael Jackson
[info] + When Artist of the album is obtained
[info] + Then the Artist should be an instance of Artist
[info] + And the artist's first name and last name should be Michael Jackson
[info] + This is still pending, since there may be more to accomplish in this test
[info] Passed: : Total 1, Failed 0, Errors 0, Passed 0, Skipped 1

只有标记为construction的测试才会被执行。

这就是FuncSpec这个测试类的用法了,一个测试类继承了FuncSpec,将使用如下形式的代码风格写测试:

import org.scalatest.matchers.ShouldMatchers
import org.scalatest.{Tag, GivenWhenThen, FunSpec}
class A extends FunSpec with ShouldMachers {
describe("一些描述信息") {
it("一些描述信息") {
}
// 其它的 it
}
}

在断言中,可以使用ShouldMacherMustMacherInformer、等特质提供的方法,在describe代码块中也可以使用GivenWhenThen特质来使测试更加结构化。

WordSpec

在研究WordSpec之前,先对前面说到的一些基本类进行一些修改:

Act类

class Act

Album类

class Album(val title: String, val year: Int, val acts: Act*)

Band类

class Band(name: String, members: List[Artist]) extends Act

WordSpecScalaTest提供的另一个测试类,它大量使用了whenshouldcan这些属于String的方法。如下面的例子:

import org.scalatest.{ShouldMatchers, WordSpec}
class AlbumWordSpec extends WordSpec with ShouldMatchers {
"An Album" when {
"created" should {
"accept the title, the year, and a Band as a parameter, and be able to read those parameters back" in {
new Album("Hotel California", 1997,
new Band("The Eagles", List(new Artist("Don", "Henley"),
new Artist("Glenn", "Frey"),
new Artist("Joe", "Walsh"),
new Artist("Randy", "Meisner"),
new Artist("Don", "Felder"))))
}
}
}
}

运行上面例子的测试,将得到如下结果:

[info] AlbumWordSpec:
[info] An Album
[info] when created
[info] - should accept the title, the year, and a Band as a parameter, and be able to read those parameters back
[info] Run completed in 170 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 0 s, completed May 20, 2015 7:39:45 AM

从上面的结果,结合例子中的代码,可以看出在WordSpec中,一个测试类继承WordSpec类,使用如下形式的代码风格写测试:

import org.scalatest.{ShouldMatchers, WordSpec}
class A extends WordSpec with ShouldMatchers {
"一些描述" when {
"一些描述" should {
"一些描述" in {
// 其它代码
}
}
}
}

在一个when代码块中,可以使用多个should代码块,同时should代码块可以不包含在when代码块中。如下面的例子:

import org.scalatest.{ShouldMatchers, WordSpec}
class AlbumWordSpec extends WordSpec with ShouldMatchers {
"An Album" when {
"created" should {
"accept the title, the year, and a Band as a parameter, and be able to read those parameters back" in {
new Album("Hotel California", 1997,
new Band("The Eagles", List(new Artist("Don", "Henley"),
new Artist("Glenn", "Frey"),
new Artist("Joe", "Walsh"),
new Artist("Randy", "Meisner"),
new Artist("Don", "Felder"))))
}
}
}
"lack of parameters" should {
"throw an IllegalArgumentException if there are no acts when created" in {
intercept[IllegalArgumentException] {
new Album("The Joy of Listening to Nothing", 2000)
}
}
}
}

运行上面的测试,会得到如下的输出:

[info] AlbumWordSpec:
[info] An Album
[info] when created
[info] - should accept the title, the year, and a Band as a parameter, and be able to read those parameters back
[info] lack of parameters
[info] - should throw an IllegalArgumentException if there are no acts when created
[info] Run completed in 173 milliseconds.
[info] Total number of tests run: 2
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 2, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 0 s, completed May 20, 2015 7:49:07 AM

info信息的缩进可以看出whenshould的包含关系,以上就是WordSpec的用法了。

FeatureSpec

FeatureSpec可以通过测试的一些特征(feature)将测试进行分类,而每一个特征(feature)又包含若干不同的情节(scenario)。每个特征(feature)和情节(scenario)都需要用不同的字符串来描述。如下面的例子:

import org.scalatest.matchers.ShouldMatchers
import org.scalatest.FeatureSpec
class AlbumFeatureSpec extends FeatureSpec with ShouldMatchers {
feature("An album's default constructor should support a parameter that acceptsOption(List(Tracks)) ") { ... }
feature("An album should have an addTrack method that takes a track and returns an immutable copy of the Album with the added track") { ... }
}

上面的例子中,我们定义了一个AlbumFeatureSpec类,它继承了FeatureSpec类。在AlbumFeatureSpec类中,写了两个feature代码块,但在这两个代码块中并未写任何的scenario。在继续分析上例代码前,需要再给Album类添加一些内容。

Track

class Track(name: String)

Album类

class Album (val title:String, val year:Int, val tracks:Option[List[Track]], val acts:Act*) {
require(acts.size > 0)
def this(title:String, year:Int, acts:Act*) = this (title, year, None, acts:_*)
}

首先来实现第一个feature代码块,我们希望给它加入下面的一些scenario

  • 构造Album时提供一个长度为3的List[Track]
  • 构造Album时提供一个空List
  • 构造Album时提供一个null

首先对上例中代码的第一个feature填充三个scenario,得到如下代码:

class AlbumFeatureSpec extends FeatureSpec with ShouldMatchers {
feature("An album's default constructor should support a parameter that accepts Option(List(Tracks))") {
scenario ("Album's default constructor is given a list of the 3 tracks exactly for the tracks parameter") {pending}
scenario ("Album's default constructor is given an empty List for the tracks parameter") {pending}
scenario ("Album's default constructor is given null for the tracks parameter") {pending}
}
feature("An album should have an addTrack method that takes a track and returns an immutable copy of the Album with the added track") { }
}

接下来要做的就是给这三个scenario加上实现的代码。

Album's default constructor is given a list of the 3 tracks exactly for the tracks parameter这个scenario中,我们加入如下代码:

val depecheModeCirca1990 = new Band("Depeche Mode", List(
new Artist("Dave", "Gahan"),
new Artist("Martin", "Gore"),
new Artist("Andrew", "Fletcher"),
new Artist("Alan", "Wilder")))
val blackCelebration = new Album("Black Celebration", 1990,
Some(List(new Track("Black Celebration"),
new Track("Fly on the Windscreen"),
new Track("A Question of Lust"))), depecheModeCirca1990)
blackCelebration.tracks.get should have size (3)

接下来是Album's default constructor is given an empty List for the tracks parameter这个scenario

given("the band, the Doobie Brothers from 1973")
val theDoobieBrothersCirca1973 = new Band("The Doobie Brothers",
new Artist("Tom", "Johnston"),
new Artist("Patrick", "Simmons"),
new Artist("Tiran", "Porter"),
new Artist("Keith", "Knudsen"),
new Artist("John", "Hartman"))
when("the album is instantiated with the title, the year, none tracks, and the Doobie Brothers")
val album = new Album("The Captain and Me", 1973, None, theDoobieBrothersCirca1973)
then("calling the albums's title, year, tracks, acts property should yield the same results")
album.title should be("The Captain and Me")
album.year should be(1973)
album.tracks should be(None)
album.acts(0) should be(theDoobieBrothersCirca1973)

第三个scenario我这里就不写了,下面来看一下运行测试的结果:

[info] AlbumFeatureSpec:
[info] Feature: An album's default constructor should support a parameter that accepts Option(List(Tracks))
[info] Scenario: Album's default constructor is given a list of the 3 tracks exactly for the tracks parameter
[info] Scenario: Album's default constructor is given a None for the tracks parameter
[info] Given the band, the Doobie Brothers from 1973
[info] When the album is instantiated with the title, the year, none tracks, and the Doobie Brothers
[info] Then calling the albums's title, year, tracks, acts property should yield the same results
[info] Scenario: Album's default constructor is given null for the tracks parameter (pending)
[info] Feature: An album should have an addTrack method that takes a track and returns an immutable copy of the Album with the added track
[info] Run completed in 177 milliseconds.
[info] Total number of tests run: 2
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 2, failed 0, canceled 0, ignored 0, pending 1
[info] All tests passed.
[success] Total time: 0 s, completed May 20, 2015 8:38:47 AM

FeatureSpec使用特征(feature)将测试进行分类,每一个特征(feature)又包含若干不同的情节(scenario),对这些情节(scenario)的实现实际上就是完成测试的过程。

FreeSpec

FreeSpec是一种形式比较自由的测试,先引入一个类:

JukeBox类

class JukeBox(val albums:Option[List[Album]]) {
def readyToPlay = albums.isDefined
}

再来看一个例子:

import org.scalatest.matchers.ShouldMatchers
import org.scalatest.FreeSpec
class JukeboxFreeSpec extends FreeSpec with ShouldMatchers {
"given 3 albums" - {
val badmotorfinger = new Album("Badmotorfinger", 1991, None, new Band("Soundgarden"))
val thaDoggFather = new Album("The Dogg Father", 1996, None, new Artist("Snoop Doggy", "Dogg"))
val satchmoAtPasadena = new Album("Satchmo At Pasadena", 1951, None, new Artist("Louis", "Armstrong"))
"when a juke box is instantiated it should accept some albums" - {
val jukebox = new JukeBox(Some(List(badmotorfinger, thaDoggFather, satchmoAtPasadena)))
"then a jukebox's album catalog size should be 3" in {
jukebox.albums.get should have size (3)
}
}
}
"El constructor de Jukebox puedo aceptar la palabra clave de 'None'" - {
val jukebox = new JukeBox(None)
"y regresas 'None' cuando llamado" in {
jukebox.albums should be(None)
}
}
}

从上面的例子中,可以看到FreeSpec的结构是很自由的。描述字符串加上一个-{ }的代码块,如果需要使用断言,则使用描述字符串加上in。在FreeSpec中,并不强制使用shouldwhen等内容。在FreeSpec中,使用如下形式的代码风格写测试:

import org.scalatest.matchers.ShouldMatchers
import org.scalatest.FreeSpec
class A extends FreeSpec with ShouldMatchers {
"一些描述" - {
// 一些代码
"一些描述" in {
// 断言
}
}
}

JUnitSuite

前面我们说到的一些测试结构可能跟之前用过的如JunitTestNG这些有较大的差异,如果你比较喜欢像JUnitTestNG这种测试风格,ScalaTest也是支持的。为了使用这种风格,首先在要build.sbt文件中添加JUnit的依赖:

libraryDependencies += "junit" % "junit" % "4.12"

下面来看一个使用ScalaTest写的JUnit风格的测试:

import org.scalatest.junit.JUnitSuite
import org.junit.{Test, Before}
import org.junit.Assert._
class ArtistJUnitSuite extends JUnitSuite {
var artist:Artist = _
@Before
def startUp() {
artist = new Artist("Kenny", "Rogers")
}
@Test
def addOneAlbumAndGetCopy() {
val copyArtist = artist.addAlbum(new Album("Love will turn you around", 1982, artist))
assertEquals(copyArtist.albums.size, 1)
}
@Test
def addTwoAlbumsAndGetCopy() {
val copyArtist = artist
.addAlbum(new Album("Love will turn you around", 1982, artist))
.addAlbum(new Album("We've got tonight", 1983, artist))
assertEquals(copyArtist.albums.size, 2)
}
@After
def shutDown() {
this.artist = null
}
}

上面的例子中startUp方法被注解Before标记,addOneAlbumAndGetCopy方法和addTwoAlbumsAndGetCopy方法被注解TestshutDown方法被注解After标记。注解Test将方法标记为测试方法,而注解Before将方法标记为每个测试方法执行前执行的方法,注解After则将方法标记为每个测试方法执行后执行的方法。

因此,addOneAlbumAndGetCopy方法和addTwoAlbumsAndGetCopy方法执行前startUp方法会被调用,而方法执行结束shutDown方法会被调用。

上面例子的风格跟使用JUnit来做测试是一样的,只不过我们使用了Scala语言。

TestNGSuit

JUnit类似,在ScalaTest中也提供了TestNG风格的测试写法。同样的,需要使用TestNG风格,要先在build.sbt中添加TestNG的依赖:

libraryDependencies += "org.testng" % "testng" % "6.8.21"

我们也会一个例子来说明:

import org.scalatest.testng.TestNGSuite
import collection.mutable.ArrayBuilder
import org.testng.annotations.{Test, DataProvider}
import org.testng.Assert._
class ArtistTestNGSuite extends TestNGSuite {
@DataProvider(name = "provider")
def provideData = {
val g = new ArrayBuilder.ofRef[Array[Object]]()
g += (Array[Object]("Heart", 5.asInstanceOf[java.lang.Integer]))
g += (Array[Object]("Jimmy Buffet", 12.asInstanceOf[java.lang.Integer]))
g.result()
}
@Test(dataProvider = "provider")
def testTheStringLength(n1:String, n2:java.lang.Integer) {
assertEquals(n1.length, n2)
}
}

上面的例子中,provideData方法被注解DataProvider标记,testTheStringLength方法被注解Test标记。注解Test将方法标记为测试方法,属性dataProvider指定了测试数据由哪个DataProvider来提供。注解DataProvider将一个方法标记为一个DataProvider

上面例子中的测试执行,则testTheStringLength测试法的中的测试数据是来自于provideData这个方法。

另外一点,在TestNG中,标签(Tag)功能被称为group,给一个测试添加group的写法如下:

@Test(dataProvider = "provider", groups=Array("word_count_analysis"))
def testTheStringLength(n1:String, n2:java.lang.Integer) {
assertEquals(n1.length, n2)
}

使用如下命令执行指定group的测试:

test-only ArtistTestNGSuite -- -n word_count_analysis

关于上面这条命令中---n等符号、参数的含义在之前的标记里已经分析过了。

Fixtures

Fixture翻译成中文有这么些意思:固定装置;卡具;固定附物,固定附着物;固定财产,在ScalaTest中,可能会有这么一种情境:在一个测试类中,不同的测试方法需要的类实例、依赖等数据是一样的,显然,没必要为每个测试类去new一些它们专用的数据,可以提供一些公共的数据,然后在不同的测试方法中重用它们。

要做到数据的重用,有很多方法:

  • Scala语言自带的方法
  • ScalaTest测试框架提供的解决方案
  • 每一种测试方法也有自己的一些实现
  • JUnitTestNG也有它们自己的结构

匿名对象

先从Scala语言本身提供的方案说起。Scala语言提供的匿名对象可以用来解决前面说到的数据重用的问题。Scala中的匿名对象就是没有名字的对象,匿名对象一旦被创建,就可以在不同的测试方法中重用。每次匿名对象被请求的时候,它都会创建一个全新的对象。如下面的例子:

import org.scalatest.matchers.ShouldMatchers
import org.scalatest.FunSpec
class AlbumFixtureSpec extends FunSpec with ShouldMatchers {
def fixture = new {
val letterFromHome = new Album("Letter from Home", 1989, new Band("Pat Metheny Group"))
}
describe("The Letter From Home Album by Pat Metheny") {
it("should get the year 1989 from the album") {
val album = fixture.letterFromHome
album.year should be (1989)
}
}
}

上面的例子定义了一个fixture方法来获取一个Album对象,fixture方法每次被调用都会产生一个匿名对象

这里有一点需要注意的,即使fixture方法产生的是一个可变mutable的对象,在另一个方法调用fixture时,它仍然后产生一个新的对象,而不是提供之前的对象。下面的例子使用了可变集合来说明:

import org.scalatest.FunSpec
import org.scalatest.matchers.ShouldMatchers
class AlbumMutableFixtureSpec extends FunSpec with ShouldMatchers {
def fixture = new {
import scala.collection.mutable._
val beachBoys = new Band("Beach Boys")
val beachBoysDiscography = new ListBuffer[Album]()
beachBoysDiscography += (new Album("Surfin' Safari", 1962, beachBoys))
}
describe("Given a single fixture each beach boy discography initially contains a single album") {
it("then after 1 album is added, the discography size should have 2") {
val discographyDeBeachBoys = fixture.beachBoysDiscography
discographyDeBeachBoys += (new Album("Surfin' Safari", 1962, fixture.beachBoys))
discographyDeBeachBoys.size should be(2)
}
it("then after 2 albums are added, the discography size should return 3") {
val discographyDeBeachBoys = fixture.beachBoysDiscography
discographyDeBeachBoys += (new Album("Surfin' Safari", 1962, fixture.beachBoys))
discographyDeBeachBoys += (new Album("All Summer Long", 1964, fixture.beachBoys))
discographyDeBeachBoys.size should be(3)
}
}
}

跟前一个例子一样,上面的例子使用了fixture方法,在Scala语言中,使用def定义的方法在每次被调用的时候都会重新执行方法体。因而,在每个测试方法中我们得到的都是新的实例。

Fixture Traits

另一种在ScalaTest中的可供选择的做法是自定义一个特质来确保每个测试方法都得到不同的对象。特质在混入对象后仍然后持有它原来的方法,并不会在混入的对象之中共享。下面的例子使用一个特质而不是一个匿名对象

import org.scalatest.matchers.ShouldMatchers
import org.scalatest.FunSpec
class AlbumFixtureTraitSpec extends FunSpec with ShouldMatchers {
trait AlbumFixture {
val letterFromHome = new Album("Letter from Home", 1989, new Band("Pat Metheny Group"))
}
describe("The Letter From Home Album by Pat Metheny") {
it("should get the year 1989 from the album") {
new AlbumFixture {
letterFromHome.year should be(1989)
}
}
}
}

上面的例子使用了一个特质来封装测试方法需要的数据,在特质中又使用了匿名对象的方式来创建对象,实际上,这种实现方式依然使用了Scala的语言特性。

OneInstancePerTest

除了依赖Scala的语言特性,ScalaTest也提供了方法来确保每个测试都有它自己的数据实例。下面的例子使用了OnInstancePerTest特质来实现:

import org.scalatest.matchers.ShouldMatchers
import collection.mutable.ListBuffer
import org.scalatest.{FreeSpec, OneInstancePerTest}
class AlbumListOneInstancePerTestFreeSpec extends FreeSpec with ShouldMatchers
with OneInstancePerTest {
val graceJonesDiscography = new ListBuffer[Album]()
graceJonesDiscography += (new Album("Portfolio", 1977, new Artist("Grace", "Jones")))
"Given an initial Grace Jones Discography" - {
"when an additional two albums are added, then the discography size should be 3" in {
graceJonesDiscography += (new Album("Fame", 1978, new Artist("Grace", "Jones")))
graceJonesDiscography += (new Album("Muse", 1979, new Artist("Grace", "Jones")))
graceJonesDiscography.size should be(3)
}
"when one additional album is added, then the discography size should be 2" in {
graceJonesDiscography += (new Album("Warm Leatherette", 1980, new Artist("Grace", "Jones")))
graceJonesDiscography.size should be(2)
}
}
"Given an initial Grace Jones Discography " - {
"when one additional album from 1980 is added, then the discography size should be 2" in {
graceJonesDiscography += (new Album("Nightclubbing", 1981, new Artist("Grace", "Jones")))
graceJonesDiscography.size should be(2)
}
}
}

上面的例子使用了FreeSpec风格的测试写法。在测试开始时,定义了graceJonesDiscography变量,然后该变量被用在多个测试中。由于AlbumListOneInstancePerTestFreeSpec类混入了OneInstancePerTest接口,graceJonesDiscography变量在每个测试方法中使用时都会被重新创建。

上面的例子中,测试方法是在in代码块中的内容。

Before and After

为了更好的控制在测试方法执行前、后有什么行为,ScalaTest提供了一个名为BeforeAndAfter的特质。可以很方便的指定在每一个测试方法执行前有什么行为,在每个测试方法执行后有什么行为。如下面的例子:

import collection.mutable.ListBuffer
import org.scalatest.{BeforeAndAfter, WordSpec}
import org.scalatest.matchers.ShouldMatchers
class AlbumBeforeAndAfterFixtureSpec extends WordSpec with ShouldMatchers with BeforeAndAfter {
val humanLeagueDiscography = new ListBuffer[Album]()
before {
info("Starting to populate the discography")
humanLeagueDiscography += (new Album("Dare", 1981, new Band("Human League")))
}
"A mutable ListBuffer of albums" should {
"have a size of 3 when two more albums are added to the Human League Discography" in {
humanLeagueDiscography += (new Album("Hysteria", 1984, new Band("Human League")))
humanLeagueDiscography += (new Album("Crash", 1986, new Band("Human League")))
humanLeagueDiscography should have size (3)
}
"have a size of 2 when one more album is added to the Human League Discography" in {
humanLeagueDiscography += (new Album("Romantic", 1990, new Band("Human League")))
humanLeagueDiscography should have size (2)
}
}
after {
info("Clearing the discography")
humanLeagueDiscography.clear()
}
}

上面的例子使用了WordSpec风格的测试,在测试方法执行前,初始化了一个名为humanLeagueDiscography的可变列表,在测试方法执行完毕后,humanLeagueDiscography可变列表被清空。BeforeAndAfter特质中的beforeafter方法和在JUnit中被标记为BeforeAfter的方法作用是一样的。

上面的例子中,测试方法依然是在in代码块中的内容。

简单的总结

ScalaTest是一个强大的测试框架,它是Java友好的,集成了JUnitTestNG的测试风格,是学习Scala语言的一大利器。本文通过各种代码举例,简单的说明了ScalaTest中的各种Spec,是对ScalaTest的一个简单的介绍。软件 测度是一门大学问,只有通过不断的实践才能获得属于自己的经验。

本文中大量参考了《Testing in Scala》一书,文中有些地方是对原书的翻译,本人水平有限,对知识的理解也有限,欢迎大家指点~如果您对本文有任何疑问,欢迎与我联系。

参考资料

Testing in Scala by Daniel Hinojosa (O’REILLY) 密码:5vjs

本文链接: ScalaTest测试框架
转载请注明作者 xring 和出处 xring's