はじめに
Webcrewアドベントカレンダー5日目の記事です。
2019年度新卒の@verdoyantが5日目を担当します。
テストコードを学んでいこうと思い、ScalaTestについて調べたので簡単にまとめました。
対象
テストを書き始めてみようかなと思っている人向け
ScalaTest 概要
- Scalaでは一番使用されている
- 使いやすく拡張しやすい
環境
sbt 1.3.4
scala 2.13.1
PlayFramework 2.7.3
導入方法(PlayFramework 2.7.3 + sbt)
build.sbt に以下を追記
libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "4.0.3" % "test" //PlayFrameworkを使う場合
libraryDependencies ++= Seq(//PlayFrameworkを使わない場合
"org.scalactic" %% "scalactic" % "3.0.8",
"org.scalatest" %% "scalatest" % "3.0.8" % "test"
)
テスト例
test ディレクトリ以下に下記ファイルを作成
import org.scalatest._
import org.scalatestplus.play._
class SetSuite extends FunSuite {
test("An empty Set should have size 0") {
assert(Set.empty.size == 0)
}
test("Invoking head on an empty Set should produce NoSuchElementException") {
assertThrows[NoSuchElementException] {
Set.empty.head
}
}
}
プロジェクトのルートディレクトリでsbt test後
app(src/main)ディレクトリ下とtestディレクトリ下をコンパイル後にテストが行われます。
するとこんな感じの表示が出ると思います。出なかったら多分logの設定がおかしいと思われます。
[info] SetSuite:
[info] - An empty Set should have size 0
[info] - Invoking head on an empty Set should produce NoSuchElementException
[info] ScalaTest
[info] Run completed in 248 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.
[info] Passed: Total 2, Failed 0, Errors 0, Passed 2
結果を見るとテストが2つ実行されて、2つ通ったという状態です。
assertが失敗するとfailedにカウントされ、sbt.TestsFailedException: Tests unsuccessful
というerrorを返します。
assertThrowsを使うテストはSuites: completedにカウントされないということも注意が必要です。
cancelledやignoredなどは特定の書き方をしないと出てこないので、基本的にはPassedとFailedを見ていればいいです。
テストコードの書き方
記法
FunSuiteをextendsするとtest(""){ assert()}
という記法になるのですが、FlatSpecやWordSpecをextendsすると別の書き方ができます。
PlaySpec, WordSpecやFlatSpecであれば、一つのメソッドを別の条件でテストするというのが分かりやすく書けるので基本的にこれらを使うのが個人的には良いと思います。
ちなみにWordSpecで使える機能はPlaySpecでも使えます。(PlaySpecはWordSpecをextendsしたもの)
参考 Selecting testing styles for your project
テストケースの書き方
テストケースはassert(Hoge.hode() == 1)
のようにassert文の中に条件文を書いていく形になります。
PlaySpecを使っている場合Hoge.hode() mustBe 1
のようにも書けます。
class SetSuite extends PlaySpec {
"An empty Set should have size 0" in {
Set.empty.size mustBe 0
}
"Invoking head on an empty Set should produce NoSuchElementException" in {
a [NoSuchElementException] must be thrownBy {
Set.empty.head
}
}
}
must
とassert
はどちらも同じ値であるか見る以外にも型が等しいかやコンパイル可能かどうかなども見ることができます。
"TypeTest" in{
1 mustBe a [Int]
}
assertを使う書き方はリンク参照 Using assertions
mustを使う書き方はリンク参照 MustMatchers
テスト実行方法/オプション
テストタスク
- testOnly
テストはsbt testで実行できますが、あるテストだけ実行したいときはsbt testOnly SetSuiteのように特定のテストクラスを指定することができます。
testOnlyタスクにはワイルドカードも使えるのでSetSuite01, SetSuite02, SeqSuite01がある場合に
sbt testOnly SetSuite*でSetSuite01, SetSuite02だけを実行できます。
- testQuick
sbt testQuickで前回失敗したり、実行されていなかったテストのみを実行できます。
オプション引数
testOnly
は引数付きで実行でき、build.sbtに以下のように記述することで全てのテストが引数付きで実行されます。
testOptions in Test += Tests.Argument(TestFrameworks.ScalaTest, "-f", output.txt")
-
- z
testOnly TestClass -- -z "TestCase"でTestClass内の"TestCase"を含むテストのみ行うことができます。
- -f filename
テスト結果をfilenameに書き出してくれます。
そのほかにも多くのオプションがあり、以下を参照
Using the Runner
mock
実際に作成したクラスなどテストをするうえで、機能としては使わなくともオブジェクトを用意しなければならないことが多々あります。
その際にmockというものを使うとわざわざインスタンスを作成しなくても、偽装したインスタンスを作成して引数で渡すことでテストの依存する部分が減らせるという利点があります。
mockを作成できるライブラリはいくつかあるのですが、今回はspecs2 + mockitoを使用してみます。
build.sbtに以下を追記
libraryDependencies ++= specs2 % Test //PlayFrameworkの場合
libraryDependencies ++= Seq( //PlayFrameworkなしの場合
"org.specs2" %% "specs2-core" % "4.8.1" % "test",
"org.specs2" %% "specs2-mock" % "4.8.1" % "test"
)
app(main/scala)以下にテスト対象のクラスを含んだファイルを作成します。
package main
case class Data(date:java.util.Date)
class Controller(data:Data) {
def getMessage(message:String):String = message+"Controlled"
}
このControllerをテストするためにはDataを引数で渡す必要があります。
しかしgetMessageメソッドには使われていません。
そこでmock[Data]でDataを偽装してControllerに渡してしまいましょう。
testディレクトリ以下に次のテストファイルを作成します。
import org.scalatest._
import org.scalatestplus.play._
import org.specs2.mock.Mockito
import main._
class ControllerSpec extends PlaySpec with Mockito {
"Controllerのテスト" in {
val expected = "Test"
val controller = new Controller(mock[Data]) {
override def getMessage(message:String): String = message + "Controlled"
}
controller.getMessage(expected) must include (expected)
}
}
これを実行するとテストが通ったことを確認できると思います。
mockを使わない場合、このテストケースでは関係のないDataオブジェクトを作成しなくてはなりませんが
mock[Data]を作ることでテストケースに関係ないものを無視できました。
この例だと分かりずらいのですが、他のモジュール、例えばデータベースやAPIに依存するものをテストする際には非常に有効で
APIやデータベースがちゃんと動いているかという懸念点を取り除くことができ、mockによって純粋な機能テストになるという点でmockは活用されています。
Coverage
テストについて学んでいるとコードカバレッジについて目にしたこともあると思います。
ScalaTestを使ってコードカバレッジのレポートを出力するにはproject/plugins.sbtに以下を追記します。
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.6.1")
そしてsbt clean coverage testを実行して、次にsbt coverageReportを実行します。
そうするとtarget/scala2.13/scoverage-report以下にhtmlファイルが作成されており、index.htmlを開くとCoverage %などを見ることができます。
では先ほど作ったControllerのテストはどうだったか見てみましょう。
出来てないですね。先ほど書いたControllerSpecを直しましょう。
class ControllerSpec extends PlaySpec with Mockito {
"Controllerのテスト" in {
val expected = "Test"
val controller = new Controller(mock[Data])
controller.getMessage(expected) must include (expected)
}
}
テストに使うメソッド自体をoverrideしてしまうとcoverage%が下がり、今回の場合だと意味がないので変更しました。
そしてsbt clean coverage testを実行して、次にsbt coverageReportを実行してindex.htmlを開くと
Coverage 100%になりました。良かったですね。
他にもこのレポートは様々な情報があるので実際にScalaTestを動かしてつつ学んでみてください。
まとめ
ScalaTestを使ってテストをする方法をまとめました。
文中にもあるようにPlaySpecはWordSpecをextendsして機能追加したものなのですが、同じように自分用の独自Specを作成することも出来て拡張性があったりします。
上に書いたこと以外にもDIコンテナのGuiceを使うと、よりテストのしやすい依存性の少ないコードが書けるので、もし興味を持ったならばGuiceやDIについて学んでみるのを是非お勧めします。
Webcrewアドベントカレンダー6日目担当は@k5jpWCさんです!
心理学関係について書いてくれるそうです。
参考文献
-
ScalaTest
ScalaTestの公式、全部英語ですが… -
sbt
sbtのテストのdocument,テストの設定など詳しい。 -
Scala研修テキスト
dwangoの新卒エンジニア向けテキスト。とても分かりやすい。mockの例はこっちのほうがずっとわかりやすい。 -
PlayFrameworkのdocument
PlayFrameworkのScalaTestのdocument。PlayFrameworkのコントローラやモデルなどのテストでの扱い方など詳しい。
注意点
Windows環境で日本語を表示するとterminalの文字コード制約により文字化けが起きてしまいます。
javaの起動オプションに-Dfile.encoding=MS932
を渡すと解消するかもしれないです。