sbt Test Configuration: Managing Tests in Scala Projects

sbt Test Configuration: Managing Tests in Scala Projects

sbt is Scala's primary build tool, and understanding its test system is essential for managing a well-organized test suite. From running specific tests to configuring parallel execution and separating unit from integration tests, sbt's test configuration has more depth than most developers use.

Basic Test Commands

# Run all tests
sbt <span class="hljs-built_in">test

<span class="hljs-comment"># Watch mode — re-run tests on file changes
sbt ~<span class="hljs-built_in">test

<span class="hljs-comment"># Run tests in a specific class
sbt <span class="hljs-string">"testOnly com.myapp.UserServiceSpec"

<span class="hljs-comment"># Run tests matching a pattern
sbt <span class="hljs-string">"testOnly *ServiceSpec"
sbt <span class="hljs-string">"testOnly com.myapp.*"

<span class="hljs-comment"># Run tests that failed in the last run
sbt testQuick

<span class="hljs-comment"># Run a specific test method (ScalaTest)
sbt <span class="hljs-string">"testOnly com.myapp.UserServiceSpec -- -t 'creates a user'"

Test Configuration in build.sbt

// Test dependencies only compile and run in the test scope
libraryDependencies ++= Seq(
  "org.scalatest" %% "scalatest" % "3.2.18" % Test,
  "org.mockito" %% "mockito-scala" % "1.17.37" % Test
)

// Fork a separate JVM for tests (recommended for Spark, Play)
Test / fork := true

// Run test suites in parallel (different files in parallel)
Test / parallelExecution := true

// Pass JVM options to the forked test JVM
Test / javaOptions ++= Seq(
  "-Xmx2G",
  "-XX:+UseG1GC"
)

// Set environment variables for tests
Test / envVars := Map(
  "APP_ENV" -> "test",
  "DB_URL" -> "jdbc:h2:mem:testdb"
)

Separating Unit and Integration Tests

The most common organizational pattern is to separate fast unit tests from slower integration tests using sbt's configuration system:

// build.sbt
lazy val root = (project in file("."))
  .configs(IntegrationTest)
  .settings(
    Defaults.itSettings,
    libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.18" % "it,test"
  )

Place integration tests in src/it/scala/ (alongside the standard src/test/scala/):

src/
  main/scala/         # Production code
  test/scala/         # Unit tests (fast)
  it/scala/           # Integration tests (slow)

Run them separately:

# Unit tests only (fast, run on every commit)
sbt <span class="hljs-built_in">test

<span class="hljs-comment"># Integration tests only
sbt it:<span class="hljs-built_in">test

<span class="hljs-comment"># Both
sbt <span class="hljs-built_in">test it:<span class="hljs-built_in">test

Custom Test Tags for Fine-Grained Control

Another approach uses tags instead of separate source sets:

// In your test code
import org.scalatest.Tag

object SlowTest extends Tag("com.myapp.SlowTest")
object DatabaseTest extends Tag("com.myapp.DatabaseTest")
object ExternalAPI extends Tag("com.myapp.ExternalAPI")

class SomeSpec extends AnyFlatSpec {
  "slow operation" should "process data" taggedAs SlowTest in { ... }
  "db write" should "persist user" taggedAs DatabaseTest in { ... }
}

Configure exclusions in build.sbt:

// Exclude slow and external tests by default
Test / testOptions += Tests.Argument(
  TestFrameworks.ScalaTest,
  "-l", "com.myapp.SlowTest",
  "-l", "com.myapp.ExternalAPI"
)

Run including everything:

sbt "test -- -n com.myapp.SlowTest"

Test Framework Configuration

Pass arguments to your test framework through testOptions:

// ScalaTest — show durations, verbose output
Test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-oD")

// ScalaTest — short stack traces
Test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-oS")

// ScalaCheck — more test runs
Test / testOptions += Tests.Argument(
  TestFrameworks.ScalaCheck,
  "-minSuccessfulTests", "200"
)

// JUnit — show detailed output
Test / testOptions += Tests.Argument(TestFrameworks.JUnit, "-v")

Common ScalaTest reporter flags:

  • -oD — show durations
  • -oF — full stack traces
  • -oS — short stack traces
  • -oW — without color

