Scala - Unit Testing (ScalaTest/MUnit)

ScalaTest dominates the Scala testing ecosystem with its flexible DSL and extensive matcher library. MUnit emerged as a faster, simpler alternative focused on compilation speed and straightforward...

Key Insights

  • ScalaTest offers multiple testing styles (FunSuite, FlatSpec, WordSpec) with powerful matchers, while MUnit provides a lightweight, fast alternative with minimal syntax overhead and excellent IDE integration
  • Property-based testing with ScalaCheck integration allows you to test code against hundreds of generated inputs automatically, catching edge cases that example-based tests miss
  • Asynchronous testing requires special handling in both frameworks—ScalaTest uses ScalaFutures trait while MUnit provides Future[Unit] return types with built-in timeout management

Choosing Between ScalaTest and MUnit

ScalaTest dominates the Scala testing ecosystem with its flexible DSL and extensive matcher library. MUnit emerged as a faster, simpler alternative focused on compilation speed and straightforward syntax.

ScalaTest excels when you need:

  • Multiple testing styles for different team preferences
  • Rich matchers and custom assertions
  • Extensive mocking and property-based testing integration

MUnit shines for:

  • Fast compile times (critical for large codebases)
  • Minimal boilerplate
  • Built-in filtering and test selection
// build.sbt
libraryDependencies ++= Seq(
  "org.scalatest" %% "scalatest" % "3.2.17" % Test,
  "org.scalameta" %% "munit" % "0.7.29" % Test
)

ScalaTest Fundamentals

ScalaTest’s AnyFunSuite provides a straightforward testing style with test blocks:

import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers

class UserServiceTest extends AnyFunSuite with Matchers {
  
  test("user registration should create new user with hashed password") {
    val service = new UserService
    val user = service.register("john@example.com", "password123")
    
    user.email shouldBe "john@example.com"
    user.passwordHash should not be "password123"
    user.passwordHash.length shouldBe 60 // bcrypt hash length
  }
  
  test("duplicate email should throw exception") {
    val service = new UserService
    service.register("john@example.com", "password123")
    
    assertThrows[DuplicateEmailException] {
      service.register("john@example.com", "different")
    }
  }
}

The FlatSpec style reads more like specifications:

import org.scalatest.flatspec.AnyFlatSpec

class CalculatorSpec extends AnyFlatSpec with Matchers {
  
  "A Calculator" should "add two numbers correctly" in {
    val calc = new Calculator
    calc.add(2, 3) shouldBe 5
  }
  
  it should "handle negative numbers" in {
    val calc = new Calculator
    calc.add(-5, 3) shouldBe -2
  }
  
  it should "throw exception for overflow" in {
    val calc = new Calculator
    an[ArithmeticException] should be thrownBy {
      calc.add(Int.MaxValue, 1)
    }
  }
}

MUnit Syntax and Features

MUnit uses a simpler approach with fewer abstractions:

import munit.FunSuite

class UserServiceSuite extends FunSuite {
  
  test("user registration creates new user with hashed password") {
    val service = new UserService
    val user = service.register("john@example.com", "password123")
    
    assertEquals(user.email, "john@example.com")
    assertNotEquals(user.passwordHash, "password123")
    assertEquals(user.passwordHash.length, 60)
  }
  
  test("duplicate email throws exception") {
    val service = new UserService
    service.register("john@example.com", "password123")
    
    intercept[DuplicateEmailException] {
      service.register("john@example.com", "different")
    }
  }
}

MUnit’s fixtures provide clean setup and teardown:

class DatabaseSuite extends FunSuite {
  
  val database = FunFixture[Database](
    setup = { _ => 
      val db = Database.connect("jdbc:h2:mem:test")
      db.migrate()
      db
    },
    teardown = { db => db.close() }
  )
  
  database.test("insert and retrieve user") { db =>
    val userId = db.insertUser("alice@example.com")
    val user = db.getUser(userId)
    assertEquals(user.email, "alice@example.com")
  }
  
  database.test("transaction rollback on error") { db =>
    intercept[SQLException] {
      db.transaction { implicit tx =>
        db.insertUser("bob@example.com")
        db.insertUser("invalid-email") // violates constraint
      }
    }
    assertEquals(db.countUsers(), 0)
  }
}

Advanced Matchers and Assertions

ScalaTest provides extensive matchers for collections, exceptions, and custom types:

import org.scalatest.matchers.should.Matchers._
import org.scalatest.inspectors._

class OrderProcessorTest extends AnyFunSuite with Matchers {
  
  test("order processing validates all items") {
    val processor = new OrderProcessor
    val order = Order(List(
      Item("book", 29.99, quantity = 2),
      Item("pen", 1.99, quantity = 5)
    ))
    
    val result = processor.process(order)
    
    result.items should have size 2
    result.total shouldBe 69.93 +- 0.01
    
    all(result.items) should have (
      Symbol("validated") (true)
    )
    
    forAll(result.items) { item =>
      item.quantity should be > 0
    }
  }
  
