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 onlylibraryDependencies += "org.scalatest" %% "scalatest" % "3.2.17" % Test// or, for MUnit:libraryDependencies += "org.scalameta" %% "munit" % "1.0.0" % Testsrc/test/scala/โโโ com/example/ โโโ CalculatorSpec.scala // convention: <ClassName>Spec.scala or <ClassName>Test.scalaRecall 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.AnyFlatSpecimport 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 // equalityresult should be > 3 // comparison matchersresult should not be null // null check (rare in idiomatic Scala, but supported)list should contain(3) // collection membershiplist should have size 3 // collection sizeScalaTestโ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.AsyncFlatSpecimport 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 supportclass 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.AnyPropSpecimport org.scalatestplus.scalacheck.ScalaCheckPropertyChecksimport 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 typesval 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
| Symptom | Likely cause |
|---|---|
| Async test passes even though the assertion inside the Future never actually ran | Forgetting 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 timing | A 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 counterexample | This 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 tests | var 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 setup | Business 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.