Specs2 で基本的なテストを記述する

2015.12.18

はじめに

クリスマスが差し迫ってまいりました。ちかごろは、サンタの襲来に怯える日々を過ごしております。
というわけで今回は Scala で広く利用されているテスティングフレームワークの Specs2 を使用して基本的なテストを記述する方法をご紹介します。

目次

Specs2 を使ってテストを書いてみる

Specs2 のインポート

Specs2 を使用してテストを記述するには、まず Specs2 ライブラリを依存関係に追加する必要があります。
公式サイトの QuickStart に従って、以下の2行を build.sbt に追加しましょう。

build.sbt

// ––略––

libraryDependencies += "org.specs2" %% "specs2-core" % "3.6.6" % "test" // 追加
scalacOptions in Test ++= Seq("-Yrangepos") // 追加

3行目の "3.6.6" の箇所には、利用する Specs2 のバージョンが入ります。今回は執筆時点で最新の 3.6.6 を使います。

うさぎクラスを用意する

テストコードを記述するにはテスト対象クラスが必要です。既存のクラス (Scala API にあるクラスなど) に対するテストコードを書くこともできますが、今回はより実践に近くするため、自分で作ったクラスに対するテストを記述してみたいと思います。

そのために、以下の簡単な「うさぎクラス」を用意しました。

Rabbit.scala

class Rabbit(name: String) {

  def jump: String = s"$name jumped!"
}

それでは、うさぎクラスに対して Specs2 のテストコードを書いてみます。

うさぎクラスをテストする

では、うさぎクラスの振る舞いをテストするコードを書いてみます。

RabbitSpec.scala

import org.specs2.mutable.Specification

class RabbitSpec extends Specification {

  "うさぎ" should {

    "ジャンプできる" in {
      val rabbit = new Rabbit("月野うさぎ")
      rabbit.jump must_== "月野うさぎ jumped!"
    }
  }
}

RabbitSpec.scala は src/test/scala 以下に配置します。

Specs2 では "コンテキスト名" should {...} でコンテキストを区切ります。そして実際のテストケースは "テストケース名" in {...} と記述します。
また、 Specification を継承することで should in などの DSL や must_== などのマッチャーが使用できるようになります。

上記のテストケースでは Rabbit クラスの jump メソッドが正しく動作することを保証しています。

テストを実行する

まずターミナルから実行してみます。これは単純に SBT のルートで sbt test を実行すれば OK です。

$ sbt test

...
[info] RabbitSpec
[info] 
[info] うさぎ should
[info]   + ジャンプできる
[info] 
[info] 
[info] Total for specification RabbitSpec
[info] Finished in 47 ms
[info] 1 example, 0 failure, 0 error
[info] 
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 2 s, completed 2015/12/18 15:31:46

14行目の Passed: Total 1, Failed 0, Errors 0, Passed 1 を確認すると、計1ケースのうち1ケースが成功した("うさぎ" should "ジャンプできる" が成功した)のが確認できます。

Intellij IDEA を使用している場合は Project ペインから実行できます。

idea-specs2

また OS X のキーバインドであれば Command + Option + R などのキーボードショートカットも利用可能です。

構造化されたテストを記述する

うさぎクラスの拡張

すべての動物は話すことができるべきだ、という上長のふざけた思い込みのためにうさぎクラスを拡張することになりました。

Rabbit.scala

class Rabbit(name: String) {

  def jump: String = s"$name jumped!"

  def talk: String = throw new UnsupportedOperationException("Rabbit cannot talk!")
}

これが新しい Rabbit クラスの定義です。talk メソッドが新しく追加されましたが、うさぎは話すことができないので(仮にできても私たちにはわからないので)UnsupportedOperationException 例外を発生させる実装になっています。

この拡張を踏まえて、テストコードを修正してみます。

テストコードの修正

拡張されたうさぎクラスに対するテストコードは次のようになりました。

RabbitSpec.scala

import org.specs2.mutable.Specification

class RabbitSpec extends Specification {

  "うさぎ" should {

    "ジャンプできる" in {
      val rabbit = new Rabbit("月野うさぎ")
      rabbit.jump must_== "月野うさぎ jumped!"
    }

    "話すことができない" in {
      val rabbit = new Rabbit("月野うさぎ")
      rabbit.talk must throwAn[UnsupportedOperationException]
    }
  }
}

