Technology  /  Scala

๐Ÿ”ด Scala 21 guides ยท updated 2026

The JVM's hybrid OOP/functional language โ€” immutability, pattern matching, and the type system that powers Spark and Akka, from first principles to production.

Testing in Scala: ScalaTest and MUnit Explained With Real Examples

The immutability guide made a concrete claim: pure functions are simpler to test because they need no mocking, no setup, no teardown โ€” just call them and check the result. This guide makes that payoff tangible with two real testing frameworks โ€” ScalaTest (feature-rich, several styles, the long-standing standard) and MUnit (simpler, closer to JUnitโ€™s philosophy, increasingly popular for new projects) โ€” plus the specific patterns for testing Option, Either, and Future-returning code correctly.


Setting Up a Test Dependency

// build.sbt โ€” from the project setup guide, scoped to Test only
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.17" % Test
// or, for MUnit:
libraryDependencies += "org.scalameta" %% "munit" % "1.0.0" % Test
src/test/scala/
โ””โ”€โ”€ com/example/
โ””โ”€โ”€ CalculatorSpec.scala // convention: <ClassName>Spec.scala or <ClassName>Test.scala

Recall from the project setup guide that % Test scopes a dependency to src/test/scala only โ€” it never gets bundled into a production build artifact, exactly like a devDependency in other ecosystems.


ScalaTest โ€” AnyFlatSpec Style

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class CalculatorSpec extends AnyFlatSpec with Matchers {
"Calculator.add" should "return the sum of two positive numbers" in {
Calculator.add(2, 3) shouldBe 5
}
it should "handle negative numbers correctly" in {
Calculator.add(-2, -3) shouldBe -5
}
it should "return the other number when adding zero" in {
Calculator.add(5, 0) shouldBe 5
}
}

ScalaTest supports several distinct testing styles (AnyFlatSpec, AnyFunSuite, AnyWordSpec, and others) โ€” this is a genuine point of confusion for newcomers, since they all test the same things with different syntax conventions. AnyFlatSpecโ€™s "X" should "Y" in { } reads close to a specification sentence (โ€œCalculator.add should return the sumโ€ฆโ€), and it should "..." continues describing the same subject from the line above without repeating it โ€” a readability convention, not a different testing mechanism.

import org.scalatest.matchers.should.Matchers._
result shouldBe 5 // equality
result should be > 3 // comparison matchers
result should not be null // null check (rare in idiomatic Scala, but supported)
list should contain(3) // collection membership
list should have size 3 // collection size

ScalaTestโ€™s matcher DSL (should be, should contain, should have size) reads close to natural English specifically to make test intent self-documenting โ€” this fluent style is one of ScalaTestโ€™s most distinctive, and most debated (some find it overly magical) features.


ScalaTest โ€” AnyFunSuite Style (Closer to JUnit)

import org.scalatest.funsuite.AnyFunSuite
class CalculatorSuite extends AnyFunSuite {
test("add returns the sum of two positive numbers") {
assert(Calculator.add(2, 3) == 5)
}
test("add handles negative numbers correctly") {
assert(Calculator.add(-2, -3) == -5)
}
}

AnyFunSuite uses plain test("description") { ... } blocks with ordinary assert โ€” closer to JUnitโ€™s or MUnitโ€™s style than AnyFlatSpecโ€™s specification-sentence approach. Teams migrating from Java/JUnit backgrounds, or simply preferring less DSL magic, often gravitate toward this style specifically because it reads more directly, at some cost to the โ€œreads like a specificationโ€ readability AnyFlatSpec aims for.


MUnit โ€” A Simpler, More Direct Alternative

class CalculatorSuite extends munit.FunSuite {
test("add returns the sum of two positive numbers") {
assertEquals(Calculator.add(2, 3), 5)
}
test("add handles negative numbers correctly") {
assertEquals(Calculator.add(-2, -3), -5)
}
test("dividing by zero throws an exception") {
intercept[ArithmeticException] {
Calculator.divide(10, 0)
}
}
}

MUnit deliberately has a smaller feature surface than ScalaTest โ€” one style, not several, assertEquals instead of a fluent matcher DSL, and notably better compile times and clearer failure messages (MUnitโ€™s assertEquals shows a structured diff of expected vs actual, which is genuinely more useful than a plain boolean assert failure for complex values). This simplicity is exactly why MUnit has grown popular for new Scala 3 projects specifically โ€” less DSL to learn, faster to compile, and its opinionated single style avoids the โ€œwhich ScalaTest style should our team standardize onโ€ discussion entirely.


