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

倭マン's BLOG

くだらない日々の日記書いてます。 たまにプログラミング関連の記事書いてます。 書いてます。

ScalaTest も『JUnit 実践入門』もまとめて相手してやんよ (2) : ScalaTest 特化機能1

Scala テスト

今回はちょっと『JUnit 実践入門』から外れて、ScalaTest (もしくは Scala)特有の機能について見ていきます(目次)。 ScalaTest User Guide で飛ばした話の回収です。

この記事の内容

【追記】

DiagrammedAssertions 【追記】

今まで使っていた assert メソッド(これは org.scalatest.Assertions に定義されている)では、検証が失敗すると素っ気ないメッセージが表示されるだけでした。 例えば以下のようなテストケースを実行すると

import org.scalatest.FlatSpec

class CalculatorSpec extends FlatSpec{

  "Calculator" should "multiplyで3と4の乗算結果が取得できる" in {
    val calc = new Calculator
    val expected = 11
    val actual = calc.multiply(3, 4)
    assert(actual == expected)
  }
  ..
}

次のようなメッセージが表示されるだけです:

[info] CalculatorSpec:
[info] Calculator
[info] - should multiplyで3と4の乗算結果が取得できる *** FAILED ***
[info]   12 did not equal 11 (CalculatorSpec.scala:11)

これでは assert に渡された引数のどの部分でどのように検証が失敗しているのかを見極めるのが大変です。 そこで、scalatest.DiagrammedAssertions トレイトをテストクラスにミックスインして

import org.scalatest.{DiagrammedAssertions, FlatSpec}

class CalculatorSpec extends FlatSpec with DiagrammedAssertions{
  ...
}

同じテストを実行すると(固定幅フォントで表示するために色を付けてませんが、実際には色つきで表示されます)

[info] CalculatorSpec:
[info] Calculator
[info] - should multiplyで3と4の乗算結果が取得できる *** FAILED ***
[info]   assert(actual == expected)
[info]          |      |  |
[info]          12     |  11
[info]                 false (CalculatorSpec.scala:11)

のように actual が12、 expected が11で検証が失敗していることがすぐに見て取れるような表示を出力してくれます。

assertResult や intercept ではこのような表示はしてくれないようです。  後で見る assertCompiles とかもたぶんやってくんない。

コード断片のコンパイル

前回見た「基本的なアサーション」というのは org.scalatest.Assertions トレイトに定義されているアサーションのメソッドだったのですが、この Assertions トレイトには他にも便利なアサーション用のメソッドがいくつか定義されています。 特に Scala のコード断片 (code snippet) がコンパイル可能かどうかを検証するメソッドというのがあります。 これに関連するメソッドは次の3つ:

メソッド名 説明
assertCompiles 引数の文字列が Scala コードとしてコンパイルできるかどうか
assertDoesNotCompile 引数の文字列が Scala コードとしてコンパイルできないかどうか(assertCompiles の逆)
assertTypeError 引数の文字列を Scala コードとしてコンパイルしたときに、特に型チェックでエラーがでるかどうか

具体的な使い方は次節の後の方で見ます。

TripleEquals 「===」

前回使ったサンプルコードでは、Calculator の divide メソッドは返り値を Float 値にしてました:

class Calculator{
  ...
  def divide(x:Int, y:Int):Float = ...
}

テストコードでは、それにあわせて計算結果の期待値も Float 値「1.5f」にしていました:

  "Calculator" should "divideで3と2の除算結果が取得できる" in {
    val calc = new Calculator
    val expected = 1.5f  // Float 値
    val actual = calc.divide(3, 2)
    assert(actual == expected)
  }

今の場合、これを Double 値にしていても値として比較してくれるのでテストは通ります*1

  "Calculator" should "divideで3と2の除算結果が取得できる" in {
    val calc = new Calculator
    val expected = 1.5d  // Double 値にする
    val actual = calc.divide(3, 2)
    assert(actual == expected)  // Float値とDouble値は比較可
  }

これは直感的にはいいのですが、場合によっては型が異なるときには値が同じでもアサーションを通さないでほしいときがあります。 こういう場合に使えるのが TypeCheckedTripleEquals トレイトです。 使い方は簡単で、org.scalactic.TypeCheckedTripleEquals トレイトをテストクラスにミックスインして、テストコードの中で「==」の代わりに「===」を使うだけです。

package scalatest.tutorial

import org.scalatest.FlatSpec
import org.scalactic.TypeCheckedTripleEquals

