Jade Dungeon

抽取器、断言与测试

抽取器(Extractors)

例子:抽取email地址

对于一个email类的话,如果是合法的email地址取出用户名与域名。

  EMail(user, domain)

模式匹配表达式可以写为:

  s match {
    case EMail(user, domain) => println(user +" AT "+ domain)
    case _ => println("not an email address")
  }

找到两个连续的同一用户的email地址的模式:

  ss match {
    case EMail(u1, d1) :: EMail(u2, d2) :: _ if (u1 == u2) => ...
    ...
  }

现在要匹配的email不是一个类,而是字符串。我们先把方法定义出来:

  def isEMail(s: String): Boolean
  def domain(s: String): String
  def user(s: String): String

调用的时候就是这样的:

  if (isEMail(s)) println(user(s) +" AT "+ domain(s)
  else println("not an email address")

抽取器

在scala对象中与apply方法相对的方法是unapply方法,而有unapply成员方法的对象 就是抽取器。unapply是为了匹配并分解值。

下面的例子中apply方法注入对象,而unapply方法从对象中抽取内容:

  object EMail {

    // The injection method (optional)
    def apply(user: String, domain: String) = user +"@"+ domain

    // The extraction method (mandatory)
    def unapply(str: String): Option[(String, String)] = {
      val parts = str split "@"
      if (parts.length == 2) Some(parts(0), parts(1)) else None
    }
  }

还可以让这个对象继承自Scala的函数类型:

  object EMail extends (String, String) => String { ... }

对象声明里的(String, String) => String的意思相当于Function2[String, String] ,是对Email类实现的抽象apply方法的声明。这样可以把Email传递给需要 Function2[String,String]的方法。

unapply返回类型为Option

注意unapply方法返回类型是Option。因为输入参数可能不是正确的email格式。

  unapply("John@epfl.ch")  equals  Some("John", "epfl.ch")
  unapply("John Doe")  equals  None

unapply与模式匹配

现在,当模式匹配到抽取器对象指定的模式就会在选择器表达式中调用抽取器的unapply 方法。如下面的代码:

  selectorString match { case EMail(user, domain) => ... }

String类型的selectorString其实先被抽取器处理:

  EMail.unapply(selectorString)

产的结构再进行模式匹配判断。

selectorString的类型虽然和unapply一样都是String,但这并不是必须的。像下面 这样检查任意类型的实例是不是email:

  val x: Any = ...
  x match { case EMail(user, domain) => ... }

apply与unapply应该有对偶关系

一般来说如果包含了注入方法,那应该与抽取方法成对偶关系。如调用:

  EMail.unapply(EMail.apply(user, domain))

应该返回:

  Some(user, domain)

也就是说被Some包装的同一序列的参数。反过来就是先执行unapply再执行apply

  EMail.unapply(obj) match { 
    case Some(u, d) => EMail.apply(u, d) 
  }

虽然这样的对偶性不是强制要求的,但强烈建议实现。

只有1个或没有变量的模式

之前unapply方法返回的是元组,这在有多个值要返回的时候很有用。但是在只有一个值 或没有值要返回的时候有麻烦,因为元组最小是二元组,没有有一元元组。

所以模式只绑定一个变量的情况要特别对待,把结果直接放在Some中:

  object Twice {
    def apply(s: String): String = s + s
    def unapply(s: String): Option[String] = {
      val length = s.length / 2
      val half = s.substring(0, length)
      if (half == s.substring(length)) Some(half) else None
    }
  }

还有一种情况下抽取器模式不绑定任何变量。这样情况下返回布尔值表示匹配成功或失败:

  object UpperCase {
    def unapply(s: String): Boolean = s.toUpperCase == s
  }

注意上面的代码没有apply,因为本来就没有什么好构造的。

下面的userTwiceUpper函数的模式匹配代码集中了前面定义的所有抽取器:

  def userTwiceUpper(s: String) = s match {
    case EMail(Twice(x @ UpperCase()), domain) =>
      "match: "+ x +" in domain "+ domain
    case _ => 
      "no match"
  }

函数的第一部分匹配email地址,并且用户名部分需要由大家字母形式的相同字符串出现 两次组成。

注意第二行里的UpperCase的空参数列表()是不能省略的,不然会被解释为与 UpperCase对象进行匹配。还要注意虽然UpperCase()本身没有绑定任何变量,但还可以 把变量与匹配它的整个模式联系起来。用模式中的变量绑定方案;以x @ UpperCase()的 模式把xUpperCase()匹配的模式联系起来。例如在第一个userTwiceUpper调用中, x被绑定为DI,因为匹配于UpperCase()模式的值。

  scala> userTwiceUpper("DIDI@hotmail.com")
  res0: java.lang.String = match: DI in domain hotmail.com

  scala> userTwiceUpper("DIDO@hotmail.com")
  res1: java.lang.String = no match

  scala> userTwiceUpper("didi@hotmail.com")
  res2: java.lang.String = no match

可变参数的抽取器

希望的元素个数是可变的,如对域名的处理:

  dom match {
    case Domain("org", "acm") => println("acm.org")
    case Domain("com", "sun", "java") => println("java.sun.com")
    case Domain("net", _*) => println("a .net domain")
  }

可以看到上面的域名是反向展开的,最后一个情况下下_*剩下的所有元素。unapply值 的返回个数定下了就不能改了,所以Scala允许为变参数定义不同的抽取方法unapplySeq

  object Domain {

    // The injection method (optional)
    def apply(parts: String*): String = 
      parts.reverse.mkString(".")

    // The extraction method (mandatory)
    def unapplySeq(whole: String): Option[Seq[String]] = 
      Some(whole.split("\\.").reverse)
  }

unapplySeq以句点拆分字符串包装在Some中返回。unapplySeq抽取器返回结果类型 必须是Option[seq[T]],这里的元素类型T不能限制。Seq是各种序列类(List、 Array、RichString等)的共同超类。

这样寻找某个.com域名中的email的函数就是这样:

  def isTomInDotCom(s: String): Boolean = s match {
    case EMail("tom", Domain("com", _*)) => true
    case _ => false
  }

返回期望的结果:

  scala> isTomInDotCom("tom@sun.com")
  res3: Boolean = true

  scala> isTomInDotCom("peter@sun.com")
  res4: Boolean = false

  scala> isTomInDotCom("tom@acm.org")
  res5: Boolean = false

同样也可以从unapplySeq及变化部分返回固定的元素。表达为包含所有元素的元组,变化 部分还是在最后,下面是新的抽取器,其中域名部分已经扩展为序列了:

  object ExpandedEMail {
    def unapplySeq(email: String) : Option[(String, Seq[String])] = {
      val parts = email split "@"
      if (parts.length == 2) 
        Some(parts(0), parts(1).split("\\.").reverse)
      else 
        None
    }
  }

这里unapplySeq方法返回对偶的可选类型。对偶的第一个元素是用户部分,第二个部分是 表示域名的序列。然后就可以这样匹配它:

  scala> val s = "tom@support.epfl.ch"
  s: java.lang.String = [[tom@support.epfl.ch]]

  scala> val ExpandedEMail(name, topdom, subdoms @ _*) = s
  name: String = tom
  topdom: String = ch
  subdoms: Seq[String] = List(epfl, support)

抽取器和序列模式

模式匹配中我们已经知道可以使用序列模式访问列表或数组的元素:

  List()
  List(x, y, _*)
  Array(x, 0, 0, _)

这里的List(...)形式的模式其实是由scala.List的伴生对象定义了unapplySeq方法 的抽取器,相关定义如下:

  package scala
  object List {
    def apply[T](elems: T*) = elems.toList
    def unapplySeq[T](x: List[T]): Option[Seq[T]] = Some(x)
    ... 
  }

List对象包含了带可变数量参数的apply方法,从而允许编写如下的表达式:

  List()
  List(1, 2, 3)

它还包含了以序列形式返回列表所有元素的unapplySeq方法,从而对List(...)模式 提供了支持。scala.Array对象定义也非常类似,所以数组也支持注入和抽取方法。

抽取器与模式匹配

抽取器中置模式匹配

如果unapply方法产出的是一个对偶,就可以在模式匹配case语句中使用中置表示法。 对于有两个参数的样本类:

case class Currency(value: Double, unit: String)
val amt = Currency(100.00, "EUR")

下面的匹配语句:

amt match { case Currency(a, u) => println(u + " " + a) }

可用中置表达格式:

amt match { case a Currency u => println(u + " " + a) }

中置表示法可以用于任何返回对偶的unapply方法,比如:

case object +: {
	def unapply[T](input: List[T]) = 
		if (input.isEmpty)
			None
		else
			Some((input.head, input.tail))
}

这样就可以直接用+:来析构列表了:

1 +: 7 +: 2 +:9 +: Nil match {
	case first +: second +: rest => first + second + rest.length
}

抽取器 VS. 样本类

样本类会暴露了数据的具体表达方式,让外部看到了类名与构造器等信息,如:

  case C(...)

对于已经存在的样本类后来写的代码一定要拿来用它们。如果修改了样本类就一定要把用到 的地方都一起改了。抽取器是独立的,没有这个问题。

样本类的优点是容易实现、性能更加高效。而且样本类如果继承自sealed,编译器可以 穷举所有可能性检查程序里会漏掉的逻辑。

一般来说,如果是封闭的应用,样本类更加合适;如果是开放的,类层级之类的会有重构, 就更加适合抽取器。

在面临选择的时候可以先从样本类开始做,以后发觉有问题再换抽取器。因为抽取器与 样本类模式在Scala上看上去样子完全一样,所以在客户代码中的模式匹配还是可以继续 工作的。但是像本章的email例子中模式架构与数据表现类不相符的情况下,只能用抽取器 。

正则表达式

正则表达式规则看java.util.regex.Pattern包的JavaDoc。RTFM!

简单的情况下不用新建正则表达式类,直接用String自带的成员方法就可以了。

String类的matches方法检查字符串是否匹配指定模式:

scala> "Foroggy went a' counting" matches ".* counting"
res8: Boolean = true

String类的replaceAll方法替换所有匹配的模式:

scala> "milk, tea, muck" replaceAll ("m[^ ]+k", "coffee")
res9: String = coffee, tea, coffee

String类的replaceFirst方法替换第一个匹配的模式:

scala> "milk, tea, muck" replaceFirst ("m[^ ]+k", "coffee")
res10: String = coffee, tea, muck

形成正则表达式

Scala相关正则的类放在scala.util.matching包里:

scala> import scala.util.matching.Regex

通过Regex类构造器传递正则表达式:

scala> val Decimal = new Regex("(-)?(\\d+)(\\.\\d*)?")
Decimal: scala.util.matching.Regex = (-)?(\d+)(\.\d*)?

Scala的照排字符串可以免了java复杂的转义:

scala> val Decimal = new Regex("""(-)?(\d+)(\.\d*)?""")
Decimal: scala.util.matching.Regex = (-)?(\d+)(\.\d*)?

还可以更加简化:

  scala> val Decimal = """(-)?(\d+)(\.\d*)?""".r
  Decimal: scala.util.matching.Regex = (-)?(\d+)(\.\d*)?

RichString类里实现了r方法把字符串转为正则表达式:

  package scala.runtime
  import scala.util.matching.Regex

  class RichString(self: String) ... {
    ...
    def r = new Regex(self)
  }

变量插值会影响转义

使用三个引号的原文字符串可以不用转义反斜杠\

scala> for(s <- """\w+\s\w+""".r.findAllIn("Hello world!!!")) println(s)
Hello world

但是如果使用了含有变量名的插值字符串形式,插值字符串中的反斜杠还是要转义的:

scala> var str = """\w+"""
str: String = \w+

scala> for(s <- s"""$str\s$str""".r.findAllIn("Hello world!!!")) println(s)
scala.StringContext$InvalidEscapeException: invalid escape '\s' not one of [\b, \t, \n, \f, \r, \\, \", \'] at index 0 in "\s". Use \\ for literal \.
  at scala.StringContext$.loop$1(StringContext.scala:236)
  at scala.StringContext$.replace$1(StringContext.scala:246)
  at scala.StringContext$.treatEscapes0(StringContext.scala:250)
  at scala.StringContext$.treatEscapes(StringContext.scala:195)
  at scala.StringContext$$anonfun$s$1.apply(StringContext.scala:95)
  at scala.StringContext$$anonfun$s$1.apply(StringContext.scala:95)
  at scala.StringContext.standardInterpolator(StringContext.scala:126)
  at scala.StringContext.s(StringContext.scala:95)
  ... 33 elided

scala> for(s <- s"""$str\\s$str""".r.findAllIn("Hello world!!!")) println(s)
Hello world

用正则表达式查找替换

查找首次出现作为Option类型结果返回:

regex findFirstIn str

所有的出现的匹配以Iterator类型结果返回:

regex findAllIn str

查找开始位置匹配出现,返回Option类型:

regex findPrefixOf str

例子:

scala> val Decimal = """(-)?(\d+)(\.\d*)?""".r
Decimal: scala.util.matching.Regex = (-)?(\d+)(\.\d*)?

scala> val input = "for -1.0 to 99 by 3"      
input: java.lang.String = for -1.0 to 99 by 3

scala> for (s <- Decimal findAllIn input)
     | println(s)
-1.0
99
3

scala> Decimal findFirstIn input
res1: Option[String] = Some(-1.0)

scala> Decimal findPrefixOf input
res2: Option[String] = None

正则表达式抽取值

Scala所有的正则表达式都定义了抽取器,可以用来鉴别匹配于正则表达式分组的子字符串 ,相当于买一送一。

形式为:

val <regex>(<identifier>) = <input string>

例如:

scala> val ptn = """.* price is ([\d.]+) .*""".r
ptn: scala.util.matching.Regex = .* price is ([\d.]+) .*

scala> val ptn(price) = "He told me the price is 15.33 today."
price: String = 15.33

scala> val ptn(priceStr) = "He told me the price is 15.33 today."
priceStr: String = 15.33

scala> val price = priceStr.toDouble
price: Double = 15.33

如果匹配有模式有多个部分,可以解构到多个变量中:

scala> val Decimal = """(-)?(\d+)(\.\d*)?""".r
Decimal: scala.util.matching.Regex = (-)?(\d+)(\.\d*)?

scala> val Decimal(sign, integerpart, decimalpart) = "-1.23"  
sign: String = -
integerpart: String = 1
decimalpart: String = .23

注意这里用Decimal(...)来定义了一个valDecimal正则表达式值定义了 unapplySeq 方法把字符串与正则匹配到三个模式变量signintergerpartdecimalpart,如果有一个部分缺少就是null

scala> val Decimal(sign, integerpart, decimalpart) = "1.0"
sign: String = null
integerpart: String = 1
decimalpart: String = .0

还可以在for表达式中混用抽取器与正则表达式做搜索,如,下面的代码从字符串中找到 所有的数值:

scala> for (Decimal(s, i, d) <- Decimal findAllIn input)
     |   println("sign: "+ s +", integer: "+
     |       i +", decimal: "+ d)
sign: -, integer: 1, decimal: .0
sign: null, integer: 99, decimal: null
sign: null, integer: 3, decimal: null

断言

assert是预定义的方法,assert(condition)会在条件不成立时抛出AssertionError

另一个版本assert(condition, explanation),指定了抛出含有指定explantion作为 说明的assertionErrorexplation的类型为Any,所以任何对象都可以作为参数。 assert方法会对传入的参数调用toString的结果作为放在AssertionError中的文字描述。

在前面的「组合与继承」这一章中的above方法加上道检查,只有宽度一样的元素才可以 上下连接在一起:

  def above(that: Element): Element = {
    val this1 = this widen that.width
    val that1 = that widen this.width
    elem(this1.contents ++ that1.contents)
  }

assert方法所在的Predef包里还有一个ensuring方法可以直接对表达式的返回结果 进行测试而不用先把表达式的结果先存放到变量中:

  def widen(w: Int): Element =
    if (w <= width) this
    else {
      val left = elem(' ', (w - width) / 2, height)
      var right = elem(' ', w - width - left.width, height)
      left beside this beside right
    } ensuring (w <= _.width)

ensuring接收一个返回类型为Boolean的函数作为参数。注意这里ensuring是作用在 if-else表达式的Element类结果上的,而不是方法widen的返回值上。相当于是:

  def widen(w: Int): Element = {
    if (w <= width) this
    else {
      val left = elem(' ', (w - width) / 2, height)
      var right = elem(' ', w - width - left.width, height)
      left beside this beside right
    } ensuring (w <= _.width)
  }

还有一点要明白的是,这里的语法看起来像是对Element类对象调用ensuring方法(把 xxx.ensuring(...)中的点换成了空格)。但是实际上Emement对象没有这个成员方法, 而是被隐式转换成了Ensuring对象。由于存在隐式转换,所以ensuring可以作用在任何 类型上。

测试

受益于和Java之间无缝操作。Java的测试库,像JUnit、TestNG,可以直接用于scala的 单元测试。

Junit 4

如果喜欢JUnit4的风格,那么可以写下面这样的单元测试。

import java.util.ArrayList
import org.junit.Test
import org.junit.Assert._

class SampleTest {

	@Test
	def listAdd() {
		val list = new ArrayList[String]
		list add "milk"
		list add "sugar"
		assertEquals(2, list.size())
	}

}

产生的测试类可以直接在Java中使用:

$ scalac -classpath .:junit-4.10.jar tmp.scala

$ java -classpath .:junit-4.10.jar:scala-library-2.10.2.jar \
		org.junit.runner.JUnitCore SampleTest
JUnit version 4.10
.
Time: 0.009

OK (1 test)

ScalaTest

ScalaTest是专为scala设计的一个测试框架。ScalaTest提供了一些比较方便的功能。注意 ScalaTest除了自己的版本号外,对应不同的Scala版本还有不同的对应版本。拿错了是 用不了的。

先用脚本来测试一下对不对:

class CanaryTest extends org.scalatest.Suite {

	def testOK() {
		assert(true)
	}

}

(new CanaryTest).execute()

因为使用的是脚本,所以上面最后一行调用execute()方法直接运行。执行:

$ scala -classpath .:scalatest_2.10-1.9.1.jar tmp.scala
Main$$anon$1$CanaryTest:
- testOK

Runner

ScalaTest的Runner类来配置运行或不运行哪些套件,配置不同的reporter来指定报表 。可以在ScalaTest的文档中查看所有的选项。

class CanaryTest extends org.scalatest.Suite {

	def testListEmpty() {
		val list = new java.util.ArrayList[Integer]
		assert(0 == list.size)
	}

	def testListAdd() {
		val list = new java.util.ArrayList[Integer]
		list add 1
		list add 4
		assert(2 == list.size)
	}

}

通过Runner打开图形界面,在view菜单中可以显示测试过程信息:

$ scalac -classpath .:scalatest_2.10-1.9.1.jar tmp.scala

$ scala -classpath .:scalatest_2.10-1.9.1.jar org.scalatest.tools.Runner -p .
WARNING: -p has been deprecated and will be reused for a different (but still 
		very cool) purpose in ScalaTest 2.0. Please change all uses of -p to -R.

上面的参数-p指定的查找测试类的路径。

如果不想要图形界面,可以用-o重定向结果到标准输出:

$ scala -classpath .:scalatest_2.10-1.9.1.jar org.scalatest.tools.Runner -p . -o
WARNING: -p has been deprecated and will be reused for a different (but still 
		very cool) purpose in ScalaTest 2.0. Please change all uses of -p to -R.
Run starting. Expected test count is: 2
DiscoverySuite:
CanaryTest:
- testListAdd
- testListEmpty
Run completed in 150 milliseconds.
Total number of tests run: 2
Suites: completed 2, aborted 0
Tests: succeeded 2, failed 0, ignored 0, pending 0
All tests passed.

还有-f参数可以把结果输出到文件。

assert()与expect()方法

ScalaTest提供的assert()方法测试条件是否成立。还有一个两个参数版本的,第二个 参数设置失败时的提示信息。

	assert(2 == list.size(), "Unexpected size of list")

还有一个expect()方法作为类似于Junit的assertEquals()方法。注意它的第三个参数 是一个闭包:

	assert(2, "Unexpected size of list") { list.size() }

注意expect()方法已经过期,用expectResult()方法代替。

测试异常

有一个比较啰嗦的方案:

try {
	// .....
	fail("Expected exception here")
} catch {
	case e: IndexOutOfBoundsException => // success
}

更好的方法是用ScalaTest提供的intercept()方法:

intercept(classOf[IndexOutOfBoundsException], "Expected exception here") {
	// .....
}

注意上面的格式可能会在ScalaTest的新版本中改变,会是这样的:

intercept[IndexOutOfBoundsException]("Expected exception here") {
	// .....
}

intercept()方法会把捕获的结果作为返回值,如果需要的话可以对它进行处理。

在测试间共享代码

BeforeAndAfter特质

Scala的BeforeAndAfter特质提供了beforeEach()afterEach()方法会在每个测试 开始与结束前调用;还有beforeAll()afterAll()在所有的开始与结束时运行一次。

class ShareCodeImperation extends org.scalatest.Suite 
	with org.scalatest.BeforeAndAfter
{
		var list: java.util.ArrayList[Integer] = _

		override def beforeEach() { list = new java.util.ArrayList[Integer] }
		override def afterEach()  { list = null }

		def testListEmptyOnCreate() {
			expect(0, "Expected size to be 0") { list.size() }
		}

		def testGetOnEmptyList() {
			intercept[IndexOutOfBoundsException] { list.get(0) }
		}
}

(new ShareCodeImperative).execute()
通过闭包
import org.scalatest.Suite
import java.util.ArrayList

class ShareCodeFunctional extends Suite {

	def withList(testFunction : (ArrayList[Integer]) => Unit) {
		// init list
		val list = new ArrayList[Integer]

		try {
			testFunction(list)
		} finally {
			// clean up
		}
	}

	def testListEmptyOnCreate() {
		withList {
			list => expectResult(0, "Expected size to be 0") {
				list.size()
			}
		}
	}

	def testGetOnEmptyList() {
		withList {
			list => intercept[IndexOutOfBoundsException] {
				list.get(0)
			}
		}
	}

}

(new ShareCodeFunctional).execute()

运行:

$ scala -deprecation -classpath .:scalatest_2.10-1.9.1.jar tmp.scala
Main$$anon$1$ShareCodeFunctional:
- testGetOnEmptyList
- testListEmptyOnCreate

FunSuite

ScalaTest提供了函数式的FunSuite(Function Suite)。test方法为测试方法,说明 字符串的内容会显示在输出信息中:

import org.scalatest.FunSuite
import scala.collection.mutable.Stack
 
class ExampleSuite extends FunSuite {
 
  test("pop is invoked on a non-empty stack") {
    val stack = new Stack[Int]
    stack.push(1)
    stack.push(2)
    val oldSize = stack.size
    val result = stack.pop()
    assert(result === 2)
    assert(stack.size === oldSize - 1)
  }
 
  test("pop is invoked on an empty stack") {
    val emptyStack = new Stack[Int]
    intercept[NoSuchElementException] {
      emptyStack.pop()
    }
    assert(emptyStack.isEmpty)
  }
}
\-(morgan:%) >>> scalac -cp scalatest_2.9.0-1.9.1.jar ExampleSuite.scala                      
\-(morgan:%) >>> scala -cp .:scalatest_2.9.0-1.9.1.jar org.scalatest.run ExampleSuite 
Run starting. Expected test count is: 2
ExampleSuite:
- pop is invoked on a non-empty stack
- pop is invoked on an empty stack
Run completed in 158 milliseconds.
Total number of tests run: 2
Suites: completed 1, aborted 0
Tests: succeeded 2, failed 0, ignored 0, pending 0
All tests passed.

ScalaTest中也可以写JUnit风格的测试:

import org.scalatest.junit.AssertionsForJUnit
import scala.collection.mutable.ListBuffer
import org.junit.Assert._
import org.junit.Test
import org.junit.Before

class ExampleSuite extends AssertionsForJUnit {

  var sb: StringBuilder = _
  var lb: ListBuffer[String] = _

  @Before 
	def initialize() {
    sb = new StringBuilder("ScalaTest is ")
    lb = new ListBuffer[String]
  }

  @Test 
	def verifyEasy() { // Uses JUnit-style assertions
    sb.append("easy!")
    assertEquals("ScalaTest is easy!", sb.toString)
    assertTrue(lb.isEmpty)
    lb += "sweet"
    try {
      "verbose".charAt(-1)
      fail()
    }
    catch {
      case e: StringIndexOutOfBoundsException => // Expected
    }
  }

  @Test 
	def verifyFun() { // Uses ScalaTest assertions
    sb.append("fun!")
    assert(sb.toString === "ScalaTest is fun!")
    assert(lb.isEmpty)
    lb += "sweeter"
    intercept[StringIndexOutOfBoundsException] {
      "concise".charAt(-1)
    }
  }
}

编译运行:

\-(morgan:%) >>> scalac -cp scalatest_2.9.0-1.9.1.jar:junit-4.8.2.jar ExampleSuite.scala                      
\-(morgan:%) >>> scala -cp .:scalatest_2.9.0-1.9.1.jar:junit-4.8.2.jar org.junit.runner.JUnitCore ExampleSuite
JUnit version 4.8.2
..
Time: 0.026

OK (2 tests)

混合ScalaTest与JUnit

org.scalatest.junit.JUnitSuite已经混入了特质AssertionsForJUnit,可以同时被 用于JUnit与ScalaTest方法:

import org.scalatest.junit.JUnitSuite
import scala.collection.mutable.ListBuffer
import org.junit.Assert._
import org.junit.Test
import org.junit.Before

class ExampleSuite extends JUnitSuite {

  var sb: StringBuilder = _
  var lb: ListBuffer[String] = _

  @Before
	def initialize() {
    sb = new StringBuilder("ScalaTest is ")
    lb = new ListBuffer[String]
  }

  @Test
	def verifyEasy() { // Uses JUnit-style assertions
    sb.append("easy!")
    assertEquals("ScalaTest is easy!", sb.toString)
    assertTrue(lb.isEmpty)
    lb += "sweet"
    try {
      "verbose".charAt(-1)
      fail()
    }
    catch {
      case e: StringIndexOutOfBoundsException => // Expected
    }
  }

  @Test
	def verifyFun() { // Uses ScalaTest assertions
    sb.append("fun!")
    assert(sb.toString === "ScalaTest is fun!")
    assert(lb.isEmpty)
    lb += "sweeter"
    intercept[StringIndexOutOfBoundsException] {
      "concise".charAt(-1)
    }
  }
}

JUnit调用:

\-(morgan:%) >>> scala -cp .:scalatest_2.9.0-1.9.1.jar:junit-4.8.2.jar org.junit.runner.JUnitCore ExampleSuite
JUnit version 4.8.2
..
Time: 0.026

OK (2 tests)

ScalaTest调用:

\-(morgan:%) >>> scala -cp .:scalatest_2.9.0-1.9.1.jar:junit-4.8.2.jar org.scalatest.run ExampleSuite
Run starting. Expected test count is: 2
ExampleSuite:
- verifyEasy
- verifyFun
Run completed in 226 milliseconds.
Total number of tests run: 2
Suites: completed 1, aborted 0
Tests: succeeded 2, failed 0, ignored 0, pending 0
All tests passed.

ScalaTest还提供了函数式的单元测试。

// 用函数式的方式来写单元测试
// IDE目前对ScalaTest的支持不是特别好
// 加上RunWith就可以用JUnit的方式来运行了
@RunWith(classOf[JUnitRunner])
class ElementSuite3 extends FunSuite {
    test("elem result should have passed width") {
        val ele = elem('x', 2, 3)
        assert(ele.width == 2)
    }
}

单元测试对提高软件质量很有好处。唯一的不足就是只针对程序员。其它人员要看懂还是 比较困难。ScalaTest提供了BDD(Behavior Driven Development行为驱动开发)测试方式 。下面的这段测试代码在运行时就会打印出可读的解释。

class ElementSpec extends FlatSpec with ShouldMatchers {
    "A UniformElement" should
        "have a width equal to the passed value" in {
        val ele = elem('x', 2, 3)
        ele.width should be(2)
    }
    it should
        "have a height equal to the passed value" in {
        val ele = elem('x', 2, 3)
        ele.height should be(3)
    }
    it should
        "throw an IAE if passed a negative width" in {
        evaluating {
            elem('x', -2, 3)
        } should produce[IllegalArgumentException]
    }
}

上面的代码会打印出下面这样的提示。

A UniformElement 
- should have a width equal to the passed value
- should have a height equal to the passed value
- should throw an IAE if passed a negative width

我们写单元测试时会测试一些边界值。然后再选一些典型的值。如果这些选值有库来做, 不但可以减少单元测试的工作量,而且可以将边界值选取更合理。

下面是如何将ScalaCheckerScalaTest联合起来使用的一个例子:

class ElementSpecChecker extends FlatSpec with ShouldMatchers with Checkers{
    "A UniformElement" should
        "have a width equal to the passed value" in {
        // 这可以用数学化的方式来读
        // 对每个整数w
        // 当w>0时
        // 都有后面的等式成立
        check((w: Int) => w > 0 ==> (elem('x', w, 3).width == w))
    }
}