読者です 読者をやめる 読者になる 読者になる

seratch's weblog in Japanese

About Scala, Java and Ruby programming in Japaense. If you need English information, go to http://blog.seratch.net/

Scalaのユニットテスト入門

Scala

何を使うべきか?

公式サイトのForumにて

http://www.scala-lang.org/node/9826

  • Java における JUnit のようなデファクトスタンダードはないの?
  • ScalaTest を使うとしてどのスタイルが一般的?(JUnit、BDD、Features、FunSuite・・)
  • ScalaTest、specs(specs2)、ScalaCheck、JUnitTestNG あたりが選択肢
  • どれか一つに絞るというより、ScalaTest と ScalaCheck を併用のような使い方もアリ
  • 私は ScalaTest を BDD スタイルで使うことが多い
  • ScalaCheck の入力を自動生成するアプローチは機能テストのようなものに特に向いている
  • 私の環境(Java/Scala が混在する maven プロジェクト)には ScalaTest よりも specs の方が組み込みやすかった
  • ScalaTest と specs2 はどちらも素晴らしいライブラリなのであとは好みの問題
Stack Overflowにて

http://stackoverflow.com/questions/2220815/whats-the-difference-between-scalatest-and-scala-specs-unit-test-frameworks

  • ScalaTest と specs って何が違うんですか?
  • ScalaTest と specs の jar ファイルは共存できるので、どちらかだけを選択する必要はないよ
  • specs は BDD のためにデザインされていて、ScalaTest はもっとテストの全般的な範囲をカバーしている
  • ScalaTest の Spec は matchers を内包しないので ShouldMatchers や MustMatchers を mixin するスタイル
  • specs の Specification は matchers も内包していて継承するだけで matchers も使える all-in-one
  • BDD スタイルでの DSL の細かいシンタックスのポリシーが違う
  • specs には優れた Mockito サポートがある

JUnit

JUnit を使ってテストを書くことができます。

JUnit によるテストは mvn test で実行できますが sbt test ではスキップされます。

import org.junit.Test
import Assert._
import org.hamcrest.Matchers._
@Test
class StrringTest {
  @Test def startsWith = {
    assertThat("abc".startsWith("a"), is(true))
  }
}

ScalaTest

ScalaTestとは?

http://www.scalatest.org/

今回取り上げる3種類のフレームワークの中で、最も様々なスタイルのテストを書くことができます。

この記事は ScalaTest 1.6.1 を対象にしています。

ScalaTest は Scaladoc が非常に充実しているので Quick Start だけでなく、そちらも参考になります。

Assertions(AssertionsForJUnit)

http://www.scalatest.org/scaladoc/1.6.1/#org.scalatest.Assertions

JUnit でテストを実行します。

import org.scalatest.Assertions
import org.junit.Test
class StringSuite extends Assertions {
  @Test 
  def startsWith() {
    // ScalaTestによるassert
    assert("abc".startsWith("a")) 
    // 例外発生を想定
    intercept[NullPointerException] {
      val str: String = null
      str.startsWith("a")
    }
    // JUnitのAssert.*もimportすれば使える
    import org.junit.Assert.assertTrue
    assertTrue("abc".startsWith("a"))
  }
}
ShouldMatchers

http://www.scalatest.org/scaladoc/1.6.1/#org.scalatest.matchers.ShouldMatchers

assertion に "should" を使えるようになります。

import org.scalatest.Assertions
import org.scalatest.matchers.ShouldMatchers
import org.junit.Test
class StringShouldSuite extends Assertions with ShouldMatchers {
  @Test def startsWith() {
    "abc".startsWith("a") should be(true)
  }
}
MustMatchers

http://www.scalatest.org/scaladoc/1.6.1/#org.scalatest.matchers.MustMatchers

assertion に "must" を使えるようになります。

import org.scalatest.Assertions
import org.scalatest.matchers.MustMatchers
import org.junit.Test
class StringMustSuite extends Assertions with MustMatchers {
  @Test def startsWith() {
    "abc".startsWith("a") must be(true)
  }
}
Spec

http://www.scalatest.org/getting_started_with_spec
http://www.scalatest.org/scaladoc/1.6.1/#org.scalatest.Spec

BDD スタイルのテストケースを書くことができます。

この Spec トレイトは RubyRspec にインスパイアされており describe と it のネストのスタイルで記述します。

http://rspec.info/

import org.scalatest.Spec
import org.scalatest.matchers.ShouldMatchers
class StringSpec extends Spec with ShouldMatchers {
  describe("abc") { // describeはネスト可能
    it("should start with a") {
      "abc".startsWith("a") should be(true)
    }
  }
}
WordSpec

http://www.scalatest.org/getting_started_with_word_spec
http://www.scalatest.org/scaladoc/1.6.1/#org.scalatest.WordSpec

WordSpec は後述の Specs にインスパイアされた BDD スタイルです。

http://code.google.com/p/specs/