class TripleEqualsSpec extends FlatSpec with TypeCheckedTripleEquals{

  "Calculator" should "divideで3と2の除算結果が取得できる" in {
    val calc = new Calculator
    val expected = 1.5d
    val actual = calc.divide(3, 2)
    assert(actual === expected)  // コンパイル・エラー!
  }
}

注意が必要というか、「===」の高機能性というか、もし Float 値と Double 値を比較しようとするとアサーションが通らないのではなく、テストコードのコンパイル・エラーになります。 これを実際に見るために、前節の assertCompiles, assertDoesNotCompile, assertTypeError を使ってちょっとテストを書いてみましょう(下記のテストでは1つのテストケースでいくつもアサーションを行っていますが本当はよくない)。 「!==」は「===」の否定です。

package scalatest.tutorial

import org.scalatest.FlatSpec
import org.scalactic.TypeCheckedTripleEquals

class TripleEqualsSpec extends FlatSpec with TypeCheckedTripleEquals{

  "===" should "Double値とFloat値を比較しようとするとコンパイル・エラー" in {
    assertDoesNotCompile("1.5d === 1.5f")  // コンパイルできなヨ
    assertTypeError("1.5d === 1.5f")  // 「===」では Double 値と Float 値の比較は型チェックが通らない
    // assert(1.5d === 1.5f)  はコンパイル・エラーになる
  }

  "!==" should "Double値とFloat値を!==で比較しようとするとコンパイル・エラー" in {
    assertDoesNotCompile("1.5d !== 2.5f")
    assertTypeError("1.5d !== 2.5f")
    // assert(1.5d !== 2.5f)  はコンパイル・エラーになる

    assertCompiles("1.5d !== 1.5")
    assert(1.5d !== 1.5)  // ★アサーション・エラー
  }

  // 「===」 との比較のために、通常の「==」でのテストを書いてます
  "==" should "Double値とFloat値を比較できる" in {
    assertCompiles("1.5d == 1.5f")
    assert(1.5d == 1.5f)  // 「==」では Double 値と Float 値が値として等しければアサーションを通る

    assertCompiles("1.5d == 2.5f")
    assert(1.5d == 2.5f)  // ★アサーション・エラー
  }

  "!=" should "Double値とFloat値を比較できる" in {
    assertCompiles("1.5d != 1.5f")
    assert(1.5d != 1.5f)  // ★アサーション・エラー

    assertCompiles("1.5d != 2.5f")
    assert(1.5d != 2.5f)
  }

★アサーション・エラーと書いてある箇所は、コメントアウトしないとテストが通りません。

テストスタイル

あんまり扱うつもりはなかったんですが、ScalaTest 特化機能ということで、FlatSpec 以外のテストスタイルもちょっと見てみましょう。 と言ってもあんまり深入りはせずに、前回に書いた FlatSpec のテストコードを他のテストスタイルで書き換えてみる程度ですが。

  • FreeSpec
  • FunSpec
  • FlatSpec
  • FunSuite
  • Spec
  • WordSpec

これらのテストスタイルの違いは、基本的にテストケース(JUnit で言うところのテストメソッド)の宣言の違いです。 順番はテストケース名に日本語を使った場合にコードや出力が読みやすい順です(拙者の独断と偏見による)。

以下では、前回に書いた FlatSpec によるテストコードを他の各テストスタイルで書き直しているだけですが、いくつか本質的でない変更しています:

  • テスト本体で actual や expected などのローカル変数は使わない
  • 失敗時のメッセージを見るために、multiply メソッドの2つ目のテストは失敗するようにしてある(assert(calc.multiply(5, 7) == 12))
  • テストケースの本体の前に「in」を書くテストスタイル(FreeSpec, WordSpec)では「fixture-context object」(普通のトレイトなんだけど)というのを使ってます

「fixture-context object」は FlatSpec でも使えます。 これを使うと、以下のようなコード

class CalculatorSpec extends FlatSpec{

  "Calculator" should "multiplyで3と4の乗算結果が取得できる" in {
    val calc = new Calculator
    assert(calc.multiply(3, 4) == 12)
  }

  it should "multiplyで5と7の乗算結果が取得できる" in {
    val calc = new Calculator
    assert(calc.multiply(5, 7) == 12)
  }
}

を次のように書き換えることができます:

class CalculatorSpec extends FlatSpec{

  trait Fixture = new {
    val calc = new Calculator
  }

  "Calculator" should "multiplyで3と4の乗算結果が取得できる" in new Fixture{
    assert(calc.multiply(3, 4) == 12)
  }

