Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Scalaでテストにタグ付けして、特定のタグのテストだけ実行したりしなかったりする

Scalaのsbtなどでテストを実行していて、特定のテストだけ実行したりしなかったり、ということをやりたい、という状況は多い。その方法を解説する。

ここではテストフレームワークとしてScalaTestを利用する。

この記事のコードの完全版は以下のリポジトリで閲覧できる:

github.com

復習: Scalaでのテスト

ScalaTestでテストを実行するには、まずbuild.sbtにScalaTestへの依存性を追加する:

val scala3Version = "3.8.2"

lazy val root = project
  .in(file("."))
  .settings(
    name := "scalatest-tagging-exercise",
    version := "0.1.0-SNAPSHOT",
    scalaVersion := scala3Version,
    libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.19", // added
    libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % Test // added
  )

ここではテストスタイルとしてFunSuite styleを利用する。

import org.scalatest.funsuite.AnyFunSuite

class MySuite extends AnyFunSuite {
  test("example test") {
    assert(1 + 1 == 2)
  }
  test("another example test") {
    assert(2 + 2 == 4)
  }
  test("heavy test") {
    assert(3 + 3 == 6)
  }
}

これをsrc/test/scala/MySuite.scalaとして保存して、sbt testを実行するとテストが実行される:

[info] MySuite:
[info] - example test
[info] - another example test
[info] - heavy test
[info] Run completed in 219 milliseconds.
[info] Total number of tests run: 3
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 3, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 5 s, completed 2026/03/01 0:47:34

さて、なぜsbt testでテストが実行できるのだろうか?まずはそこを考えておきたい。

  • テストファイルをコンパイルするのはsbt(から呼び出されたscalac)の責務である。
  • 具体的にテストファイルを実行して結果を返すのはsbtではなくテストフレームワーク(ここではScalaTest)の責務である。
  • sbtはsbt/test-interfaceというインターフェイスを定義しており、sbtに対応しているフレームワークはすべてこれを実装している。
  • sbtはsbt/test-interfaceを実装しているクラスを発見し、ユーザがテストを要求したときに規定の方法でそれを実行する。
  • このため、sbtはtestだとかtestOnlyといった共通のインターフェイスを介してユーザにテスト機能を提供できる。

www.scala-sbt.org

ここまで理解できれば、特定のタグがついたテストを実行するにはどうするか、分かりやすい。

テストにタグをつける

ユーザによってテストにつけられたタグをタグとして認識するのは、テストフレームワークの責務である。つまり、sbtもScalaコンパイラも、ユーザが設置したタグについて関知しない。テストのタグ付けはテストフレームワークのいち機能に過ぎない。

タグの定義

ScalaTestは、テストにタグを付ける機能を提供している。まずはタグを定義しよう。

まず、javaファイルとしてアノテーションも定義する:

package dev.capslock.scalatesttaggingexercise.tags;

import java.lang.annotation.*;
import org.scalatest.TagAnnotation;

@TagAnnotation
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD, ElementType.TYPE })
public @interface Heavy {
}

こちらはsrc/test/java/dev/capslock/scalatesttaggingexercise/tags/Heavy.javaに配置する。

次に、Scalaのobjectを定義する:

package dev.capslock.scalatesttaggingexercise.tagobjects

import org.scalatest.Tag

object Heavy extends Tag("dev.capslock.scalatesttaggingexercise.tags.Heavy")

これをsrc/test/scala/dev/capslock/scalatesttaggingexercise/tagobjects/Tags.scalaという名前でテストファイルから読める場所に保存しておく。

Javaのアノテーションはファイル名と名前が揃っている必要がある。また、objectに渡す文字列はJavaのアノテーションを完全修飾名で正確に指し示していなければならない。

www.scalatest.org

タグをつける

次に、テストにタグをつける。タグを付ける方法はテストスタイルによって異なる。FunSuite styleでは、testの第二引数にタグを渡す。これはvarargsなのでいくらでも渡してよい:

import org.scalatest.funsuite.AnyFunSuite
import dev.capslock.scalatesttaggingexercise.tagobjects.Heavy
import org.scalatest.tagobjects.Slow
import org.scalatest.tagobjects.CPU

class MySuite extends AnyFunSuite {
  test("example test", CPU) { // Added!
    assert(1 + 1 == 2)
  }
  test("another example test", Slow) { // Added!
    assert(2 + 2 == 4)
  }
  test("heavy test", Heavy, Slow) { // Added!
    assert(3 + 3 == 6)
  }
}

また、アノテーションを定義している場合はテストスイート単位でタギングできる:

import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.tags.Slow

// Added!
@Slow class SlowSuite extends AnyFunSuite {
  test("very slow test") {
    assert(1 + 1 == 2)
  }
  test("another very slow test") {
    assert(2 + 2 == 4)
  }
  test("heavy test") {
    assert(3 + 3 == 6)
  }
}

ScalaTestは標準でorg.scalatest.tags/org.scalatest.tagobjectsを提供してくれるが、これは前者がアノテーションを置いている場所で、後者が個々のテストに渡すオブジェクトを置いている場所だ。ユーザがカスタムでタグを用意する場合も、この規約に則ってtagstagobjectsを分けるとよさそう。

さて、これでテストとタグが関連づけられた。