Testing Option and Either-Returning Code

class UserServiceSpec extends AnyFlatSpec with Matchers {
"findUser" should "return Some when the user exists" in {
UserService.findUser("1") shouldBe Some("Alice")
}
it should "return None when the user doesn't exist" in {
UserService.findUser("999") shouldBe None
}
}
class ValidationSpec extends AnyFlatSpec with Matchers {
"parseAge" should "return Right for a valid age" in {
parseAge("30") shouldBe Right(30)
}
it should "return Left with a message for a negative age" in {
parseAge("-5") shouldBe Left("Age cannot be negative")
}
}

Because Option and Either are ordinary case-class-like values with structural equality (covered in the case classes guide), testing them is simply comparing the whole wrapped result directly โ€” Some("Alice") and Right(30) compare correctly against the actual return value with no special test-framework support needed, unlike testing exception-based code, which typically needs a dedicated intercept/assertThrows construct.


Testing Future-Returning Code

import org.scalatest.flatspec.AsyncFlatSpec
import org.scalatest.matchers.should.Matchers
class UserServiceSpec extends AsyncFlatSpec with Matchers {
"fetchUser" should "return the correct user" in {
UserService.fetchUser("1").map { user =>
user.name shouldBe "Alice"
}
}
}

AsyncFlatSpec (and MUnitโ€™s equivalent FunSuite with Future-returning test bodies) understands that a test body itself can return a Future[Assertion] rather than a plain Assertion โ€” the test framework awaits the Future before deciding pass/fail, rather than you needing a manual Await.result inside every async test. This mirrors exactly the โ€œdonโ€™t block a thread waiting on a Futureโ€ principle from the futures guide, applied specifically to test code.

// MUnit's equivalent async test support
class UserServiceSuite extends munit.FunSuite {
test("fetchUser returns the correct user") {
UserService.fetchUser("1").map { user =>
assertEquals(user.name, "Alice")
}
}
}

Fixtures โ€” Setup and Teardown

import org.scalatest.BeforeAndAfterEach
class DatabaseSpec extends AnyFlatSpec with Matchers with BeforeAndAfterEach {
var connection: TestConnection = _
override def beforeEach(): Unit = {
connection = new TestConnection()
}
override def afterEach(): Unit = {
connection.close()
}
"query" should "return expected rows" in {
connection.query("SELECT 1") should have size 1
}
}

Fixtures (setup/teardown around each test) are exactly the machinery the immutability guide pointed to as the extra work impure, stateful code requires for testing โ€” a pure function needs none of this. This is a concrete argument for keeping business logic pure wherever feasible: every test for a pure function skips fixtures entirely, while a test touching a database, file system, or other external state generally needs exactly this kind of setup/teardown scaffolding.


What to Actually Test โ€” A Practical Priority Order

Pure business logic first. Given the โ€œfunctional core, imperative shellโ€ pattern from the immutability guide, your pure core functions are the cheapest, highest-value tests to write โ€” no mocking, no fixtures, just input/output verification.

Edge cases specifically. Empty collections, zero, negative numbers, the boundary values of any validation logic (parseAge("0"), parseAge("-1")) โ€” these are where real bugs concentrate, far more than the โ€œhappy pathโ€ case most naive tests default to covering.

Error/failure paths. For Option/Either/Try-returning functions, explicitly test the None/Left/Failure cases, not just the success cases โ€” this is precisely the case coverage the sealed-trait exhaustiveness checking from the pattern matching guide encourages you to think about at the type level; tests verify it actually behaves correctly at each of those cases.

Impure/integration code last, and more sparingly. Database queries, HTTP calls, and file I/O generally need fewer, more targeted tests (often integration tests exercising a real or realistic dependency) rather than exhaustive unit-level coverage, since the bulk of your logic should already be pushed into the pure, thoroughly-unit-tested core.


Property-Based Testing With ScalaCheck

import org.scalatest.propspec.AnyPropSpec
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
import org.scalacheck.Gen
class ReverseSpec extends AnyPropSpec with ScalaCheckPropertyChecks with Matchers {
property("reversing a list twice returns the original list") {
forAll { (list: List[Int]) =>
list.reverse.reverse shouldBe list
}
}
property("reversing preserves length") {
forAll { (list: List[Int]) =>
list.reverse.length shouldBe list.length
}
}
}

