Introduction to Testing in Scala
Scala applications power everything from data pipelines at LinkedIn to financial systems at major banks. With great power comes the responsibility of ensuring correctness, and that's where a robust testing strategy becomes essential. Scala's hybrid functional-object-oriented nature offers unique testing opportunities that languages like Java or Python simply don't have.
In this tutorial, we'll explore comprehensive testing strategies for Scala applications, covering unit testing, property-based testing, integration testing, and mocking. We'll use popular libraries like ScalaTest, ScalaCheck, and Mockito Scala to build a practical, maintainable test suite.
Why Testing Strategies Matter in Scala
Scala's type system catches many errors at compile time, but it can't verify business logic, side effects, or integration points. A deliberate testing strategy matters because:
- Functional purity is testable: Pure functions are trivially testable, and leaning into this property makes your suite faster and more reliable.
- Concurrency is hard: Scala's actor systems and futures require targeted tests to catch race conditions.
- Refactoring safety: Scala codebases often evolve rapidly; tests provide the safety net.
- Type system limits: Types can't express everything — domain rules, edge cases, and external integrations still need verification.
Setting Up Your Testing Stack
Before diving into strategies, let's configure a typical Scala project with the essential testing libraries. Add these to your build.sbt:
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % "3.2.17" % Test,
"org.scalacheck" %% "scalacheck" % "1.17.0" % Test,
"org.scalatestplus" %% "scalacheck-1-17" % "3.2.17.0" % Test,
"org.mockito" %% "mockito-scala" % "1.17.37" % Test,
"org.typelevel" %% "cats-effect-testing-scalatest" % "1.5.0" % Test
)
This gives you ScalaTest as the foundation, ScalaCheck for property-based testing, Mockito Scala for mocking, and cats-effect testing for effectful code.
Unit Testing with ScalaTest
Unit testing is the foundation of any testing strategy. ScalaTest supports multiple testing styles — FlatSpec, WordSpec, FunSpec, and more. Choose one and be consistent across your project.
Choosing a Style
For most teams, AnyFlatSpec offers a good balance of readability and structure. Here's a basic example testing a simple service:
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class CalculatorSpec extends AnyFlatSpec with Matchers {
"A Calculator" should "add two positive numbers correctly" in {
val calc = new Calculator
calc.add(2, 3) shouldEqual 5
}
it should "handle negative numbers" in {
val calc = new Calculator
calc.add(-1, -4) shouldEqual -5
}
it should "throw on overflow" in {
val calc = new Calculator
assertThrows[ArithmeticException] {
calc.add(Long.MaxValue, 1)
}
}
}
Testing Pure Functions
Scala encourages pure functions — functions with no side effects that always return the same output for the same input. These are the easiest to test and should make up the bulk of your business logic.
object Pricing {
def applyDiscount(price: BigDecimal, discountPercent: BigDecimal): BigDecimal = {
require(discountPercent >= 0 && discountPercent <= 100, "Invalid discount")
price - (price * discountPercent / 100)
}
def totalWithTax(prices: List[BigDecimal], taxRate: BigDecimal): BigDecimal = {
prices.sum * (1 + taxRate / 100)
}
}
class PricingSpec extends AnyFlatSpec with Matchers {
"applyDiscount" should "reduce price by the given percentage" in {
Pricing.applyDiscount(BigDecimal("100"), BigDecimal("20")) shouldEqual BigDecimal("80.0")
}
it should "reject invalid discount percentages" in {
assertThrows[IllegalArgumentException] {
Pricing.applyDiscount(BigDecimal("100"), BigDecimal("150"))
}
}
"totalWithTax" should "sum prices and apply tax" in {
val prices = List(BigDecimal("10"), BigDecimal("20"), BigDecimal("30"))
Pricing.totalWithTax(prices, BigDecimal("10")) shouldEqual BigDecimal("66.0")
}
}
Property-Based Testing with ScalaCheck
Example-based testing verifies specific cases, but property-based testing verifies universal properties across hundreds of generated inputs. This is one of Scala's superpowers, inherited from Haskell's QuickCheck.
Basic Property Tests
Instead of writing individual test cases, you define properties that should always hold true:
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
import org.scalacheck.Prop.forAll
class ListPropertiesSpec extends AnyFlatSpec with Matchers with ScalaCheckPropertyChecks {
"List reversal" should "preserve length" in {
forAll { (xs: List[Int]) =>
xs.reverse.length == xs.length
}
}
it should "be idempotent" in {
forAll { (xs: List[Int]) =>
xs.reverse.reverse == xs
}
}
"String concatenation" should "be associative" in {
forAll { (a: String, b: String, c: String) =>
(a + b) + c == a + (b + c)
}
}
}
Custom Generators
Real-world domains need custom generators. Here's how to generate realistic test data for a user model:
import org.scalacheck.{Gen, Arbitrary}
import org.scalacheck.Gen._
case class User(id: Long, email: String, age: Int, active: Boolean)
object UserGenerators {
val emailGen: Gen[String] = for {
name <- alphaStr
domain <- alphaStr
tld <- oneOf("com", "org", "io", "net")
} yield s"$name@$domain.$tld"
val ageGen: Gen[Int] = choose(0, 120)
val userGen: Gen[User] = for {
id <- positiveNum[Long]
email <- emailGen
age <- ageGen
active <- boolean
} yield User(id, email, age, active)
implicit val userArbitrary: Arbitrary[User] = Arbitrary(userGen)
}
class UserServiceSpec extends AnyFlatSpec with Matchers with ScalaCheckPropertyChecks {
import UserGenerators._
"UserService" should "activate any user" in {
forAll { (user: User) =>
val activated = user.copy(active = true)
activated.active mustBe true
}
}
it should "never produce a negative age" in {
forAll { (user: User) =>
user.age >= 0
}
}
}
Mocking External Dependencies
Unit tests should run in isolation. When your code depends on databases, HTTP APIs, or message queues, you need to mock those dependencies. Mockito Scala provides a clean, Scala-idiomatic mocking API.
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.mockito.scalatest.MockitoSugar
import org.mockito.Mockito._
trait UserRepository {
def findById(id: Long): Option[User]
def save(user: User): User
}
class UserService(repo: UserRepository) {
def getUser(id: Long): User =
repo.findById(id).getOrElse(throw new NoSuchElementException(s"User $id not found"))
def activateUser(id: Long): User = {
val user = getUser(id)
val updated = user.copy(active = true)
repo.save(updated)
}
}
class UserServiceSpec extends AnyFlatSpec with Matchers with MockitoSugar {
"UserService" should "return user when found" in {
val mockRepo = mock[UserRepository]
val user = User(1, "test@example.com", 30, false)
when(mockRepo.findById(1)).thenReturn(Some(user))
val service = new UserService(mockRepo)
service.getUser(1) shouldEqual user
}
it should "throw when user not found" in {
val mockRepo = mock[UserRepository]
when(mockRepo.findById(99)).thenReturn(None)
val service = new UserService(mockRepo)
assertThrows[NoSuchElementException] {
service.getUser(99)
}
}
it should "activate and save user" in {
val mockRepo = mock[UserRepository]
val user = User(1, "test@example.com", 30, false)
val activated = user.copy(active = true)
when(mockRepo.findById(1)).thenReturn(Some(user))
when(mockRepo.save(activated)).thenReturn(activated)
val service = new UserService(mockRepo)
service.activateUser(1) shouldEqual activated
verify(mockRepo).save(activated)
}
}
Testing Effectful Code with Cats Effect
Modern Scala applications often use effect systems like Cats Effect or ZIO. Testing IO values requires special handling — you can't just call them directly.
import cats.effect.{IO, Ref}
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import cats.effect.testing.scalatest.AsyncIOSpec
class Counter {
def increment(ref: Ref[IO, Int]): IO[Int] =
ref.updateAndGet(_ + 1)
}
class CounterSpec extends AsyncIOSpec with Matchers {
"Counter" should "increment values correctly" in {
for {
ref <- Ref.of[IO, Int](0)
counter = new Counter
v1 <- counter.increment(ref)
v2 <- counter.increment(ref)
v3 <- counter.increment(ref)
} yield {
v1 shouldEqual 1
v2 shouldEqual 2
v3 shouldEqual 3
}
}
}
Integration Testing
Integration tests verify that components work together correctly. These are slower and should be separated from unit tests using ScalaTest tags.
import org.scalatest.{BeforeAndAfterAll, Tag}
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
object IntegrationTest extends Tag("IntegrationTest")
class DatabaseIntegrationSpec extends AnyFlatSpec with Matchers with BeforeAndAfterAll {
private var connection: java.sql.Connection = _
override def beforeAll(): Unit = {
connection = java.sql.DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/testdb",
"testuser", "testpass"
)
}
override def afterAll(): Unit = {
if (connection != null) connection.close()
}
"User repository" should "persist and retrieve users" taggedAs IntegrationTest in {
val repo = new JdbcUserRepository(connection)
val user = User(0, "integration@example.com", 25, true)
val saved = repo.save(user)
saved.id should not equal 0
val retrieved = repo.findById(saved.id)
retrieved shouldEqual Some(saved)
}
}
Configure SBT to run only unit tests by default and integration tests on demand:
Test / testOptions := Seq(Tests.Exclude(Set("IntegrationTest")))
lazy val runIntegrationTests = taskKey[Unit]("Run integration tests")
runIntegrationTests := {
(Test / test).value
}
runIntegrationTests / testOptions := Seq()
Testing Asynchronous Code
Futures are everywhere in Scala. ScalaTest provides AsyncFlatSpec for testing asynchronous code naturally:
import org.scalatest.flatspec.AsyncFlatSpec
import org.scalatest.matchers.should.Matchers
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
class AsyncUserService(repo: UserRepository) {
def getUserAsync(id: Long): Future[User] =
Future(repo.findById(id).getOrElse(throw new NoSuchElementException))
}
class AsyncUserServiceSpec extends AsyncFlatSpec with Matchers {
"AsyncUserService" should "return user asynchronously" in {
val mockRepo = mock[UserRepository]
val user = User(1, "async@example.com", 28, true)
when(mockRepo.findById(1)).thenReturn(Some(user))
val service = new AsyncUserService(mockRepo)
service.getUserAsync(1).map { result =>
result shouldEqual user
}
}
}
Best Practices
Structure Your Tests
- Follow the Arrange-Act-Assert pattern consistently.
- One assertion per test when practical — failures become more diagnostic.
- Name tests as sentences that describe behavior, not implementation.
- Group tests by feature, not by class, when it improves readability.
Prefer Pure Functions
Design your code so business logic lives in pure functions. Push side effects to the edges of your application. This makes 80% of your code trivially testable without mocks.
// Bad: logic mixed with side effects
class OrderProcessor(db: Database) {
def process(order: Order): Order = {
val validated = validate(order)
val priced = applyPricing(validated)
db.save(priced) // side effect buried in logic
priced
}
}
// Good: pure logic, effects at the edge
object OrderLogic {
def validate(order: Order): Either[String, Order] = ???
def applyPricing(order: Order): Order = ???
}
class OrderProcessor(db: Database) {
def process(order: Order): Order =
OrderLogic.validate(order)
.map(OrderLogic.applyPricing)
.map { priced =>
db.save(priced)
priced
}
.getOrElse(order)
}
Use the Testing Pyramid
- 70% unit tests: Fast, isolated, numerous.
- 20% integration tests: Verify component interactions.
- 10% end-to-end tests: Verify critical user journeys.
Leverage Property-Based Testing for Algorithms
Use ScalaCheck for anything involving collections, math, parsing, or state transitions. It finds edge cases you'd never think to write manually.
Keep Tests Fast
Slow test suites get skipped. Aim for unit tests running in under 10 seconds total. Use in-memory databases like H2 for integration tests instead of real PostgreSQL when possible.
Test Behavior, Not Implementation
Avoid asserting on internal state or method call counts unless absolutely necessary. Tests that verify implementation details break during refactoring even when behavior is correct.
Conclusion
Testing Scala applications effectively means leaning into the language's strengths: pure functions for trivially testable logic, property-based testing for exhaustive verification, and effect systems for predictable async behavior. By combining ScalaTest for structure, ScalaCheck for property verification, and Mockito for isolation, you build a safety net that catches bugs early and enables confident refactoring. Start with pure functions and unit tests, add property-based tests for algorithmic code, layer in integration tests for boundaries, and always keep your suite fast enough that developers want to run it. A well-tested Scala codebase isn't just more reliable — it's more enjoyable to work in, because the tests document behavior and make change safe.