[Scala] 簡簡單單做 Mock Object 及 BDD 單元測試。

話說在做單元測試的時候,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 = 
    
      
      
      
    
}

// 錯誤的 XML 格式
trait WrongXML extends LoadXML
{
    override def loadXML = 
}

// 連線錯誤 Exception
trait ExceptionXML extends LoadXML
{
    override def loadXML = throw new java.net.ConnectException
}

有了這些 Mock 物件之後,就可以著手來寫單元測試了,在這裡所使用的是 ScalaTest 這個支援多種單元測試風格的 Framework,我們用的是 FlatSpec 這個 Behavior Driven Development 測試。

由於使用 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 進行單元測試很方便吧?!請多多利用這個技巧,讓單元測試變得更簡單愉快喲!

回響