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">testCustom 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").valueNow 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 verificationTest 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 coverageAggregateReports 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.sqlAccess in tests:
val config = ConfigFactory.load("application-test")
val testData = Source.fromResource("test-data.json").mkStringFiltering 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 testQuickThis 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/SparkparallelExecution := true— run suites in parallel- Integration test config —
src/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 tasks —
unitTest,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.