  it should "multiplyで5と7の乗算結果が取得できる" in new Fixture{
    assert(calc.multiply(5, 7) == 12)
  }
}

各テストケースで使う Calculator オブジェクトを一箇所でインスタンス化(各テストケースで別々のオブジェクト)してるだけです。 詳しくはそのうち別記事で。

さて、これを踏まえて各テストスタイルでのテストコードと、そのテストを sbt で実行した結果の出力をザッと見ていきましょう。

FreeSpec

FreeSpec は英語以外の言語のためのテストスタイル。 日本語使うならこれでいいんじゃないかなぁ。 テストケースのカテゴリ分け(? 正式名称ではない。 タグなどとも別)もできるし、FlatSpec で使える機能も大体使えるっぽいです。 少々注意が必要なのは、テストケースの本体の前には(「-」ではなく)「in」を付ける必要があること。 これを付けないと実行可能なテストとして認識されません。 個人的にこれを付け忘れててハマった経験アリ。

package scalatest.tutorial.specs

import org.scalatest.FreeSpec
import scalatest.tutorial.Calculator

class CalculatorFreeSpec extends FreeSpec{

  trait Fixture{
    val calc = new Calculator
  }

  "Calculator" - {
    "multiplyメソッド" - {
      "3と4の乗算結果が取得できる" in new Fixture{
        assert(calc.multiply(3, 4) == 12)
      }

      "5と7の乗算結果が取得できる" in new Fixture{
        assert(calc.multiply(5, 7) == 12)
      }
    }

    "divideメソッド" - {
      "3と2の除算結果が取得できる" in new Fixture{
        assert(calc.divide(3, 2) == 1.5f)
      }

      "5と0のときIllegalArgumentExceptionを送出する" in new Fixture{
        intercept[IllegalArgumentException] {
          calc.divide(5, 0)
        }
      }
    }
  }
}

テストを実行するとこんな感じ:

> test-only scalatest.tutorial.specs.CalculatorFreeSpec
[info] CalculatorFreeSpec:
[info] Calculator
[info]  multiplyメソッド
[info]  - 3と4の乗算結果が取得できる
[info]  - 5と7の乗算結果が取得できる *** FAILED ***
[info]   35 did not equal 12 (CalculatorFreeSpec.scala:20)
[info]  divideメソッド
[info]  - 3と2の除算結果が取得できる
[info]  - 5と0のときIllegalArgumentExceptionを送出する
[info] Run completed in 4 seconds, 228 milliseconds.
[info] Total number of tests run: 4
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 3, failed 1, canceled 0, ignored 0, pending 0
[info] *** 1 TEST FAILED ***
[error] Failed tests:
[error]     scalatest.tutorial.specs.CalculatorFreeSpec
[error] (test:testOnly) sbt.TestsFailedException: Tests unsuccessful
[error] Total time: 8 s, completed 2015/06/08 6:22:56

日本語としてフツー。 これでいいんじゃね?

FunSpec

このテストスタイルもあまり英語縛りがないので、出力は FreeSpec と同じくらい日本語として読めます。 テストケースのカテゴリ分けも可。 ただ、コードに desclibe や it などを書かないといけないのと、FlatSpec で使える機能が一部使えないあたりがちょっと難点。

package scalatest.tutorial.specs

import org.scalatest.FunSpec
import scalatest.tutorial.Calculator

class CalculatorFunSpec extends FunSpec{

