scalaintermediate

Testing with Mocks and Stubs

Write testable Scala code with trait-based mocks, stubs, and dependency substitution.

scala
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

// Interfaces
trait Clock:
  def now(): Long

trait Logger:
  def info(msg: String): Unit
  def error(msg: String): Unit

trait UserRepository:
  def findById(id: Long): Option[String]
  def save(name: String): Long
  def delete(id: Long): Boolean

trait EmailSender:
  def send(to: String, subject: String, body: String): Boolean

// Service under test
class UserService(
  repo: UserRepository,
  email: EmailSender,
  logger: Logger,
  clock: Clock
):
  def register(name: String, emailAddr: String): Either[String, Long] =
    if name.isEmpty then
      logger.error("Empty name")
      Left("Name is required")
    else
      val id = repo.save(name)
      logger.info(s"Registered user $id at ${clock.now()}")
      email.send(emailAddr, "Welcome", s"Hello $name")
      Right(id)

  def deregister(id: Long): Either[String, Unit] =
    repo.findById(id) match
      case None =>
        Left(s"User $id not found")
      case Some(name) =>
        repo.delete(id)
        logger.info(s"Deleted user $id")
        Right(())

// Stubs
class StubClock(time: Long) extends Clock:
  def now(): Long = time

class StubLogger extends Logger:
  var infos: List[String] = Nil
  var errors: List[String] = Nil
  def info(msg: String): Unit = infos = infos :+ msg
  def error(msg: String): Unit = errors = errors :+ msg

class StubUserRepo extends UserRepository:
  private var users = Map.empty[Long, String]
  private var nextId = 1L
  var deletedIds: List[Long] = Nil

  def findById(id: Long): Option[String] = users.get(id)
  def save(name: String): Long =
    val id = nextId; nextId += 1
    users += (id -> name); id
  def delete(id: Long): Boolean =
    deletedIds = deletedIds :+ id
    users -= id; true

class StubEmailSender extends EmailSender:
  var sent: List[(String, String, String)] = Nil
  var shouldFail = false

  def send(to: String, subject: String, body: String): Boolean =
    if shouldFail then false
    else { sent = sent :+ (to, subject, body); true }

// Tests
class UserServiceSpec extends AnyFlatSpec with Matchers:
  def createService() =
    val repo = StubUserRepo()
    val email = StubEmailSender()
    val logger = StubLogger()
    val clock = StubClock(1000L)
    val service = UserService(repo, email, logger, clock)
    (service, repo, email, logger)

  "register" should "create user and send email" in {
    val (service, repo, email, logger) = createService()
    val result = service.register("Alice", "alice@test.com")

    result shouldBe Right(1L)
    email.sent should have length 1
    email.sent.head._1 shouldBe "alice@test.com"
    logger.infos should have length 1
    logger.errors shouldBe empty
  }

  it should "reject empty name" in {
    val (service, _, _, logger) = createService()
    val result = service.register("", "test@test.com")

    result shouldBe Left("Name is required")
    logger.errors should have length 1
  }

  "deregister" should "delete existing user" in {
    val (service, repo, _, logger) = createService()
    repo.save("Alice")
    val result = service.deregister(1)

    result shouldBe Right(())
    repo.deletedIds should contain(1L)
  }

  it should "fail for non-existent user" in {
    val (service, _, _, _) = createService()
    val result = service.deregister(99)
    result shouldBe a[Left[?, ?]]
  }

Use Cases

  • Unit testing with dependency injection
  • Test doubles for external services
  • Verifying side effects in tests

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.