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
ScalaFuturestrait while MUnit providesFuture[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.