ScalaTestとScalaMockでテストを行なう

はじめに

今後Scalaを触ることになりそうになのでちょっと勉強しておこうかと思いまして、まずはテストのやり方を確認しようかと思いScalaTestScalaMockを使ってユニットテストを書いてみようと思います。

書いてみる

環境

今回の環境は以下の通り

$ scala --version
Scala code runner version 3.0.0 -- Copyright 2002-2021, LAMP/EPFL

$ sbt --version
sbt version in this project: 1.5.2
sbt script version: 1.5.2

セットアップ

まず、sbtのプロジェクトはIntelliJプラグインで作成しました。
ScalaTestのセットアップはこちらを参考に行います。

まずは、build.sbtに以下の依存を追加します。

libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.9"
libraryDependencies += "org.scalamock" %% "scalamock" % "5.1.0" % Test
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.9" % "test"

sbtで依存を定義するための手っ取り早い方法はlibraryDependenciesに次のような構文で記述するようです。
libraryDependencies += groupID % artifactID % revision % configuration

ここでは3つ依存を定義しています。scalatestscalamockに関しては良いとしてscalaitcですが、ScalaTestの姉妹ライブラリーで==オペレーターなどのテストやプロダクションコードで使えるような諸々を提供してくれるみたいです。今回使うかはわかりませんが、ドキュメントで依存に追加することが推奨されていたので追加しておこうと思います。

ScalaTestでユニットテストを記述する

ScalaTestのスタイルについて

ScalaTestでは以下のようなテストスタイルをサポートしているようです。

  • FunSuiteスタイル
  • FlatSpecスタイル
  • FunSpecスタイル
  • FreeSpecスタイル

それぞれのテストの書き方はこちらを参考にしてください。 最初のステップとしてはFlatSpecスタイルがおすすめされているようなので、このブログではFlatSpecを用いてテストを記述しようと思います。

単純なテストを書いてみる

まずは以下のような足し算をするだけの簡単なクラスを用意します。

class Calc {
  def plus(a: Int, b: Int): Int = a + b
}

このクラスのメソッドplusをテストするコードは以下のようになります。

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should

class CalcTest extends AnyFlatSpec with should.Matchers {

  it should "足し算の結果を返す" in {
    val c = new Calc
    c.plus(1, 2) should be (3)
  }
}

AnyFlatSpecX should YA must Bのような形式でテストを記述するための構文を提供してくれます。   shouldmustcanと行ったような助動詞の記述が可能なようです。
また、should.Matchersをミクスインしていますが、これは result should be (expected)のようにアサーションを記述するための構文を提供してくれます。
should.Matchersは以下のような構文を提供します。

  • result should equal (expected)
    • 比較がカスタマイズ可能
  • result should === (expected)
    • 比較がカスタマイズ可能
    • 型の制約を強制する
  • result should be (expected)
    • 比較がカスタマイズができない代わりにコンパイルが早い
  • result shouldEqual expected
    • ()を必要としない
    • カスタマイズ可能な比較
  • result shouldBe 3
    • ()を必要としない
    • 比較がカスタマイズができない代わりにコンパイルが早い

should.Matchersの他にもAnyFlatSpecの上位のクラスでミクスインされているAssertionsトレイトではアサーションを行なうためのいくつかのマクロを定義されています。
このマクロには例えば以下のようなものがあります。

  • fail
    • テストを失敗させる
  • succeed
    • テストを成功させる

他にもさまざまなマクロがあります。詳細はこちらを確認ください。

それでは、記述したテストを実行してみます。
様々な実行方法がありますが、今回はsbtを使って実行したいと思います。
単純にすべてのテストを実行するためにはプロジェクトルートで以下のコマンドを実行します。

$ sbt

> test
[info] CalcSpec:
[info] - should 足し算の結果を返す
[info] CalcFunSuiteTest:
[info] - 足し算の結果を返す
[info] Run completed in 120 milliseconds.
[info] Total number of tests run: 2
[info] Suites: completed 2, aborted 0
[info] Tests: succeeded 2, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 0 s, completed 2021/05/29 23:15:25

CalcFunSuiteTestはブログには記載してませんが、同じテストを別のスタイルで書いているだけです。ここではそんなに気にしなくても大丈夫です。
すべてのテストでは無く一部のテストを実行したい場合は以下のように実行します。

> testOnly CalcSpec
[info] CalcSpec:
[info] - should 足し算の結果を返す
[info] Run completed in 324 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.
[success] Total time: 1 s, completed 2021/05/29 23:14:45

先程と違いCalcSpecだけが実行されたのがわかります。

例外をテストする

例外のテストを行なう場合もshould.Matchersで定義されているshould be thrownByを用います。
例えば先程のplusメソッドは正の数だけを受け取ることを想定しているメソッドで負の数を受け取った場合はIllegalArgumentExceptionを投げることを想定しているとします。
CalcSpecに以下のテストを追加します。

  it should "負の数が引数に渡された場合にIllegalArgumentException" in {
    val c = new Calc
    a[IllegalArgumentException] should be thrownBy {
      c.plus(1, -2)
    }
  }