  describe("Calculator") {
    describe("multiplyメソッド") {
      it("3と4の乗算結果が取得できる") {
        val calc = new Calculator
        assert(calc.multiply(3, 4) == 12)
      }

      it("5と7の乗算結果が取得できる") {
        val calc = new Calculator
        assert(calc.multiply(5, 7) == 12)
      }
    }

    describe("divideメソッド") {
      it("3と2の除算結果が取得できる") {
        val calc = new Calculator
        assert(calc.divide(3, 2) == 1.5f)
      }

      it("5と0のときIllegalArgumentExceptionを送出する") {
        val calc = new Calculator
        intercept[IllegalArgumentException] {
          calc.divide(5, 0)
        }
      }
    }
  }
}

出力はこんな感じ:

> test-only scalatest.tutorial.specs.CalculatorFunSpec
[info] CalculatorFunSpec:
[info] Calculator
[info]  multiplyメソッド
[info]  - 3と4の乗算結果が取得できる
[info]  - 5と7の乗算結果が取得できる *** FAILED ***
[info]   35 did not equal 12 (CalculatorFreeSpec.scala:20)
[info]  divideメソッド
[info]  - 3と2の除算結果が取得できる
[info]  - 5と0のときIllegalArgumentExceptionを送出する
[info] Run completed in 2 seconds, 544 milliseconds.
[info] Total number of tests run: 4
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 3, failed 1, canceled 0, ignored 0, pending 0
[info] *** 1 TEST FAILED ***
[error] Failed tests:
[error]     scalatest.tutorial.specs.CalculatorFunSpec
[error] (test:testOnly) sbt.TestsFailedException: Tests unsuccessful
[error] Total time: 3 s, completed 2015/06/08 6:33:56

出力自体は FreeSpec と同じ。

FunSuite

これは1番 JUnit に近いんじゃないかな。 @Test アノテーションを付けるのではなく test メソッドだけど。 テストケース名は普通の文字列そのままで書けますが、テストのカテゴリ分けは不可。 ちなみに「Fun」は function から来てるそう(FunSpec も同じかな?)。 別に楽しいテストって意味ではない(笑)

package scalatest.tutorial.specs

import org.scalatest.FunSuite
import scalatest.tutorial.Calculator

class CalculatorFunSuite extends FunSuite{

  test("Calculatorはmultiplyで3と4の乗算結果が取得できる"){
    val calc = new Calculator
    assert(calc.multiply(3, 4) == 12)
  }

  test("Calculatorはmultiplyで5と7の乗算結果が取得できる"){
    val calc = new Calculator
    assert(calc.multiply(5, 7) == 12)
  }

  test("Calculatorはdivideで3と2の除算結果が取得できる"){
    val calc = new Calculator
    assert(calc.divide(3, 2) == 1.5f)
  }

  test("Calculatorはdivideで5と0のときIllegalArgumentExceptionを送出する"){
    val calc = new Calculator
    intercept[IllegalArgumentException]{
      calc.divide(5, 0)
    }
  }
}

実行結果は

> test-only scalatest.tutorial.specs.CalculatorFunSuite
[info] CalculatorFunSuite:
[info] - Calculatorはmultiplyで3と4の乗算結果が取得できる
[info] - Calculatorはmultiplyで5と7の乗算結果が取得できる *** FAILED ***
[info]  35 did not equal 12 (CalculatorFunSuite.scala:16)
[info] - Calculatorはdivideで3と2の除算結果が取得できる
[info] - Calculatorはdivideで5と0のときIllegalArgumentExceptionを送出する
[info] Run completed in 2 seconds, 211 milliseconds.
[info] Total number of tests run: 4
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 3, failed 1, canceled 0, ignored 0, pending 0
[info] *** 1 TEST FAILED ***
[error] Failed tests:
[error]     scalatest.tutorial.specs.CalculatorFunSuite
[error] (test:testOnly) sbt.TestsFailedException: Tests unsuccessful
[error] Total time: 3 s, completed 2015/06/08 6:38:01

出力は FlatSpec と同じような感じ。

Spec