ちなみに、タグのつけかたはstyleを定義しているtraitのScaladocのtagging testsのコーナーに記載されている。たとえば、FunSuite styleの場合はorg.scalatest.funsuite.AnyFunSuiteのscaladocを読むと:

www.scalatest.org

みっちりと使い方が書いてある。Scalaの場合、まともなライブラリにはたいていちゃんとしたScaladocがついてくるので、とりあえず読んでみることをおすすめする。

実行するテストを選別する

さて、タグがいくら付いていようと、それがついたテストをうまいこと捌くのはテストランナーの責務だ。ScalaTestでは、org.scalatest.tools.Runnerがこれを引き受けている。

www.scalatest.org

そして、タグによるテストの選別はここで定義されているため、我々はここに引数を渡せばよい。

-n specifies a tag to include (Note: only one tag name allowed per -n) -n UnitTests -n FastTests

-l specifies a tag to exclude (Note: only one tag name allowed per -l) -l SlowTests -l PerfTests

また、sbtの機能として、testOptionsを指定する、もしくはsbt testOnly -- ...で フラグを渡すことでそのままランナーにフラグを渡せるようになっている。

www.scalatest.org

sbt:scalatest-tagging-exercise> testOnly -- -n "dev.capslock.scalatesttaggingexercise.tags.Heavy"
[info] MySuite:
[info] - heavy test
[info] Run completed in 67 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 2026/03/01 1:29:35
sbt:scalatest-tagging-exercise> testOnly -- -n "org.scalatest.tags.Slow"
[info] MySuite:
[info] - another example test
[info] - heavy test
[info] Run completed in 72 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 2026/03/01 1:32:08

-nフラグで特定のタグがついたテストを実行できることがわかった。タグは、完全修飾名で正確にタグアノテーションを指定する必要がある(ここでは、dev.capslock.scalatesttaggingexercise.tagobjects.Heavyではなく、dev.capslock.scalatesttaggingexercise.tags.Heavyを書く、ということ)特定のタグがついたテストを除外するには、-lを利用する。また、ここでScalaTestに最初からあるタグを指定するには、org.scalatest.tagsを利用する。

www.scalatest.org

複数のタグを指定するには"Foo Bar"のように二重引用符で括るか、-n Foo -n Barのように多重に定義すればよい。

カスタムタスクを作成する

ここまで来れば、sbtで便利にテストを定義できるところまでもう少しだ。build.sbtをいじって、カスタムタスクを定義しよう:

lazy val testOnlyHeavy = taskKey[Unit]("Run only the heavy tests")
testOnlyHeavy := (Test / testOnly)
  .toTask(
    " -- -n dev.capslock.scalatesttaggingexercise.tags.Heavy" // DO NOT FORGET TO INSERT INITIAL SPACE!!!
  )
  .value

lazy val testOnlySlow = taskKey[Unit]("Run only the slow tests")
testOnlySlow := (Test / testOnly)
  .toTask(
    " -- -n org.scalatest.tags.Slow"
  )
  .value

最初のlazy valでは、testOnlyHeavy/testOnlySlowという名前のタスクがあることを宣言する。ここでは名前だけ定義しているというわけ。

次に中身を定義している。:=の右辺でそのタスクが返すはずの処理を組み立ててやればよい。ただし直接計算するのではなく、他のタスクから組み立てたりする。ここではTest / testOnlyを再利用する。

toTask は「CLIから渡されるはずだった引数文字列を、コンパイル時に埋め込む」操作だと思ってもらったらいい。元々のtestOnlyInputTask[Unit]であり、ユーザ入力を受け取ることを想定したタスクだ。これ単体では実行結果にすることができない。なんと、toTaskInputTaskに引数を渡すことでTaskに変形できる。Taskはもう値を取り出せる形になっているから、.valuetestOnlyHeavyの結果として値を割り当てて完成だ。

sbtのタスクについてもそのうち書きたいね。

実行

あとはタスクを実行するだけだ。

sbt:scalatest-tagging-exercise> testOnlyHeavy
[info] SlowSuite:
[info] MySuite:
[info] - heavy test
[info] Run completed in 66 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 2, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 0 s, completed 2026/03/01 2:05:45
sbt:scalatest-tagging-exercise> testOnlySlow
[info] SlowSuite:
[info] - very slow test
[info] - another very slow test
[info] - heavy test
[info] MySuite:
[info] - another example test
[info] - heavy test
[info] Run completed in 76 milliseconds.
[info] Total number of tests run: 5
[info] Suites: completed 2, aborted 0
[info] Tests: succeeded 5, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 0 s, completed 2026/03/01 2:05:47

やったね。

まとめ

  • sbtは、あらかじめ定められたインターフェイスに従ってテストツールのランナーを呼び出す。
  • ScalaTestはこの規約を定義しており、呼び出されることができる。
  • ScalaTestはテストに対してタグを定義する能力を有しており、ランナーに-n-lといったフラグを渡すことで実行するテストを制御できる。
  • ScalaTestでは、タグはJavaアノテーションとScala上のオブジェクトとして2つ同時に定義する必要がある*1
  • sbtのカスタムタスクを定義することで、特定のタグがついたテストを実行する専用のタスクを定義できる。

*1:厳密にはそうではないのだが、そのほうが使いやすくて都合がよい

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?