このテストを実行すると以下の結果になります。

> testOnly CalcSpec
[info] CalcSpec:
[info] - should 足し算の結果を返す
[info] - should 負の数が引数に渡された場合 *** FAILED ***
[info]   Expected exception java.lang.IllegalArgumentException to be thrown, but no exception was thrown (CalcSpec.scala:13)
[info] Run completed in 118 milliseconds.
[info] Total number of tests run: 2
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 1, canceled 0, ignored 0, pending 0
[info] *** 1 TEST FAILED ***
[error] Failed tests:
[error]         CalcSpec
[error] (Test / testOnly) sbt.TestsFailedException: Tests unsuccessful
[error] Total time: 0 s, completed 2021/05/29 23:22:03

まだ実装をしてないので失敗しますね。
それでは実装を行ってもう一度テストを実行します。
実装を以下のように修正します。

class Calc {
  def plus(a: Int, b: Int): Int = {
    require(a > 0 && b > 0)
    a + b
  }
}

Scalaの引数チェックはrequireメソッドを用いて行なうことができるようです。
こいつは条件に合致しない場合、IllegalArgumentExceptionを投げます。 テストを実行します。  

> testOnly CalcSpec
[info] CalcSpec:
[info] - should 足し算の結果を返す
[info] - should 負の数が引数に渡された場合にIllegalArgumentException
[info] Run completed in 123 milliseconds.
[info] Total number of tests run: 2
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 2, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 0 s, completed 2021/05/29 23:29:27

実行が成功しましたね。

ScalaMockでモックする

次はScalaMockを使ってモックを行ってみようと思います。
例えば以下のようなトレイトとクラスがあったとします。

trait Language {
  def greeting(): String = "Hello!!"
}

class Person(val lang: Language) {
  def saySomeThing(): String = lang.greeting()
}

class Japanese extends Language {
  override def greeting(): String = "こんにちは"
}

このPersonクラスのsaySomeThingメソッドをテストし、Languageトレイトをモックするとします。
テストは以下のように記述します。

class PersonSpec extends AnyFlatSpec with should.Matchers with MockFactory {

  it should "Languageをモックする" in {
    val mockLang = mock[Language]
    (mockLang.greeting _).expects().returning("Hello, ScalaMock!!").once()

    val target = new Person(mockLang)
    target.saySomeThing() should be ("Hello, ScalaMock!!")
  }
}

MockFactoryをミクスインすることで、ScalaMockの構文を利用することができます。
まずは、val mockLang = mock[Language]のところでMockオブジェクトを作成します。
次に、(mockLang.greeting _).expects().returning("Hello, ScalaMock!!").once()のところで、モックの設定をしてます。
書いてあるとおりですがreturningでモックのが返す値を モックが引数を期待する場合はexpects()の引数に渡すようです。
例えばなんでも良いがモックが1つの引数を期待する場合はexpects(*)とかけば良いようです。
そして最後に、once()ですがこのモックが1回実行されることを確認しています。

このテストを実行すると以下のような結果になります。

> testOnly PersonSpec
[info] PersonSpec:
[info] - should Languageをモックする
[info] Run completed in 132 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.
[success] Total time: 0 s, completed 2021/05/30 1:08:24

非同期でテストする

ScalaTestとScalaMockを非同期にするのは簡単でAsyncFlatSpecAsyncMockFactoryを利用するだけです。
先程のPersonTestのテストを非同期で行なう用に書き換えます。

import org.scalamock.scalatest.AsyncMockFactory
import org.scalatest.flatspec.AsyncFlatSpec
import org.scalatest.matchers.should

class PersonSpec extends AsyncFlatSpec with should.Matchers with AsyncMockFactory {

  it should "Languageをモックする1" in {
    val mockLang = mock[Language]
    (mockLang.greeting _).expects().returning("Hello, ScalaMock!").once()

    val target = new Person(mockLang)
    target.saySomeThing() should be("Hello, ScalaMock!")
  }


  it should "Languageをモックする2" in {
    val mockLang = mock[Language]
    (mockLang.greeting _).expects().returning("Hello, ScalaMock!!").once()

    val target = new Person(mockLang)
    target.saySomeThing() should be("Hello, ScalaMock!!")
  }
}

AnyFlatSpecMockFactoryを単純に置き換えただけです。
AnyFlatSpecに対するAsyncFlatSpecの用にスタイルごとに非同期用のものが用意されているようです。

実行すると以下のような結果になります。

> testOnly PersonSpec
[info] PersonSpec:
[info] - should Languageをモックする1
[info] - should Languageをモックする2
[info] Run completed in 151 milliseconds.
[info] Total number of tests run: 2
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 2, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 0 s, completed 2021/05/30 1:10:53