  • Scaladoc「Spec

Spec はスコープ・オブジェクト(普通の object)でカテゴリ分けして、メソッドでテストケースを定義します。 スコープオブジェクト名やテストケース名はバッククォート (`) で挟んで書きます。 注意が必要なのは、これらの名前には半角スペースを1つ以上入れないとスコープオブジェクトやテストケースとして認識されません。 この性質のため、イマイチ日本語で書くのに適してません。

package scalatest.tutorial.specs

import org.scalatest.Spec
import scalatest.tutorial.Calculator

class CalculatorObjectSpec extends Spec{

  object `Calculator ` {
    object `multiplyメソッド ` {
      def `34の乗算結果が取得できる ` {
        val calc = new Calculator
        assert(calc.multiply(3, 4) == 12)
      }

      def `57の乗算結果が取得できる ` {
        val calc = new Calculator
        assert(calc.multiply(5, 7) == 12)
      }
    }

    object `divideメソッド ` {
      def `32の除算結果が取得できる ` {
        val calc = new Calculator
        assert(calc.divide(3, 2) == 1.5f)
      }

      def `50のときIllegalArgumentExceptionを送出する ` {
        val calc = new Calculator
        intercept[IllegalArgumentException] {
          calc.divide(5, 0)
        }
      }
    }
  }
}

ここではスコープ・オブジェクト名やテストケース名の最後にスペースを付けてます。 ミスの元だなぁ。 出力結果はこんな感じ:

> test-only scalatest.tutorial.specs.CalculatorObjectSpec
[info] CalculatorObjectSpec:
[info] A Calculator
[info]  A Calculator/ide method
[info]  - 3と2の除算結果が取得できる
[info]  - 5と0のときIllegalArgumentExceptionを送出する
[info]  multiply method
[info]  - 3と4の乗算結果が取得できる
[info]  - 5と7の乗算結果が取得できる *** FAILED ***
[info]   35 did not equal 12 (CalculatorObjectSpec.scala:18)
[info] Run completed in 3 seconds, 12 milliseconds.
[info] Total number of tests run: 4
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 3, failed 1, canceled 0, ignored 0, pending 0
[info] *** 1 TEST FAILED ***
[error] Failed tests:
[error]     scalatest.tutorial.specs.CalculatorObjectSpec
[error] (test:testOnly) sbt.TestsFailedException: Tests unsuccessful
[error] Total time: 4 s, completed 2015/06/08 6:44:37

テストケースがカテゴリ分けされているので出力は読みやすいです。 ただ、divideメソッドの「div」の部分がおかしなことになっとる。

WordSpec

WordSpec は日本語を使う上で最大の敵みたいなテストスタイル(笑) should, when, which などのメソッドを使ってテストケースの定義やカテゴリ分けをし、出力結果にもこれらの単語が表示されるので、日本語とは相性悪いです。 英語で書くなら、独自の単語とか書いたりできる自由度があるようですが。

package scalatest.tutorial.specs

import org.scalatest.WordSpec
import scalatest.tutorial.Calculator

class CalculatorWordSpec extends WordSpec{

  trait Fixture{
    val calc = new Calculator
  }

  def provide = afterWord("規定する")

  "Calculator" should provide{
    "multiplyメソッド" which {
      "3と4の乗算結果が取得できる" in new Fixture {
        assert(calc.multiply(3, 4) == 12)
      }

      "5と7の乗算結果が取得できる" in new Fixture {
        assert(calc.multiply(5, 7) == 12)
      }
    }

    "divideメソッド" which {
      "3と2の除算結果が取得できる" in new Fixture {
        assert(calc.divide(3, 2) == 1.5f)
      }

      "5と0のときIllegalArgumentExceptionを送出する" in new Fixture {
        intercept[IllegalArgumentException] {
          calc.divide(5, 0)
        }
      }
    }
  }
}

実行結果はこんな感じ:

> test-only scalatest.tutorial.specs.CalculatorWordSpec
[info] CalculatorWordSpec:
[info] Calculator
[info]  should 規定する
[info]   multiplyメソッド which
[info]   - 3と4の乗算結果が取得できる
[info]   - 5と7の乗算結果が取得できる *** FAILED ***
[info]    35 did not equal 12 (CalculatorWordSpec.scala:22)
[info]   divideメソッド which
[info]   - 3と2の除算結果が取得できる
[info]   - 5と0のときIllegalArgumentExceptionを送出する
[info] Run completed in 2 seconds, 885 milliseconds.
[info] Total number of tests run: 4
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 3, failed 1, canceled 0, ignored 0, pending 0
[info] *** 1 TEST FAILED ***
[error] Failed tests:
[error]     scalatest.tutorial.specs.CalculatorWordSpec
[error] (test:testOnly) sbt.TestsFailedException: Tests unsuccessful
[error] Total time: 3 s, completed 2015/06/08 7:22:35

テストケースの書き方にもよりますが、should や which などがどうしても出力されてしまいます。 WordSpec やるなら英語で。

今回はちょっとした ScalaTest の回収のつもりでしたが、またまた記事が長くなってしまいました。 次回からは『JUnit 実践入門』に戻る予定。github.com

JUnit実践入門 ~体系的に学ぶユニットテストの技法 (WEB+DB PRESS plus)

JUnit実践入門 ~体系的に学ぶユニットテストの技法 (WEB+DB PRESS plus)

Scalaスケーラブルプログラミング第2版

Scalaスケーラブルプログラミング第2版

  • 作者: Martin Odersky,Lex Spoon,Bill Venners,羽生田栄一,水島宏太,長尾高弘
  • 出版社/メーカー: インプレスジャパン
  • 発売日: 2011/09/27
  • メディア: 単行本(ソフトカバー)
  • 購入: 12人 クリック: 235回
  • この商品を含むブログ (46件) を見る

*1:Scala では浮動小数点数はデフォルトで Double 値なので「1.5d」でなく「1.5」でも同じですが、特に Double 値であることを明示するために d を付けてます。