倭マン's BLOG

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

ScalaTest も『JUnit 実践入門』もまとめて相手してやんよ (1) : テストコードの記述

今回は「テストコードの記述」を見ていきます(目次)。

この記事の内容

参考

テストクラス

まずはテストクラスの宣言。 ScalaTest ではどちらかというと JUnit 3.x までのようにベースとなる型を extends して作成します。 ScalaTest の場合は抽象クラスの継承ではなくトレイトのミックスインですが。 ミックスインするのはテストスタイル (testing style)*1です。

package scalatest.tutorial

import org.scalatest.FlatSpec

class CalculatorSpec extends FlatSpec{
  ...
}

ここでは FlatSpec というテストスタイルをミックスインしています*2。 テストスタイルとしては以下のような種類があります:

  • FunSuite ← xUnit
  • FlatSpec ← xUnit
  • FunSpec ← RSpec (Ruby)
  • WordSpec ← specs/specs2
  • FreeSpec
  • Spec

  • PropSpec
  • FeatureSpec

詳しくは『Selecting testing styles for your project』参照。 下2つの PropSpec, FeatureSpec は別として、これらのテストスタイルはそれぞれテストケースの宣言の仕方が違うだけで、テストを実行するコードは同じように書けます。 一応、各クラスが他のテスティング・フレームワークに沿ったスタイルで書けるようにしてあるので、他フレームワークからの移行時にどれを使うとよいかを考えるとよいと思います*3。 特に何かしら制約がないなら FlatSpec を使っておくのが無難かと。 この記事でも基本的には FlatSpec を使います。

また、JUnit ではテストクラスの語尾は Test にしていましたが、ScalaTest では Spec (specification 仕様)をつけるようです。

テストケース

テストクラスの宣言を書いたので、次はテストケースを追加しましょう。 JUnit では @Test アノテーションをつけたメソッド(もうちょっと制約がありますが)として書きますが、ScalaTest の FlatSpec では

  "《テスト対象クラス名》" should "《満たすべき内容》" in {
    テストで実行するコード
  }

のように書きます。 "《テスト対象クラス名》" や "《満たすべき内容》" は通常の文字列です。 「Calculator が multiply メソッドで掛け算結果を取得できる」テストケースの宣言はこんな感じかな:

  "A Calculator" should "calculate the product with the multiply method" in {
    ...
  }

英語は冠詞とか前置詞が不安ですな。 ということで『JUnit 実践入門』にも書かれているようにテストケース名は日本語で書きたいんだけど、ちょっと ScalaTest さんのいけないところはメソッド名に should とか使ってるので英語以外で書こうとすると文にならないところ。 まぁ、でもその辺は諦めて気にしないことにして、普通に日本語で書いちゃいましょう:

  "Calculator" should "multiplyで乗算結果が取得できる" in {
    fail("まだ実装されていません")
  }

テストの実行結果は

...
[info] CalculatorSpec:
[info] Calculator
[info] - should multiplyで乗算結果が取得できる *** FAILED ***
[info] まだ実装されていません (CalculatorSpec.scala:8)
...

のようになって、どのテストケースが失敗したか分かるので特に問題ないかと思います。 どうしてもイヤだという人は別のテストスタイル(FunSuite, FunSpec, FreeSpec あたり)を試してみてください。

同じテスト対象クラスに対してのテストケースを複数書く場合は、2つ目以降を it (文字列ではない)にします。

  "Calculator" should "multiplyで3と4の乗算結果が取得できる" in { ... }

  it should "multiplyで5と7の乗算結果が取得できる" in { ... }

  it should "divideで3と2の除算結果が取得できる" in { ... }

  it should "divideで5と0のときIllegalArgumentExceptionを送出する" in { ... }

テスト対象が複数形の場合は they を全く同じように使えるそうです。

因みにどうでもいいことですが、FlatSpec のテストケース宣言は暗黙の型変換やメソッドの括弧などを省略せずに書くと以下のようになってます:

  convertToStringShouldWrapper("Calculator").should("multiplyで乗算結果が取得できる").in({
    fail("まだ実装されていません")
  })

自分で DSL とか書くときが来たら使えるかも。

基本的なアサーション

テストクラスとテストケースの宣言が書けたので、実際にテストを実行するコードを書いていきましょう。 JUnit の assertThat & Matcher に対応する、Matcher を使ったアサーションは後日に見ていくことにして、今回は基本的なアサーションのみを見ていきます。

基本的なアサーション(検証)には以下のようなものがあります:

assert 通常の検証
assertResult 計算過程が長い場合の検証
intercept 例外が投げられることを検証
fail 常に失敗する検証

fail は前回出てきたのでいいですかね。 他のものは具体的なテストコードで見ていきましょう。 例によって、『JUnit 実践入門』からコードを拝借。 まずは掛け算の検証その1。 assert を使ったテスト:

class CalculatorSpec extends FlatSpec{