import org.scalatest.WordSpec
import org.scalatest.matchers.ShouldMatchers
class StringWordSpec extends WordSpec with ShouldMatchers {
  "abc" should {
    "start with a" in {
      "abc".startsWith("a") should be(true)
    }
  }
}
FlatSpec

http://www.scalatest.org/getting_started_with_flat_spec
http://www.scalatest.org/scaladoc/1.6.1/#org.scalatest.FlatSpec

FlatSpec は Spec や WordSpec のようなブロックのネストを避けた記述の BDD スタイルです。

import org.scalatest.FlatSpec
import org.scalatest.matchers.ShouldMatchers
class StringFlatSpec extends FlatSpec with ShouldMatchers {
  "abc" should "start with a" in {
    "abc".startsWith("a") should be(true)
  }
}
FeatureSpec

http://www.scalatest.org/getting_started_with_feature_spec
http://www.scalatest.org/scaladoc/1.6.1/#org.scalatest.FeatureSpec

FeatureSpec は feature に対するシナリオを記述する形式の BDD スタイルです。

これまでのものと同じサンプルなので、さすがに無理やりな感じになってしまっていますが、リンク先の公式ドキュメントにはより実用的な例があります。

import org.scalatest.FeatureSpec
import org.scalatest.GivenWhenThen
import org.scalatest.matchers.ShouldMatchers
class StringFeatureSpec extends FeatureSpec with GivenWhenThen with ShouldMatchers {
  feature("String value can judge it's first character.") {
    // sceario("startsWith works fine")(pending)
    scenario("startsWith works fine") {
      given("abc")
      val str = "abc"
      when("""when judging "abc" start with "a" """)
      val result = str.startsWith("a")
      then("the result should be true")
      result should be(true)
    }
  }
}
FunSuite

http://www.scalatest.org/getting_started_with_fun_suite
http://www.scalatest.org/scaladoc/1.6.1/#org.scalatest.FunSuite

FunSuite はシンプルなテストケースです。

FunSuite の「Fun」は function の「fun」で、以下の「testFun」にテストを記述します。

protected def test(testName: String, testTags: org.scalatest.Tag*)(testFun : => scala.Unit): Unit
import org.scalatest.FunSuite
class StringFunSuite extends FunSuite {
  test("abc starts with a") {
    assert("abc".startsWith("a"))
  }
}

ScalaTest のテストを IntelliJ IDEA で実行しようとするとテストが実行されなかったりします。

回避方法としては、以下のように JUnitRunner を使うよう指定して IDEA から JUnit で実行すればOKです。

IDEA から実行する場面が多いプロジェクトは @RunWith(classOf[JUnitRunner]) を指定するように統一してもよいかもしれません。

import org.scalatest.FunSuite
import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner
@RunWith(classOf[JUnitRunner])
class StringFunSuite extends FunSuite {
  test("abc starts with a") {
    assert("abc".startsWith("a"))
  }
}

specs / specs2

specs とは?

http://code.google.com/p/specs/

Scala 用の BDD スタイルです。Specification を継承するだけで一通りの assertion も使うことができます。

この記事ではspecs 1.6.7 / specs2 1.5 を対象にしています。

import org.specs.Specification
class StringSpecBySpecs extends Specification {
  "abc" should {
    "start with a" in {
      "abc".startsWith("a") mustEqual (true)
    }
  }
}
// Specification "StringSpecBySpecs"
//   abc should
//   + start with a
//
// Total for specification "StringSpecBySpecs":
// Finished in 0 second, 422 ms
// 1 example, 1 expectation, 0 failure, 0 error

2011/6/3 に specs2 1.0 がリリースされています。

specs もメンテナンスはされるものの新しい機能追加は specs2 にのみ行う方針とのことで、今後新しいプロジェクトを始める場合は specs ではなく specs2 を使うことを推奨されています。

http://etorreborre.github.com/specs2/

import org.specs2.mutable.Specification
class StringSpecBySpecs2 extends Specification {
  "abc" should {
    "start with a" in {
      "abc".startsWith("a") mustEqual (true)
    }
  }
}

ScalaCheck

ScalaCheck とは?

http://code.google.com/p/scalacheck/

ScalaCheck は Haskell の QuickCheck の一部として始まったもので、現在は QuickCheck にも存在しない機能もあるようです。

http://www.haskell.org/haskellwiki/Introduction_to_QuickCheck

テスト対象のふるまいを検証するコードを書くだけで、実行時にランダムで100パターンのテストが自動生成されます。

この記事は ScalaCheck 1.9 を対象にしています。

使い方

以下のような関数を用意して、forAll に渡された関数の戻り値として常に true が返るかどうかをテストする形式になります。

100パターンのパラメータによるテストが自動生成されて実行されます(=パラメータパターンは毎回異なります)。

Properties("String") や property("startsWith") で指定している文字列は、テストの実行には影響しないテスト結果の出力で使用するためのラベルです。