Multiple Test Configurations

Define separate tasks for different test subsets:

lazy val unitTest = taskKey[Unit]("Run unit tests only")
lazy val integrationTest = taskKey[Unit]("Run integration tests")
lazy val smokeTest = taskKey[Unit]("Run smoke tests")

unitTest := (Test / testOnly).toTask(" *Spec -- -l SlowTest -l DatabaseTest").value
integrationTest := (Test / testOnly).toTask(" *IntegrationSpec").value
smokeTest := (Test / testOnly).toTask(" *SmokeSpec").value

Now CI can run:

sbt unitTest          # Fast feedback loop
sbt integrationTest   <span class="hljs-comment"># Pre-merge check
sbt smokeTest         <span class="hljs-comment"># Post-deploy verification

Test Coverage with sbt-scoverage

Add the coverage plugin to project/plugins.sbt:

addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.11")

Generate coverage reports:

# Run tests with coverage instrumentation
sbt coverage <span class="hljs-built_in">test coverageReport

<span class="hljs-comment"># Generate aggregate report for multi-module projects
sbt coverage <span class="hljs-built_in">test coverageAggregate

Reports appear in target/scala-2.13/scoverage-report/index.html.

Configure minimum coverage thresholds:

coverageMinimumStmtTotal := 80
coverageFailOnMinimum := true
coverageExcludedPackages := ".*generated.*;.*routes.*"

Multi-Module Projects

For projects with multiple submodules:

lazy val core = project.in(file("core"))
  .settings(
    libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.18" % Test
  )

lazy val api = project.in(file("api"))
  .dependsOn(core % "compile->compile;test->test")
  // test->test makes core's test utilities available to api's tests
  .settings(
    libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.18" % Test
  )

lazy val root = project.in(file("."))
  .aggregate(core, api)

Run all tests across modules:

sbt test  <span class="hljs-comment"># Tests all modules

<span class="hljs-comment"># Test specific module
sbt <span class="hljs-string">"core/test"
sbt <span class="hljs-string">"api/testOnly *UserSpec"

Test Resource Management

Place test resources in src/test/resources/:

src/test/resources/
  application-test.conf
  test-data.json
  fixtures/
    users.sql

Access in tests:

val config = ConfigFactory.load("application-test")
val testData = Source.fromResource("test-data.json").mkString

Filtering Test Output

Control what shows in CI vs local runs:

// Suppress passing tests in CI (show only failures)
val isCi = sys.env.get("CI").isDefined

Test / testOptions ++= {
  if (isCi) Seq(Tests.Argument(TestFrameworks.ScalaTest, "-oNCXEHLO"))
  else Seq(Tests.Argument(TestFrameworks.ScalaTest, "-oD"))
}

Test Caching

sbt caches test results and skips unchanged tests by default with testQuick:

# Only re-runs tests that failed or whose dependencies changed
sbt testQuick

This is invaluable in large projects — a change to UserService.scala will only re-run specs that depend on UserService.

Generating Test Reports for CI

Many CI systems consume JUnit XML reports:

Test / testOptions += Tests.Argument(
  TestFrameworks.ScalaTest,
  "-u", "target/test-reports"
)

Or with sbt-junit-xml-listener:

// project/plugins.sbt
addSbtPlugin("com.novocode" % "junit-interface" % "0.11")

Integrating with HelpMeTest

sbt handles build-time testing. For continuous post-deploy testing of your Scala application, HelpMeTest monitors your live endpoints and user flows, alerting you when things break in production. Configure it to run smoke tests after every deployment — the complement to your fast local sbt test suite.

Summary

sbt's test system is highly configurable:

  • Test / fork := true — separate JVM, essential for Play/Spark
  • parallelExecution := true — run suites in parallel
  • Integration test configsrc/it/scala/ for slow tests
  • Tags + test options — fine-grained test filtering
  • testQuick — skip tests for unchanged code
  • sbt-scoverage — coverage reports with minimum thresholds
  • Custom tasksunitTest, integrationTest, smokeTest

Investing time in sbt configuration pays off in faster CI cycles, clearer test organization, and a feedback loop that keeps developers moving quickly.

Read more