  "Calculator" should "multiplyで3と4の乗算結果が取得できる" in {
    val calc = new Calculator
    val expected = 12
    val actual = calc.multiply(3, 4)
    assert(actual == expected)  // assert を使った検証
  }
}

次は掛け算の検証その2。 assertResult を使ってみます:

  it should "multiplyで5と7の乗算結果が取得できる" in {
    val calc = new Calculator
    assertResult(35){  // assertResult を使った検証
      calc.multiply(5, 7)
    }
  }

これくらいの短い計算では返って見にくいですね。 まぁ、必要になったときに使えるように、頭の片隅に置いておく感じですかね。 次は除算の検証ですが、『JUnit 実践入門』にあるように、 Calculator クラスの divide メソッドの仕様を

  • 返り値が Float
  • 0で割った場合に IllegalArgumentException を送出する

という風に変更します。 ScalaTest では、例外が投げられることを検証するためには intercept を使います。 除算のテストはまとめていきましょう。

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

  it should "divideで5と0のときIllegalArgumentExceptionを送出する" in {
    val calc = new Calculator
    intercept[IllegalArgumentException]{  // intercept を使って例外が投げられることの検証
      calc.divide(5, 0)
    }
  }

これらのテストが通るように divide メソッドを書き直しましょう:

  def divide(x:Int, y:Int):Float = y match {
    case 0 => throw new IllegalArgumentException("divide by zero.")
    case _ => x.toFloat / y.toFloat
  }

テストを実行すると

[info] CalculatorSpec:
[info] Calculator
[info] - should multiplyで3と4の乗算結果が取得できる
[info] - should multiplyで5と7の乗算結果が取得できる
[info] - should divideで3と2の除算結果が取得できる
[info] - should divideで5と0のときIllegalArgumentExceptionを送出する
[info] Run completed in 6 seconds, 602 milliseconds.
[info] Total number of tests run: 4
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 4, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 111 s, completed 2015/06/04 14:23:47

となり全てのテストが通ってめでたしめでたし。

検証が失敗したときのメッセージ
各アサーションには検証に失敗したときのメッセージを追加することができます:

  // assert
  assert(actual == expected, "ここに検証に失敗したときのためのメッセージが書ける")

  // assertResult
  assertResult(35, "ここに検証に失敗したときのためのメッセージが書ける"){
    calc.multiply(5, 7)
  }

  // intercept
  withClue("ここに検証に失敗したときのためのメッセージが書ける"){
    intercept[IllegalArgumentException]{
      calc.divide(5, 0)
    }
  }

  // fail
  fail("ここに検証が失敗したときのためのメッセージが書ける")
  • assert や fail は JUnit などと同じなので簡単
  • assertResult も、まぁ何てことなし
  • intercept は withClue*4 によって包む必要あり。 intercept の場合の検証失敗とは、例外が投げられなかったり、指定したものと異なる例外が投げられたりした場合。 念のため。

テストのキャンセル
『JUnit 実践入門』の話からは外れますが、ある条件が整っていない場合(インターネットやデータベースが利用不可な場合など)にテストをキャンセルする assume, cancel というメソッドもあります。 これらのメソッドは、それぞれアサーションに使う assert, fail と似たシグニチャを持ちます。 ただし、投げられる例外が異なります:

引数に Boolean 値をとり
それが false なら例外を投げる
引数をとらず
常に例外を投げる
TestFailedException assert fail
TestCanceledException assume cancel

assume は JUnit の assumeThat とは異なるようですね(これは例外を投げない)。 パラメータ化テストまでたどり着けたら真面目にやりましょうか。

ここまでの話で基本的なテストは書いて実行できるようになりました。 とはいえ、やはり Matcher を使った検証までは行きたいですね。 もう少し先ですが。

ここまでのコード
Calculator.scala

package scalatest.tutorial

class Calculator {

  def multiply(x:Int, y:Int):Int = x * y

  def divide(x:Int, y:Int):Float = y match {
    case 0 => throw new IllegalArgumentException("divide by zero.")
    case _ => x.toFloat / y.toFloat
  }
}

CalculatorSpec.scala

package scalatest.tutorial

import org.scalatest.FlatSpec

class CalculatorSpec extends FlatSpec{

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

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

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

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

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:コードで言うとテストクラスにどのようにテストケースを定義するか。 JUnit の場合はテストクラスにメソッドとしてテストケースを定義しますね。

*2:なんか言葉遣いがおかしい気もするけど、特定のテストスタイルを表すトレイトをミックスインしている、と言う意味。 別におかしくないか。

*3:チーム開発の場合は、各個人が自分勝手に好きなスタイルのクラスを使うのではなく、チームで1つのスタイルを決めてそれを全員が使うようにすべきだそうです。 また、これらの抽象テストクラスを直接継承して具象テストクラスを作るのではなく、継承によってチーム内での抽象テストクラスを作り、それを継承して具象テストクラスを作る方が望ましいようです。

*4:"clue" は「手がかり」という意味。