Rather than writing individual example-based test cases (test with List(1,2,3), test with List()), property-based testing describes a general property that should hold for any input (reversing twice returns the original), and the framework generates hundreds of random inputs โ€” including deliberately tricky edge cases like empty lists, single-element lists, and lists with duplicates โ€” automatically searching for a counterexample that breaks the property. This is a particularly natural fit for pure functions specifically, since a property like โ€œreversing twice is a no-opโ€ is a direct, checkable claim about a pure functionโ€™s behavior across its entire input space, not just the handful of examples a human happened to think of.

// Custom generators for domain-specific types
val validAgeGen: Gen[Int] = Gen.choose(0, 150)
property("parseAge accepts all valid ages") {
forAll(validAgeGen) { age =>
parseAge(age.toString) shouldBe Right(age)
}
}

Mocking Dependencies

trait EmailService {
def send(to: String, body: String): Future[Unit]
}
class FakeEmailService extends EmailService {
var sentEmails: List[(String, String)] = List.empty
def send(to: String, body: String): Future[Unit] = {
sentEmails = sentEmails :+ (to, body)
Future.successful(())
}
}
class NotificationServiceSpec extends AnyFlatSpec with Matchers {
"sendWelcomeEmail" should "send an email to the correct address" in {
val fakeEmail = new FakeEmailService()
val service = new NotificationService(fakeEmail)
service.sendWelcomeEmail("alice@example.com")
fakeEmail.sentEmails should contain(("alice@example.com", "Welcome!"))
}
}

A hand-written โ€œfakeโ€ implementing the same trait as the real dependency (rather than a full mocking library like Mockito) is often sufficient in Scala specifically because traits already provide a clean seam for substituting behavior โ€” this is a direct, practical consequence of the trait-based polymorphism covered in the traits guide: any class depending on a trait EmailService rather than a concrete class can be tested with any implementation satisfying that trait, real or fake, with no special mocking framework required for straightforward cases.

Common Pitfalls

SymptomLikely cause
Async test passes even though the assertion inside the Future never actually ranForgetting that AsyncFlatSpec/MUnit async tests must return the Future[Assertion] โ€” an assertion inside a Future whose result is discarded wonโ€™t be checked
Flaky tests involving Future or timingA test relies on real wall-clock timing or non-deterministic ordering โ€” inject a fake/deterministic clock or coordinate explicitly instead
Property-based test fails with a confusing generated counterexampleThis usually means the property is genuinely false for that input โ€” read the generated failing case carefully rather than assuming a framework bug
Fixtures leak state between testsvar fields shared across tests without proper beforeEach reset โ€” ensure fresh state is created per test, not reused across the whole suite
Test suite is slow specifically because of unnecessary mocking setupBusiness logic that could be pure is entangled with I/O โ€” consider extracting a pure core per the โ€œfunctional core, imperative shellโ€ pattern

Frequently Asked Questions

Should I use ScalaTest or MUnit for a new project? MUnit is a reasonable default for new Scala 3 projects specifically for its simplicity and faster compile times. ScalaTest remains extremely common in existing Scala 2 codebases and offers a richer feature set (property-based testing integration, more matcher styles) that some teams specifically want.

Which ScalaTest style should I pick? Thereโ€™s no objectively correct answer โ€” AnyFlatSpec reads closest to a specification, AnyFunSuite reads closest to traditional JUnit-style tests. Pick one per project and standardize on it; mixing styles across a codebase adds unnecessary cognitive overhead without a corresponding benefit.

How do I test code that depends on the current time or randomness? Extract the time/randomness dependency as a parameter (or an injected dependency) rather than calling java.time.Instant.now() or scala.util.Random directly inside the function โ€” this converts an impure function into a pure one for testing purposes, letting tests supply a fixed, predictable value instead.

Do I need 100% test coverage? No โ€” coverage percentage is a weak proxy for actual test quality. Prioritizing pure business logic, edge cases, and failure paths (as outlined above) produces far more valuable test coverage than chasing a coverage number by testing trivial getters or straightforward pass-through code.

Can I test private methods? Generally, no directly โ€” and this is usually a sign to test through the public API instead, which is what actual callers exercise. If a private methodโ€™s logic feels complex enough to need dedicated tests, thatโ€™s often a signal it deserves to be extracted into its own separately-testable, possibly public, pure function.


Whatโ€™s Next

Testing pure functions is cheap; testing impure, stateful code requires fixtures and mocking โ€” a direct, practical payoff of the immutability-first design covered throughout this series. Next: Scala 3 New Features, the final guide in this series, covering enums, union types, and the significant-indentation syntax that round out modern Scala.