作者brianhsu (坟墓)
看板PLT
标题[心得] [Scala] 简简单单做 Mock 及 BDD。
时间Sat Jan 16 12:13:00 2010
话说在做单元测试的时候,Mock Object 是很常使用的技巧,可以协助开
发者隔离实际的环境,或以人工方式产生错误,以加强测试环境的控制。
[1] Mock Object
http://en.wikipedia.org/wiki/Mock_object
简而言之,Mock Object 的精神就是将非你可以控制的部份独立出来,设
计成可以随时以其他的实作取代。
在 Scala 里面,要在做单元测试时使用 Mock Object 是相当简单,只要
使用内建的 Trait 就可以很容易的达成 Mock Object 的技巧,而且对於
客户端的使用者而言,并不会感觉到任何使用上的差异。
在这边,我们使用上次实作的 GeoService 函式库来做示范,首先先复习
一下上次的函式库(我有做一些小修正,不过介面是一样的)。
================================================================
package org.maidroid.utils
import scala.io._
import scala.xml.XML
import scala.xml.Node
import scala.xml.Elem
import java.net.URLEncoder
case class GeoPlacemark (val query: String, val address: String,
val accuracy: Int, val longitude: Double,
val latitude: Double)
{
val longitudeE6 = (longitude * 1E6) toInt
val latitudeE6 = (latitude * 1E6) toInt
}
class GeoService (val query: String, val locale: String)
{
private var statusCode = 0
private var errorMessage = ""
val placemarkList: List[GeoPlacemark] = doQuery ()
def this (query: String) = this (query, "")
def error = (statusCode, errorMessage)
private def createGeoPlacemark (node: Node) =
{
val address = (node \ "address").text
val accuracy = (node \ "AddressDetails" \ "@Accuracy").text
val coordinate = (node \ "Point").text.split (",").toList
val List (longitude, latitude, _) = coordinate
GeoPlacemark (query, address, accuracy.toInt,
longitude.toDouble, latitude.toDouble)
}
private def loadXML = {
val url = "
http://maps.google.com/maps/geo?" +
"q=%s&output=xml&gl=%s".
format(URLEncoder.encode(query), locale)
val source = Source.fromURL (url, "utf-8")
val iter = for (line <- source.getLines) yield line
XML.loadString (iter.mkString)
}
private def doQuery () =
{
try {
val xml = loadXML
this.statusCode = (xml \ "Response" \ "Status" \
"code").text.toInt
// code 200 means OK
if (statusCode != 200) {
throw new Exception ("Query Faild")
}
val placemark = xml \ "Response" \ "Placemark"
val addressList = for (node <- placemark) yield
createGeoPlacemark (node)
addressList.toList
} catch {
case e => errorMessage = e.getMessage
Nil
}
}
}
================================================================
上述的程式码中,我们可以发现所谓『非我们所能控制』的部份,就是连
到 Google Maps 服务取得 XML 文件的 loadXML 这个函式。
这就是我们要把它隔离的程式码,所以我们先设计一个 Trait,里面有一
个 loadXML 函式的介面。
================================================================
trait LoadXML
{
protected def loadXML: Elem
}
================================================================
在这边要注意的是,由於这个函式是内部使用,不应该透露给外界,所以
我们将其宣告为 protected,这和 Java 的介面里面只能有 public 不一
样。
接着,我们更改我们的 GeoService 的实作,要改的部份只有两个:
* 让 GeoService mix-in LoadXML
* 将 GeoService#loadXML 的部份改成 override protected
完整的程式码如下:
================================================================
package org.maidroid.utils
import scala.io._
import scala.xml.XML
import scala.xml.Node
import scala.xml.Elem
import java.net.URLEncoder
trait LoadXML
{
protected def loadXML: Elem
}
case class GeoPlacemark (val query: String, val address: String,
val accuracy: Int, val longitude: Double,
val latitude: Double)
{
val longitudeE6 = (longitude * 1E6) toInt
val latitudeE6 = (latitude * 1E6) toInt
}
class GeoService (val query: String, val locale: String) extends
LoadXML
{
private var statusCode = 0
private var errorMessage = ""
val placemarkList: List[GeoPlacemark] = doQuery ()
def this (query: String) = this (query, "")
def error = (statusCode, errorMessage)
private def createGeoPlacemark (node: Node) =
{
val address = (node \ "address").text
val accuracy = (node \ "AddressDetails" \ "@Accuracy").text
val coordinate = (node \ "Point").text.split (",").toList
val List (longitude, latitude, _) = coordinate
GeoPlacemark (query, address, accuracy.toInt,
longitude.toDouble, latitude.toDouble)
}
protected def loadXML = {
val url = "
http://maps.google.com/maps/geo?" +
"q=%s&output=xml&gl=%s".
format(URLEncoder.encode(query), locale)
val source = Source.fromURL (url, "utf-8")
val iter = for (line <- source.getLines) yield line
XML.loadString (iter.mkString)
}
private def doQuery () =
{
try {
val xml = loadXML
this.statusCode = (xml \ "Response" \ "Status" \
"code").text.toInt
// code 200 means OK
if (statusCode != 200) {
throw new Exception ("Query Faild")
}
val placemark = xml \ "Response" \ "Placemark"
val addressList = for (node <- placemark) yield
createGeoPlacemark (node)
addressList.toList
} catch {
case e => errorMessage = e.getMessage
Nil
}
}
}
================================================================
什麽?结束了?!别怀疑,真的就只有这样子而已,这正是 Scala 吸引
我的地方,够方便吧!
在测试开始之前,我们先来做几个 Mock 来用吧,要做 Mock 很简单,只
要再宣告继承 LoadXML 的 Trait 即可。在这次的测试中,我们建造了以
下三个 Mock。
* 正确的 XML 回传值
* 错误的 XML 回传值
* 执行期间发生连线错误的 Exception
================================================================
// 正确的 XML 回传值
trait MockXML extends LoadXML
{
override def loadXML =
<kml xmlns="
http://earth.google.com/kml/2.0">
<Response>
<!-- 此处省略,请照抄 Google Maps 传回值 -->
</Response>
</kml>
}
// 错误的 XML 格式
trait WrongXML extends LoadXML
{
override def loadXML = <root />
}
// 连线错误 Exception
trait ExceptionXML extends LoadXML
{
override def loadXML = throw new java.net.ConnectException
}
================================================================
有了这些 Mock 物件之後,就可以着手来写单元测试了,在这里所使用的
是 ScalaTest 这个支援多种单元测试风格的 Framework,这次我们用的
是 FlatSpec 这个 Behavior Driven Development 测试。
[2] ScalaTest
http://www.scalatest.org/
由於使用 FlatSpec 所写出的测试都相当直觉,看起来就像英文句子,所
以程式码的部份就不做详细的解释了。
在这边要注意的地方,就是当我们要使用 Mock 的时候,直需要在建立物
件时将要使用的 Mock 给 mix-in 进来就好,范例如下。
================================================================
// 不使用任何 Mock
val noMock = new GeoService ("中央研究院")
// 分别使用上述三种 Mock
val mock1 = new GeoService ("中央研究院") with MockXML
val mock2 = new GeoService ("中央研究院") with WrongXML
val mock3 = new GeoService ("中央研究院") with ExceptionXML
================================================================
知道了上述的规则後,我们就可以使用这三个 Mock 撰写我们的测试案例
了,完整的测试案例如下。
================================================================
import org.scalatest.FlatSpec
import org.scalatest.matchers.ShouldMatchers
import org.maidroid.utils._
class GeoServiceSpec extends FlatSpec with ShouldMatchers
{
// 不使用任何 Mock 物件
"A GeoService" should "retun a list of GeoPlacemark when successed" in {
val service = new GeoService ("中央研究院")
val correct = GeoPlacemark ("中央研究院",
"115 Taiwan Taipei City Nangang "+
"District中央研究院", 9,
121.6122646, 25.0405918)
service.placemarkList should be === List (correct)
}
// 使用 MockXML 以保确取得正确的 XML 回传值
it should "has statusCode 200 and no error message when successed" in {
val service = new GeoService ("中央研究院") with MockXML
service.error should be === (200, "")
}
// 例用 WrongXML Mock 测试当回传值为不合格式的 XML 时的状况
it should "has statusCode 0 and empty list when XML format is wrong" in {
val service = new GeoService ("中央研究院") with WrongXML
service.placemarkList should be === Nil
service.error._1 should be === 0
}
// 例用 ExceptionXML 测试发生 Exception 时的行为
it should "has statusCode 0 and empty list when Exception occurred" in {
val service = new GeoService ("中央研究院") with ExceptionXML
service.placemarkList should be === Nil
service.error._1 should be === 0
}
}
================================================================
如何?要在 Scala 使用 Mock Object 进行单元测试很方便吧?!请多多
利用这个技巧,让单元测试变得更愉快哟!
--
~
白马带着她一步步地回到中原。白马已经老了,只能慢慢地走,
'v'
Brian Hsu 但终是能回到中原的。江南有杨柳、桃花,有燕子、金鱼……
// \\
( 坟 墓 )
/( )\
但这个美丽的姑娘就像古高昌国人那样固执。 【白马啸西风】
^`~'^
http://bone.twbbs.org.tw/blog 『那都是很好很好的,可我偏不喜欢。』
--
※ 发信站: 批踢踢实业坊(ptt.cc)
◆ From: 59.120.199.114
1F:推 godfat:btw, 之前在试 scalacheck, 不过跟 2.8 相容还不完整.. 01/16 16:29
2F:→ godfat:现在不知道如何了?有一阵子没注意了 01/16 16:29
3F:→ brianhsu:用在 2.8 上用的话好像要抓 Snapshot 版,不过我没试过。 01/16 18:48