ScalaTest: FlatSpec vs FunSuite vs WordSpec, Matchers, Fixtures, and Parallel Execution

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 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 equality

Collection 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 <= 100

Exception 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 slow

Configure 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 := true

This 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 runs

Summary

  • 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; BeforeAndAfterEach for 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 ParallelTestExecution only 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.

Start now free