import org.scalacheck.Prop.forAll
import org.scalacheck.Properties

object StringSpecification extends Properties("String") {

  property("startsWith") = forAll {
    (a: String, b: String) => (a + b).startsWith(a)
  }
  // + String.startsWith: OK, passed 100 tests.

}

テストに失敗した場合は以下のようにどういう値を渡されたかが出力されます。

以下のメッセージは、a、b ともに空文字が渡されたケースで失敗したことを表しています。

  property("concat") = forAll {
    (a: String, b: String) => {
      (a.concat(b)).length > a.length && (a.concat(b)).length > b.length
    }
  }
  // ! String.concat: Falsified after 0 passed tests.
  // > ARG_0: 
  // > ARG_1: 

テストケースを書き換えると成功しました。

  property("concat") = forAll {
    (a: String, b: String) => {
      // (a.concat(b)).length > a.length && (a.concat(b)).length > b.length
      (a.concat(b)).length >= a.length && (a.concat(b)).length >= b.length
    }
  }
  // + String.concat: OK, passed 100 tests.

テストは Properties 型の propety(String) として定義するだけでなく、以下のように値にすることもできます。

import org.scalacheck.Prop.forAll
val propStringConcat = forAll {
  (a: String, b: String) => {
    (a + b).length >= a.length && (a + b).length >= b.length
  }
}
propStringConcat.check
// + OK, passed 100 tests.

サンプルとして Int#until(Int) のテストを書いてみます。

import org.scalacheck.Prop.forAll
import org.scalacheck.Properties
forAll {
  (a: Int, b: Int) => {
    println("[until] a:" + a + ",b:" + b)
    (a until b).size == (b - a).abs || (a > b && (a until b).size == 0)
  }
}.check

以下のようなエラーメッセージが出力されました。

...
! Exception raised on property evaluation.
> ARG_0: -1440408
> ARG_1: 2146043240
> Exception: java.lang.IllegalArgumentException: -1440408 until 2146043240 
  by 1: seqs cannot contain more than Int.MaxValue elements.
scala.collection.immutable.NumericRange$.count(NumericRange.scala:229)
scala.collection.immutable.Range$.count(Range.scala:247)
scala.collection.immutable.Range.numRangeElements(Range.scala:57)
...

値が Int.MaxValue を超えてしまっているようです。

これらのケースは今回のテスト対象ではないので、入力値の範囲をより小さい範囲に指定するようにします。

import org.scalacheck.Prop.forAll
import org.scalacheck.Gen.choose
forAll(choose(-100000,100000), choose(-100000,100000)) {
  (a: Int, b: Int) => {
    println("[until] a:" + a + ",b:" + b)
    (a until b).size == (b - a).abs || (a > b && (a until b).size == 0)
  }
}.check

テストは成功しました。

...
[until] a:-41559,b:54075
[until] a:-84343,b:-66921
[until] a:-12994,b:-97273
+ OK, passed 100 tests.

以下のように対象のパラメータが利用者によって定義された型の場合はそのままではテストが動作しません。

case class Name(first:String, last:String)
def fullName(name: Name) = name.first + " " + name.last.toUpperCase
import org.scalacheck.Prop.forAll
forAll { (name: Name) => fullName(name) == name.first + " " + name.last.toUpperCase }.check
// error: could not find implicit value for parameter a1: org.scalacheck.Arbitrary[Name]

この Name 型の場合は、String型をテストデータ自動生成対象にするのが最も簡単ですが・・

forAll { (f: String, l: String) => fullName(Name(f,l)) == f + " " + l }.check
// + OK, passed 100 tests.

エラーメッセージによると「org.scalacheck.Arbitrary[Name]」の暗黙のパラメータが必要とのことなので Name 型のテストデータ生成を定義してみます。

import org.scalacheck.Arbitrary
import org.scalacheck.Gen
import org.scalacheck.Gen.Params
implicit val nameClass: Arbitrary[Name] = Arbitrary(Gen[Name] {
  (params:Params) => {
    val atoz = "abcdefghijklmnopqrstuvwxyz".toCharArray
    val rand = new java.security.SecureRandom
    val first = ((1 to rand.nextInt(10)) map {
      case i => atoz(rand.nextInt(atoz.length))
    }).mkString
    val last = ((1 to rand.nextInt(10)) map {
      case i => atoz(rand.nextInt(atoz.length))
    }).mkString
    Some(Name(first,last))
  }
})

この暗黙のパラメータを定義した後でテストを実行すると成功します。

forAll { 
  (name: Name) => {
    println("Param: " + name)
    fullName(name) == name.first + " " + name.last.toUpperCase 
  }
}.check
// ...
// Param: Name(rhnkoof,wfpj)
// Param: Name(zqvdorntl,wivs)
// Param: Name(jxerptxor,iga)
// + OK, passed 100 tests.

より詳細な情報はユーザガイドをご覧ください。

http://code.google.com/p/scalacheck/wiki/UserGuide