  test("order with invalid items returns errors") {
    val processor = new OrderProcessor
    val order = Order(List(
      Item("book", -10.0, quantity = 1), // negative price
      Item("", 5.0, quantity = 0)         // empty name, zero quantity
    ))
    
    val result = processor.validate(order)
    
    result.errors should contain allOf (
      "Price must be positive",
      "Item name cannot be empty",
      "Quantity must be greater than zero"
    )
  }
}

Property-Based Testing with ScalaCheck

Property-based testing generates random test cases to verify invariants:

import org.scalatest.propspec.AnyPropSpec
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks

class StringUtilsSpec extends AnyPropSpec with ScalaCheckPropertyChecks {
  
  property("reverse of reverse should return original string") {
    forAll { (s: String) =>
      StringUtils.reverse(StringUtils.reverse(s)) shouldBe s
    }
  }
  
  property("split and join should preserve content") {
    forAll { (s: String) =>
      whenever(s.nonEmpty && !s.contains(',')) {
        val parts = StringUtils.split(s, ',')
        StringUtils.join(parts, ',') shouldBe s
      }
    }
  }
  
  property("sanitize removes all special characters") {
    import org.scalacheck.Gen
    
    forAll(Gen.asciiPrintableStr) { s =>
      val sanitized = StringUtils.sanitize(s)
      all(sanitized.toCharArray) should (
        be >= 'a' and be <= 'z' or
        be >= 'A' and be <= 'Z' or
        be >= '0' and be <= '9'
      )
    }
  }
}

MUnit also supports ScalaCheck through the munit-scalacheck module:

import munit.ScalaCheckSuite
import org.scalacheck.Prop._

class MathUtilsSuite extends ScalaCheckSuite {
  
  property("absolute value is always non-negative") {
    forAll { (n: Int) =>
      val result = MathUtils.abs(n)
      result >= 0
    }
  }
  
  property("gcd is commutative") {
    forAll { (a: Int, b: Int) =>
      (a > 0 && b > 0) ==> {
        MathUtils.gcd(a, b) == MathUtils.gcd(b, a)
      }
    }
  }
}

Testing Asynchronous Code

ScalaTest provides ScalaFutures for testing Future-based code:

import org.scalatest.concurrent.ScalaFutures
import org.scalatest.time.{Seconds, Span}
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

class ApiClientTest extends AnyFunSuite with Matchers with ScalaFutures {
  
  implicit val patience = PatienceConfig(timeout = Span(5, Seconds))
  
  test("fetch user data returns valid response") {
    val client = new ApiClient
    val futureUser = client.fetchUser(123)
    
    whenReady(futureUser) { user =>
      user.id shouldBe 123
      user.name should not be empty
    }
  }
  
  test("concurrent requests complete successfully") {
    val client = new ApiClient
    val futures = (1 to 10).map(id => client.fetchUser(id))
    
    whenReady(Future.sequence(futures)) { users =>
      users should have size 10
      users.map(_.id) shouldBe (1 to 10)
    }
  }
}

MUnit handles Futures natively by returning Future[Unit]:

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

class ApiClientSuite extends FunSuite {
  
  test("fetch user data returns valid response") {
    val client = new ApiClient
    client.fetchUser(123).map { user =>
      assertEquals(user.id, 123)
      assert(user.name.nonEmpty)
    }
  }
  
  test("handles network errors gracefully") {
    val client = new ApiClient
    client.fetchUser(-1).map { _ =>
      fail("Expected exception for invalid ID")
    }.recover {
      case _: NotFoundException => () // expected
    }
  }
}

Mocking with ScalaMock

ScalaMock integrates seamlessly with ScalaTest for creating test doubles:

import org.scalamock.scalatest.MockFactory

class PaymentProcessorTest extends AnyFunSuite with MockFactory with Matchers {
  
  test("successful payment charges card and sends receipt") {
    val gateway = mock[PaymentGateway]
    val emailService = mock[EmailService]
    val processor = new PaymentProcessor(gateway, emailService)
    
    (gateway.charge _)
      .expects(*, 99.99)
      .returning(Future.successful(TransactionId("tx-123")))
      .once()
    
    (emailService.sendReceipt _)
      .expects(where { (email: String, txId: TransactionId) =>
        email == "customer@example.com" && txId.value == "tx-123"
      })
      .returning(Future.successful(()))
      .once()
    
    val result = processor.processPayment("customer@example.com", 99.99)
    
    whenReady(result) { txId =>
      txId.value shouldBe "tx-123"
    }
  }
}

Both frameworks provide powerful tools for comprehensive testing. ScalaTest offers more flexibility and features, while MUnit prioritizes speed and simplicity. Choose based on your project’s complexity and team preferences.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.