ScalaTest 可以說是 scala 測試的王者,主要原因是它提供了七種 Test Spec,其中最基本的就是 FunSpec,有兩種不同的 assertion dialects,可以依照你自己的喜好,決定使用哪一種寫法,另外 ScalaTest 也跟 SBT 整合在一起,可以直接由 SBT 自動化進行測試。
如何使用
在專案的 build.sbt 中,增加 ScalaTest 的 libraryDependencies。
build.sbt
libraryDependencies ++= Seq(
"org.scalactic" %% "scalactic" % "2.2.6",
"org.scalatest" %% "scalatest" % "2.2.6" % "test"
)
如果要進行 Spark 的測試,也要加上 Spark 的 library,以下是我們的 build.sbt 內容。
name := "sparktest"
version := "1.0"
sbtPlugin := true
scalaVersion := "2.10.6"
val sparkVersion = "1.6.1"
libraryDependencies ++= Seq(
"ch.qos.logback" % "logback-core" % "0.9.26",
"ch.qos.logback" % "logback-classic" % "0.9.26",
"org.slf4j" % "slf4j-api" % "1.6.1",
"org.scalactic" %% "scalactic" % "2.2.6",
"org.scalatest" %% "scalatest" % "2.2.6" % "test",
// kafka
"org.apache.kafka" %% "kafka" % "0.9.0.1",
"org.apache.kafka" % "kafka-clients" % "0.9.0.1",
// spark
"org.apache.spark" %% "spark-core" % sparkVersion % "provided",
"org.apache.spark" %% "spark-hive" % sparkVersion % "provided",
"org.apache.spark" %% "spark-mllib" % sparkVersion % "provided",
"org.apache.spark" %% "spark-streaming" % sparkVersion % "provided",
"org.apache.spark" %% "spark-streaming-kafka" % sparkVersion,
// geoip2-2.6.0
"com.maxmind.geoip2" % "geoip2" % "2.6.0",
"com.maxmind.db" % "maxmind-db" % "1.2.1"
)
// 因為 spark 的 libraryDependencies 裡面用到的 library 有衝突,必須加上這一行將衝突解除
dependencyOverrides ++= Set(
"com.fasterxml.jackson.core" % "jackson-databind" % "2.4.4"
)
ivyScala := ivyScala.value map {
_.copy(overrideScalaVersion = true)
}
assemblyMergeStrategy in assembly := {
case m if m.toLowerCase.endsWith("manifest.mf") => MergeStrategy.discard
case m if m.startsWith("META-INF") => MergeStrategy.discard
case PathList("javax", "servlet", xs@_*) => MergeStrategy.first
case PathList("org", "apache", xs@_*) => MergeStrategy.first
case "about.html" => MergeStrategy.rename
case "reference.conf" => MergeStrategy.concat
case _ => MergeStrategy.first
}
Matchers
ScalaTest 包含了兩種 Matchers: MustMatchers 及 ShouldMatchers,以下用 FunSpec 為例,說明 ShouldMatchers,而MustMatchers 寫法跟 ShouldMatchers 類似。
- Simple Matcher
- String Matcher
- Relational Operator Matcher
- Floating-point Macher
- Reference Macher
- Iterable Macher
- Seq 與 Traversable Matchers
- Map Matcher
- Compound Matcher
- Property Matcher
- Java Collection Matcher
import org.scalatest.{FunSpec, Matchers}
class TestFunSpec extends FunSpec with Matchers{
describe("Using all should matchers") {
// Simple Matcher
// 驗證的資料要放在 () 裡面
// be 可以換成 equal
// be 不能換成 == 或是 !=
it("has simple matchers") {
val list = 2 :: 4 :: 5 :: Nil
list.size should be(3)
list.size should equal(3)
}
// String Matcher
it("has string matchers") {
val string = """I fell into a burning ring of fire.
I went down, down, down and the flames went higher"""
string should startWith("I fell")
string should endWith("higher")
string should not endWith "My favorite friend, the end"
string should include("down, down, down")
string should not include ("Great balls of fire")
// 以 regex 作為判斷依據
string should startWith regex ("I.fel+")
string should endWith regex ("h.{4}r")
string should not endWith regex("\\d{5}")
string should include regex ("flames?")
string should fullyMatch regex ("""I(.|\n|\S)*higher""") //regex in triple quotes
}
// Relational Operator Matcher
it("has <, >, <=, >= matchers") {
val answerToLife = 42
answerToLife should be < (50)
answerToLife should be > (3)
answerToLife should be <= (100)
answerToLife should be >= (0)
answerToLife shouldNot be <= (0)
}
// Floating-point Macher
// scala 的 FP 運算,會有誤差值
// 在 ScalaTest 提供了 +- 的方式,設定允許的誤差範圍
it("has checking floating point imperfections") {
(4.0 + 1.2) should be(5.2)
(0.9 - 0.8) should be(0.1 +- .01)
(0.4 + 0.1) shouldNot be (40.00 +- .30)
}
// Reference Macher
it("has object reference checker") {
val A:String = "Testing String A"
val B:String = A
val C:String = "Testing String C"
A should be theSameInstanceAs(B)
C shouldNot be theSameInstanceAs(B)
}
// Iterable Matcher
it("has methods for iterable") {
// 'empty 是 scala 的特殊符號
List() should be('empty)
List() shouldBe 'empty
List() shouldBe 'traversableAgain
8 :: 6 :: 7 :: 5 :: 3 :: 0 :: 9 :: Nil should contain(7)
}
// Seq 與 Traversable Matchers
it("has methods for seq") {
// 提供了 length, size method for Seq or Traversable
(1 to 9).toList should have length (9)
(1 to 9).toList should have size (9)
}
it("has methods for traversable") {
(20 to 60 by 2).toList should have length (21)
(20 to 60 by 2).toList should have size (21)
}
// Map Matcher
it("has methods for map") {
// 檢查 Map 裡面是否有某個 key 或 value
val map = Map("Jimmy Page" -> "Led Zeppelin", "Sting" -> "The Police", "Aimee Mann" -> "Til\' Tuesday")
map should contain key ("Sting")
map should contain value ("Led Zeppelin")
map shouldNot contain key("Brian May")
}
// Compound Matcher
it("has compound and and or") {
val redHotChiliPeppers = List("Anthony Kiedis", "Flea", "Chad Smith", "Josh Klinghoffer")
// 將 assert 條件用 and, or 串接起來
redHotChiliPeppers should (contain("Anthony Kiedis") and
(not contain ("John Frusciante")
or contain("Dave Navarro")))
redHotChiliPeppers shouldNot (contain ("The Edge") or contain ("Kenny G"))
//redHotChiliPeppers shouldNot contain "The Edge" or contain "Kenny G"
var total = 3
redHotChiliPeppers shouldNot (contain ("The Edge") or contain {total += 6; "Kenny G"})
total should be (9)
val gorillaz:List[String] = null
gorillaz should be (null)
}
// Property Matcher
it("has a way to asset an object's property using getter method") {
class Album(val name:String, val year:Int)
val album = new Album("Ghost Singer", 2016)
album should have (
'name ("Ghost Singer"),
'year (2016)
)
}
// Java Collection Matcher
it("has method for java collections") {
import java.util.{ArrayList => JArrayList, HashMap => JHashMap, List => JList, Map => JMap}
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 shouldNot 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 shouldNot contain key("John Lydon")
}
}
}
MustMatchers
ShouldMatchers 可以用 MustMatchers 取代,但不能同時並存,語法就是把 should 換成 must,大部分的文件都是使用 should。
import org.scalatest.{FunSpec, MustMatchers}
class TestFunSpecMustMatcher extends FunSpec with MustMatchers {
describe("Using all should matchers") {
// MustMatcher
it("replace should to 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 mustNot be >(50)
class Artist(val firstname:String, val lastname:String)
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 +- .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")))
}
}
}
Exception Handling
利用 thrownBy 或是 intercept 語法,在 FunSpec 使用 Should Matcher 測試 Exception 的驗證
// Excpetion Handling
it("has exception handler") {
class Album(val name: String, val year: Int) {
if (year <= 2000) throw new IllegalArgumentException("Album year is required")
}
the[IllegalArgumentException] thrownBy (new Album("The Joy of Listening to Nothing",
0)) should have message "Album year is required"
intercept[IllegalArgumentException] {
new Album("The Joy of Listening to Nothing", 0)
}
}
it("should produce NoSuchElementException when head is invoked") {
intercept[NoSuchElementException] {
Set.empty.head
}
}
Informers
Informer 就像是 ScalaTest 中的 debug message,可以在測試程式的任何地方,輸出一些跟這個測試相關的訊息,只要呼叫 info(String) 就可以了。
// Informers
describe("An Album") {
class Album(val name: String, val year: Int) {
if (year <= 0) throw new IllegalArgumentException("Album year is required")
}
it("can create an album") {
val album = new Album("Thriller", 1981)
info("Test the album name should be Thriller")
album.name should be("Thriller")
}
}
GivenWhenThen
任何一個測試過程,都可以被描述為 Given ... When ... Then。
Given相當於所給的前置條件
When相當於產生了某個動作或處於某種條件下
Then表示前面兩個條件產生的結果
import org.scalatest.{GivenWhenThen, ShouldMatchers, FunSpec}
class TestAlbumSpec extends FunSpec with ShouldMatchers with GivenWhenThen {
class Album(val name: String, val year: Int, val artist: Artist) {
if (year <= 0) throw new IllegalArgumentException("Album year is required")
}
class Artist(val firstName: String, val lastName: String)
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] TestAlbumSpec:
[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 209 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.
Pending Test 待測試, 未完成的測試, TODO
當我們在 test body 裡面加上 pending 註記時,就表示這是一個需要實作,還沒有完成的測試項目。
it("can be a pending test") {
info("pending 可以一直放在 test body 的最後面,代表這還沒完成")
pending
}
Ignoring Test
正式環境的程式碼,有可能隨著時間持續的修改,而造成測試的程式碼過時,還沒辦法修改好,無法驗證新版的 production code,寫法就是將 it 換成 ignore 就可以了,ScalaTest 會暫時先忽略這個測試項目。
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
}
Tagging
可以將測試項目加上 Tag,就可以分組進行測試,Tagging 可以在以下這些狀況使用:
- 想跳過某些很費時的測試
- 某些測試是檢查一些相關的功能,需要一起執行
- 你想給測試分成單元測試、綜合測試、驗收測試等分類時
object TestTag extends Tag("TestTag")
object DevTag extends Tag("DevTag")
it("can add multiple tags", TestTag, DevTag) {
info("test TestTag, DevTag")
}
在 sbt 裡面,目前只有 testOnly 有支援 Tag,可以啟動某個 tag 的測試,或是忽略某個 tag
# 啟動包含 TestTag 的測試
testOnly com.larzio.lzspark.test.scalatest.TestAlbumSpec -- -n TestTag
# 忽略 TestTag 的測試
testOnly com.larzio.lzspark.test.scalatest.TestAlbumSpec -- -l TestTag
Specifications
ScalaTest 的 Spec 指的是不同的測試 style 寫法,某方面來說,代表著自由度,可以自己選擇習慣的語法,但相對地,代表著混亂,因為寫法太多元,造成團隊開發時,程式風格差異太大。
擁抱開放與自由,就必須接納多元與混亂,開放與封閉環境,都有成功的案例。
- FunSpec
- WordSpec
- FeatureSpec
- FreeSpec
- FlatSpec
- JUnitSuite
- TestNGSuite
FunSpec
使用 describe, it
import org.scalatest.{FunSpec, Tag, GivenWhenThen, ShouldMatchers} class AlbumFunSpec extends FunSpec with ShouldMatchers with GivenWhenThen { class Album(val title: String, val year: Int, val acts: Artist*) { if (year <= 0) throw new IllegalArgumentException("Album year is required") } class Artist(val firstName: String, val lastName: String) 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 first act of the album is obtained") val act = album.acts.head Then("the act should be an instance of Artist") act.isInstanceOf[Artist] should be(true) And("the artist's first name and last name should be Michael Jackson") val artist = act.asInstanceOf[Artist] artist.firstName === "Michael" artist.lastName === "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. } } }
WordSpec
使用了 when, should
import org.scalatest.{WordSpec, ShouldMatchers} class AlbumWordSpec extends WordSpec with ShouldMatchers { class Album(val name: String, val year: Int) { if (year <= 0) throw new IllegalArgumentException("Album year is required") } "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", 1977) } } } "An album" should { "throw an IllegalArgumentException if there are no acts when created" in { intercept[IllegalArgumentException] { new Album("The Joy of Listening to Nothing", 0) } info("The test at this point should still continue") info("since we successfully trapped the exception") } "test with a thousand acts" ignore { //Working on a thousand acts new Album("One thousand singers", 2010) } } }
FeatureSpec
使用 feature, scenario
import org.scalatest.{ShouldMatchers, GivenWhenThen, FeatureSpec} class EAlbum { private var on: Boolean = false def isOn: Boolean = on def pressPowerButton() { on = !on } } class AlbumFeatureSpec extends FeatureSpec with ShouldMatchers with GivenWhenThen { info("As a EAlbum set owner") info("I want to be able to turn the EAlbum on and off") info("So I can watch EAlbum when I want") info("And save energy when I'm not watching EAlbum") feature("EAlbum power button") { scenario("User presses power button when EAlbum is off") { Given("a EAlbum set that is switched off") val tv = new EAlbum assert(!tv.isOn) When("the power button is pressed") tv.pressPowerButton() Then("the EAlbum should switch on") assert(tv.isOn) } scenario("User presses power button when TV is on") { Given("a EAlbum set that is switched on") val tv = new EAlbum tv.pressPowerButton() assert(tv.isOn) When("the power button is pressed") tv.pressPowerButton() Then("the EAlbum should switch off") assert(!tv.isOn) } } }
FreeSpec
將 story line 後面加上 - {
import org.scalatest.FreeSpec class TestFreeSpec extends FreeSpec { "A Set" - { "when empty" - { "should have size 0" in { assert(Set.empty.size == 0) } "should produce NoSuchElementException when head is invoked" in { intercept[NoSuchElementException] { Set.empty.head } } } } }
FlatSpec
"X should Y", "A must B"
import org.scalatest.FlatSpec class TestFlatSpec extends FlatSpec { "An empty Set" should " have size 0 " in { assert(Set.empty.size == 0) } it should "produce NoSuchElementException when head is invoked" in { intercept[NoSuchElementException] { Set.empty.head } } }
JUnitSuite
首先要加上 junit library
libraryDependencies += "junit" % "junit" % "4.12"
跟 JUnit 的習慣一樣,@Before 是測試準備,@After 是結束測試,@Test 則是每一個測試的 case
import org.scalatest.junit.JUnitSuite import org.junit.{After, Test, Before} import org.junit.Assert._ class TestJUnitSuite extends JUnitSuite { class Album(val title: String, val year: Int, val acts: Artist*) { if (year <= 0) throw new IllegalArgumentException("Album year is required") } class Artist(val firstName: String, val lastName: String) var artist: Artist = _ @Before def startUp() { artist = new Artist("Kenny", "Rogers") } @Test def newAlbum() { val album = new Album("Love will turn you around", 1982, artist) assertEquals(album.acts.size, 1) } @After def shutDown() { this.artist = null } }
TestNGSuite
TestNG 風格的測試,首先要加上 library
libraryDependencies += "org.testng" % "testng" % "6.8.21"
@DataProvider(name = "provider") 提供測試資料,@Test(dataProvider = "provider") 則是針對 data provider 進行測試
import org.scalatest.testng.TestNGSuite import collection.mutable.ArrayBuilder import org.testng.annotations.{Test, DataProvider} import org.testng.Assert._ class TestTestNGSuite 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) } }
Fixtures
不同的測試可能會有相同的 object dependencies 或是 測試資料,fixture 就是一次建立 subjects 並能重複使用的機制。
- Anonymous Objects
import org.scalatest.{ShouldMatchers, FunSpec}
class AlbumFixtureSpec extends FunSpec with ShouldMatchers {
class Album(val name: String, val year: Int) {
if (year <= 0) throw new IllegalArgumentException("Album year is required")
}
// 定義測試資料
def fixture = new {
val letterFromHome = new Album("Letter from Home", 1989)
}
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 Traits
利用 trait 產生匿名物件
import org.scalatest.{ShouldMatchers, FunSpec}
class AlbumFixtureTraitSpec extends FunSpec with ShouldMatchers {
class Album(val name: String, val year: Int) {
if (year <= 0) throw new IllegalArgumentException("Album year is required")
}
trait AlbumFixture {
val letterFromHome = new Album("Letter from Home", 1989)
}
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)
}
}
}
}
OneInstancePerTest
以 OneInstancePerTest 讓每個測試都有自己的資料
import org.scalatest.{OneInstancePerTest, ShouldMatchers, FreeSpec}
import scala.collection.mutable.ListBuffer
class AlbumListOneInstancePerTestFreeSpec extends FreeSpec with ShouldMatchers with OneInstancePerTest {
class Album(val title: String, val year: Int, val acts: Artist*) {
if (year <= 0) throw new IllegalArgumentException("Album year is required")
}
class Artist(val firstName: String, val lastName: String)
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 會先重建,然後再加入一個新的 Album
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)
}
}
}
Before and After
控制測試前後的行為
import collection.mutable.ListBuffer
import org.scalatest.{BeforeAndAfter, WordSpec, ShouldMatchers}
class AlbumBeforeAndAfterFixtureSpec extends WordSpec with ShouldMatchers with BeforeAndAfter {
class Album(val title: String, val year: Int, val acts: Artist*) {
if (year <= 0) throw new IllegalArgumentException("Album year is required")
}
class Artist(val firstName: String, val lastName: String)
val humanLeagueDiscography = new ListBuffer[Album]()
before {
info("Starting to populate the discography")
humanLeagueDiscography += (new Album("Dare", 1981, new Artist("Grace", "Jones")))
}
"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 Artist("Grace", "Jones")))
humanLeagueDiscography += (new Album("Crash", 1986, new Artist("Grace", "Jones")))
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 Artist("Grace", "Jones")))
humanLeagueDiscography should have size (2)
}
}
after {
info("Clearing the discography")
humanLeagueDiscography.clear()
}
}