talk メソッドの振る舞いに対するテストが "うさぎ" コンテキスト以下で "ジャンプできる" ケースと並列の箇所に追加されました。

実際に実行すると、次のような結果が得られます。

$ sbt test

...
[info] RabbitSpec
[info] 
[info] うさぎ should
[info]   + ジャンプできる
[info]   + 話すことができない
[info] 
[info] 
[info] Total for specification RabbitSpec
[info] Finished in 41 ms
[info] 2 examples, 0 failure, 0 error
[info] 
[info] Passed: Total 2, Failed 0, Errors 0, Passed 2
[success] Total time: 10 s, completed 2015/12/18 16:04:25

15行目の Passed: Total 2, Failed 0, Errors 0, Passed 2 を確認すると、テストケース数が1つ増えて2つになり、また今回も全て成功していることがわかります。

このように、Specs2 では shouldin の DSL を使って構造化されたテストを記述することが可能です。
また、この構造は二重三重にネストさせることが可能です。試しにネストを深くしてみます。

AlternativeRabbitSpec.scala

import org.specs2.mutable.Specification

class RabbitSpec extends Specification {

  "うさぎ" should {

    "日本名のうさぎ" should {

      "ジャンプできる" in {
        val rabbit = new Rabbit("月野うさぎ")
        rabbit.jump must_== "月野うさぎ jumped!"
      }
    }

    "英名のうさぎ" should {

      "ジャンプできる" in {
        val rabbit = new Rabbit("Peter Rabbit")
        rabbit.jump must_== "Peter Rabbit jumped!"
      }
    }

    "話すことができない" in {
      val rabbit = new Rabbit("月野うさぎ")
      rabbit.talk must throwAn[UnsupportedOperationException]
    }
  }
}

このように should をネストさせることで、複雑なコンテキストにも対応可能になります。
実際には、上記のようなコンテキストの切り方は Rabbit クラスが与えられた名前によって振る舞いを変えるような実装になっていない限り行う必要がありません。

共通事前条件のあるテストを記述する

うさぎテストのリファクタリング

先ほど記述した RabbitSpec.scala には明らかに DRY でない箇所がありました。

RabbitSpec.scala

import org.specs2.mutable.Specification

class RabbitSpec extends Specification {

  "うさぎ" should {

    "ジャンプできる" in {
      val rabbit = new Rabbit("月野うさぎ")
      rabbit.jump must_== "月野うさぎ jumped!"
    }

    "話すことができない" in {
      val rabbit = new Rabbit("月野うさぎ")
      rabbit.talk must throwAn[UnsupportedOperationException]
    }
  }
}

8行目と13行目の val rabbit = ... を切り出すことで DRY になりそうです。
では早速リファクタリングしてみます。

RabbitSpec.scala

import org.specs2.mutable.Specification
import org.specs2.specification.Scope

class RabbitSpec extends Specification {

  "うさぎ" should {

    trait RabbitBefore {

      val name = "月野うさぎ"
      val rabbit = new Rabbit(name)
    }

    "ジャンプできる" in new Scope with RabbitBefore {
      rabbit.jump must_== s"$name jumped!"
    }

    "話すことができない" in new Scope with RabbitBefore {
      rabbit.talk must throwAn[UnsupportedOperationException]
    }
  }
}

共通の事前条件をトレイトに切り出すことにしました。

事前条件を定義したトレイトは org.specs2.specification.Scope とミックスインし、テストケース定義の DSL in[R : CommandLineAsResult](r: => R) に匿名クラスとして生成して渡してあげることで テストケースごとに実行される共通事前コード として機能します。また、テストコード自体はこの匿名クラスのイニシャライザとして記述されるため、テストコード中から定義したトレイトに含まれるパラメータを参照できます。

より DRY にするなら new Scope with RabbitBefore を切り出して trait Context extends Scope with RabbitBefore とすることも可能ですが、やり過ぎには注意しましょうね。

まとめ

  • Specs2 では should, in などの DSL を用いて構造化されたテストを記述できます。
  • should によるコンテキストはネスト可能です。
  • トレイトを Scope とミックスインして in に渡すと、事前条件として機能させることができます。

今回はとても便利なテスティングフレームワーク Specs2 のさわり部分をご紹介しました。
Spec2 を使いこなして心豊かな Scala ライフを! ではまた!

参考