ScalaTest: FlatSpec vs FunSuite vs WordSpec, Matchers, Fixtures, and Parallel Execution
ScalaTest is the most widely used testing framework for Scala, offering multiple spec styles to match different team preferences. This post covers when to use FlatSpec, FunSuite, and WordSpec, how to write expressive matchers, manage shared fixtures, tag tests for selective runs, and speed things up with parallel execution.
ScalaTest has been the backbone of Scala testing for over a decade. Its flexibility is both a strength and a source of confusion: there are at least seven built-in spec styles, and picking the wrong one for your team or project leads to inconsistent test suites that are harder to read than they need to be. This guide focuses on the three styles you'll encounter most often, then covers the advanced features that separate a mediocre test suite from a great one.
Choosing Your Spec Style
FunSuite — the minimal baseline
FunSuite is the closest to JUnit or Python's unittest. Each test is a named block. There's no nesting, no BDD ceremony.
import org.scalatest.funsuite.AnyFunSuite
class CalculatorSuite extends AnyFunSuite {
test("addition returns the sum of two integers") {
val calc = new Calculator
assert(calc.add(2, 3) == 5)
}
test("division by zero throws ArithmeticException") {
val calc = new Calculator
assertThrows[ArithmeticException] {
calc.divide(10, 0)
}
}
}Use FunSuite when your team comes from a JUnit background, when you're writing utility or library tests without behaviour specifications, or when test names are already descriptive enough without nesting.
FlatSpec — the most popular choice
FlatSpec enforces a two-level structure: a subject noun and a behaviour verb. Tests read like "A Calculator" should "add two numbers". The flat structure prevents the deep nesting that plagues some BDD frameworks while still being self-documenting.
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class CalculatorSpec extends AnyFlatSpec with Matchers {
val calculator = new Calculator
"A Calculator" should "return the correct sum for positive integers" in {
calculator.add(4, 6) should equal(10)
}
it should "return a negative result when the sum is negative" in {
calculator.add(-7, 3) should be(-4)
}
it should "handle zero correctly" in {
calculator.add(0, 0) should be(0)
}
"A Calculator" should "throw ArithmeticException on division by zero" in {
a[ArithmeticException] should be thrownBy {
calculator.divide(5, 0)
}
}
}The it should shorthand keeps tests DRY when testing multiple behaviours of the same subject. FlatSpec is the default recommendation for most Scala projects.
WordSpec — the BDD deep-dive
WordSpec gives you describe/when/should/in nesting for complex domain logic. It shines in acceptance-style tests where the hierarchy directly maps to user stories.
import org.scalatest.wordspec.AnyWordSpec
import org.scalatest.matchers.should.Matchers
class UserRegistrationSpec extends AnyWordSpec with Matchers {
"UserRegistration" when {
"the email is valid" should {
"create a new user account" in {
val service = new UserRegistrationService
val result = service.register("alice@example.com", "s3cr3t!")
result.isRight should be(true)
result.toOption.get.email should equal("alice@example.com")
}
"send a confirmation email" in {
val emailSpy = new FakeEmailService
val service = new UserRegistrationService(emailSpy)
service.register("bob@example.com", "p@ssword")
emailSpy.sentEmails should contain("bob@example.com")
}
}
"the email is already taken" should {
"return a DuplicateEmailError" in {
val service = new UserRegistrationService
service.register("carol@example.com", "pw1")
val result = service.register("carol@example.com", "pw2")
result.isLeft should be(true)
result.left.toOption.get shouldBe a[DuplicateEmailError]
}
}
}
}Avoid WordSpec if your team doesn't need the extra BDD vocabulary or if nesting gets deeper than three levels. Deep nesting is a smell that you're testing too much in one spec.
Matchers: Writing Expressive Assertions
ScalaTest's matchers are where most of the expressiveness lives. Mix in org.scalatest.matchers.should.Matchers or org.scalatest.matchers.must.Matchers depending on your preferred keyword.
Equality and identity
result should equal(42) // structural equality (==)
result should be(42) // same as equal for primitives
result shouldBe 42 // shorthand, no parens
result should be theSameInstanceAs expected // reference equalityCollection matchers
val names = List("Alice", "Bob", "Carol")
names should have size 3
names should contain("Bob")
names should contain allOf("Alice", "Carol")
names should not contain "Dave"
names shouldBe sorted // requires Ordering[T]
val map = Map("a" -> 1, "b" -> 2)
map should contain key "a"
map should contain value 2
map("a") should be(1)String matchers
val message = "Hello, ScalaTest!"
message should startWith("Hello")
message should endWith("!")
message should include("ScalaTest")
message should fullyMatch regex """Hello, \w+!"""Numeric matchers
3.14 should be(3.14 +- 0.01) // tolerance for Double comparison
result should be > 0
result should be <= 100Exception matchers
// Assert exception type
an[IllegalArgumentException] should be thrownBy {
parseAge(-1)
}
// Inspect the exception
val ex = the[IllegalArgumentException] thrownBy parseAge(-1)
ex.getMessage should include("negative")Fixtures: Managing Shared State
beforeEach and afterEach
The simplest approach for mutable shared state:
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.{BeforeAndAfterEach, BeforeAndAfterAll}
class DatabaseSpec extends AnyFlatSpec
with Matchers
with BeforeAndAfterEach
with BeforeAndAfterAll {
var db: TestDatabase = _
override def beforeAll(): Unit = {
db = TestDatabase.startEmbedded()
}
override def beforeEach(): Unit = {
db.clearAll()
}
override def afterAll(): Unit = {
db.shutdown()
}
"UserRepository" should "save and retrieve a user" in {
val repo = new UserRepository(db)
repo.save(User("alice", "alice@example.com"))
repo.findByEmail("alice@example.com").isDefined should be(true)
}
}Loan-fixture pattern
Prefer the loan pattern when fixtures have lifecycle concerns or when you want to avoid mutable vars:
class OrderServiceSpec extends AnyFlatSpec with Matchers {
def withOrderService(test: OrderService => Unit): Unit = {
val db = TestDatabase.startEmbedded()
val service = new OrderService(db)
try {
test(service)
} finally {
db.shutdown()
}
}
"OrderService" should "create an order with the correct total" in
withOrderService { service =>
val order = service.createOrder(
items = List(Item("widget", 9.99), Item("gadget", 14.99)),
customerId = "cust-123"
)
order.total should be(24.98 +- 0.001)
}
}fixture.FlatSpec — typed fixtures
For more structured fixture sharing, use the fixture sub-package:
import org.scalatest.flatspec
import org.scalatest.matchers.should.Matchers
class PaymentSpec extends flatspec.FixtureAnyFlatSpec with Matchers {
case class FixtureParam(gateway: FakePaymentGateway, service: PaymentService)
def withFixture(test: OneArgTest): Outcome = {
val gateway = new FakePaymentGateway
val service = new PaymentService(gateway)
withFixture(test.toNoArgTest(FixtureParam(gateway, service)))
}
"PaymentService" should "charge the correct amount" in { f =>
f.service.charge("tok_visa", 5000)
f.gateway.charges should contain(5000)
}
it should "record a failed charge on gateway error" in { f =>
f.gateway.simulateFailure()
val result = f.service.charge("tok_declined", 1000)
result.isLeft should be(true)
}
}Tagging Tests for Selective Runs
Tags let you categorise tests and run subsets from the command line or CI.
import org.scalatest.Tag
object Slow extends Tag("com.example.tags.Slow")
object Integration extends Tag("com.example.tags.Integration")
object Smoke extends Tag("com.example.tags.Smoke")
class UserApiSpec extends AnyFlatSpec with Matchers {
"User API" should "return 200 for a valid token" taggedAs Smoke in {
// fast, always run
}
it should "handle 10,000 concurrent requests" taggedAs Slow in {
// skip in normal CI
}
it should "integrate with the real database" taggedAs Integration in {
// only in integration stage
}
}Run only smoke tests with sbt:
sbt "testOnly * -- -n com.example.tags.Smoke"
sbt "testOnly * -- -l com.example.tags.Slow" # exclude slowConfigure tag exclusions in build.sbt for your CI pipelines:
Test / testOptions += Tests.Argument(
TestFrameworks.ScalaTest,
"-l", "com.example.tags.Slow",
"-l", "com.example.tags.Integration"
)Parallel Execution
ScalaTest can run tests in parallel at two levels: between suites and within a single suite.
Parallel suites (the default when enabled)
// build.sbt
Test / parallelExecution := trueThis runs different test classes concurrently. Ensure each suite is fully independent — no shared mutable singletons, no port conflicts.
Parallel tests within a suite
Mix in ParallelTestExecution to run tests inside a single class in parallel:
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.ParallelTestExecution
class MathFunctionsSpec extends AnyFlatSpec
with Matchers
with ParallelTestExecution {
"sqrt" should "return 2.0 for input 4" in {
Math.sqrt(4.0) should be(2.0)
}
it should "return 3.0 for input 9" in {
Math.sqrt(9.0) should be(3.0)
}
// These run concurrently — no shared state allowed
}Warning: ParallelTestExecution is only safe when tests do not share mutable state. If you use beforeEach/afterEach to reset a shared variable, parallel execution will cause flakiness. Prefer the loan pattern in these cases.
Controlling thread count
// build.sbt
Global / concurrentRestrictions := Seq(
Tags.limit(Tags.ForkedTestProcess, 1),
Tags.limitAll(4) // up to 4 concurrent test tasks
)Practical Configuration Snippet
A solid build.sbt test configuration for a typical Scala project:
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % "3.2.18" % Test,
"org.scalatest" %% "scalatest-flatspec" % "3.2.18" % Test,
)
Test / testOptions += Tests.Argument(
TestFrameworks.ScalaTest,
"-oD", // show durations
"-l", "com.example.tags.Slow" // exclude slow by default
)
Test / parallelExecution := true
Test / fork := true // isolate JVM state between test runsSummary
- Use FunSuite for utility tests where BDD structure adds no value.
- Use FlatSpec as the default — it's readable without being verbose.
- Use WordSpec for domain-heavy acceptance tests with multi-level hierarchy.
- Write matcher-based assertions — they produce clearer failure messages than raw
assert. - Use the loan pattern for fixtures with lifecycle concerns;
BeforeAndAfterEachfor simpler cases. - Tag tests and configure CI to exclude slow or integration tests from fast-feedback loops.
- Enable parallel execution at the suite level first; add
ParallelTestExecutiononly when tests are stateless.
ScalaTest's flexibility is a feature when the team has agreed on conventions. Pick one style per module, document it, and enforce it in code review. Consistency matters more than which style you choose.