Advanced Testing Patterns: Property-Based Testing, Mocking, and Integration Strategies
Comprehensive testing is crucial for building reliable Scala applications. This lesson explores advanced testing techniques including property-based testing, sophisticated mocking strategies, integration testing patterns, and test automation frameworks that ensure code quality and system reliability.
Property-Based Testing with ScalaCheck
Understanding Property-Based Testing Fundamentals
import org.scalacheck._
import org.scalacheck.Prop._
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
// Basic property-based testing examples
class PropertyBasedTestingExamples extends AnyFlatSpec with Matchers with ScalaCheckPropertyChecks {
// Simple property: reversing a list twice returns the original list
"List reverse property" should "satisfy double reverse identity" in {
forAll { (list: List[Int]) =>
list.reverse.reverse shouldEqual list
}
}
// Mathematical properties
"Addition properties" should "be commutative and associative" in {
forAll { (a: Int, b: Int, c: Int) =>
whenever(a < Int.MaxValue - b && b < Int.MaxValue - c && a < Int.MaxValue - c) {
// Commutativity
a + b shouldEqual b + a
// Associativity
(a + b) + c shouldEqual a + (b + c)
// Identity element
a + 0 shouldEqual a
}
}
}
// String properties
"String concatenation" should "preserve length" in {
forAll { (s1: String, s2: String) =>
(s1 + s2).length shouldEqual s1.length + s2.length
}
}
// Collection properties
"Map operations" should "maintain invariants" in {
forAll { (map: Map[String, Int], key: String, value: Int) =>
val updatedMap = map + (key -> value)
// Size property
if (map.contains(key)) {
updatedMap.size shouldEqual map.size
} else {
updatedMap.size shouldEqual map.size + 1
}
// Contains property
updatedMap should contain key -> value
// All original keys (except possibly the updated one) should remain
map.keys.filterNot(_ == key).foreach { k =>
updatedMap should contain key k
updatedMap(k) shouldEqual map(k)
}
}
}
}
// Custom generators for domain objects
object CustomGenerators {
// Generator for email addresses
val emailGen: Gen[String] = for {
username <- Gen.alphaNumStr.suchThat(_.nonEmpty)
domain <- Gen.oneOf("gmail.com", "yahoo.com", "company.com", "example.org")
} yield s"$username@$domain"
// Generator for valid phone numbers
val phoneGen: Gen[String] = for {
areaCode <- Gen.choose(100, 999)
exchange <- Gen.choose(100, 999)
number <- Gen.choose(1000, 9999)
} yield f"$areaCode-$exchange-$number"
// Generator for business domain objects
case class User(id: Long, name: String, email: String, age: Int)
val userGen: Gen[User] = for {
id <- Gen.posNum[Long]
name <- Gen.alphaStr.suchThat(_.length > 2)
email <- emailGen
age <- Gen.choose(18, 100)
} yield User(id, name, email, age)
// Generator for complex data structures
case class Order(id: String, userId: Long, items: List[OrderItem], total: BigDecimal)
case class OrderItem(productId: String, quantity: Int, price: BigDecimal)
val orderItemGen: Gen[OrderItem] = for {
productId <- Gen.uuid.map(_.toString)
quantity <- Gen.choose(1, 10)
price <- Gen.choose(1.0, 1000.0).map(BigDecimal(_))
} yield OrderItem(productId, quantity, price)
val orderGen: Gen[Order] = for {
id <- Gen.uuid.map(_.toString)
userId <- Gen.posNum[Long]
items <- Gen.listOfN(Gen.choose(1, 5).sample.get, orderItemGen)
total = items.map(item => item.price * item.quantity).sum
} yield Order(id, userId, items, total)
// Generator with constraints
val validOrderGen: Gen[Order] = orderGen.suchThat { order =>
order.items.nonEmpty &&
order.total > 0 &&
order.items.forall(_.quantity > 0) &&
order.items.forall(_.price > 0)
}
// Frequency-based generators
val statusGen: Gen[String] = Gen.frequency(
(70, "ACTIVE"),
(20, "INACTIVE"),
(9, "PENDING"),
(1, "SUSPENDED")
)
// Recursive generators for tree structures
sealed trait Tree[T]
case class Leaf[T](value: T) extends Tree[T]
case class Branch[T](left: Tree[T], right: Tree[T]) extends Tree[T]
def treeGen[T](valueGen: Gen[T], maxDepth: Int): Gen[Tree[T]] = {
def treeGenHelper(depth: Int): Gen[Tree[T]] = {
if (depth >= maxDepth) {
valueGen.map(Leaf(_))
} else {
Gen.frequency(
(1, valueGen.map(Leaf(_))),
(3, for {
left <- treeGenHelper(depth + 1)
right <- treeGenHelper(depth + 1)
} yield Branch(left, right))
)
}
}
treeGenHelper(0)
}
val intTreeGen: Gen[Tree[Int]] = treeGen(Gen.choose(-100, 100), 5)
}
// Advanced property testing patterns
class AdvancedPropertyTesting extends AnyFlatSpec with Matchers with ScalaCheckPropertyChecks {
import CustomGenerators._
// Testing with custom generators
"User validation" should "accept valid users" in {
forAll(userGen) { user =>
validateUser(user) shouldBe true
}
}
// Testing invariants
"Order total calculation" should "equal sum of item totals" in {
forAll(validOrderGen) { order =>
val calculatedTotal = order.items.map(item => item.price * item.quantity).sum
order.total shouldEqual calculatedTotal
}
}
// Testing with preconditions
"Division operation" should "be consistent" in {
forAll { (a: Double, b: Double) =>
whenever(b != 0.0 && !b.isInfinite && !b.isNaN && !a.isInfinite && !a.isNaN) {
val result = a / b
math.abs((result * b) - a) should be < 1e-10
}
}
}
// Testing state machines
sealed trait BankAccountAction
case class Deposit(amount: BigDecimal) extends BankAccountAction
case class Withdraw(amount: BigDecimal) extends BankAccountAction
case object CheckBalance extends BankAccountAction
class BankAccount(private var balance: BigDecimal = 0) {
def deposit(amount: BigDecimal): Either[String, BigDecimal] = {
if (amount <= 0) Left("Amount must be positive")
else {
balance += amount
Right(balance)
}
}
def withdraw(amount: BigDecimal): Either[String, BigDecimal] = {
if (amount <= 0) Left("Amount must be positive")
else if (amount > balance) Left("Insufficient funds")
else {
balance -= amount
Right(balance)
}
}
def getBalance: BigDecimal = balance
}
val actionGen: Gen[BankAccountAction] = Gen.frequency(
(40, Gen.choose(0.01, 1000.0).map(amount => Deposit(BigDecimal(amount)))),
(30, Gen.choose(0.01, 500.0).map(amount => Withdraw(BigDecimal(amount)))),
(30, Gen.const(CheckBalance))
)
"Bank account" should "maintain balance invariants" in {
forAll(Gen.listOf(actionGen)) { actions =>
val account = new BankAccount()
var expectedBalance = BigDecimal(0)
actions.foreach { action =>
val balanceBefore = account.getBalance
action match {
case Deposit(amount) =>
account.deposit(amount) match {
case Right(newBalance) =>
expectedBalance += amount
newBalance shouldEqual expectedBalance
case Left(_) =>
amount should be <= 0
}
case Withdraw(amount) =>
account.withdraw(amount) match {
case Right(newBalance) =>
expectedBalance -= amount
newBalance shouldEqual expectedBalance
case Left(_) =>
// Either amount <= 0 or insufficient funds
(amount <= 0 || amount > balanceBefore) shouldBe true
}
case CheckBalance =>
account.getBalance shouldEqual expectedBalance
}
// Balance should never be negative
account.getBalance should be >= 0
}
}
}
// Testing concurrent operations
"Concurrent counter" should "maintain count accuracy" in {
import scala.concurrent.{Future, ExecutionContext}
import java.util.concurrent.atomic.AtomicInteger
implicit val ec: ExecutionContext = ExecutionContext.global
forAll(Gen.choose(1, 100)) { numOperations =>
val counter = new AtomicInteger(0)
val futures = (1 to numOperations).map { _ =>
Future {
counter.incrementAndGet()
}
}
val result = Future.sequence(futures)
result.futureValue.sum shouldEqual (numOperations * (numOperations + 1)) / 2
counter.get() shouldEqual numOperations
}
}
private def validateUser(user: User): Boolean = {
user.name.nonEmpty &&
user.email.contains("@") &&
user.age >= 18 &&
user.age <= 120
}
}
// Performance property testing
class PerformancePropertyTesting extends AnyFlatSpec with Matchers with ScalaCheckPropertyChecks {
"Sorting algorithms" should "have correct time complexity characteristics" in {
forAll(Gen.choose(100, 10000)) { size =>
val data = (1 to size).map(_ => scala.util.Random.nextInt(1000)).toList
val startTime = System.nanoTime()
val sorted = data.sorted
val endTime = System.nanoTime()
val duration = endTime - startTime
// Verify correctness
sorted shouldEqual data.sorted
// Basic performance check (this is quite loose)
// In practice, you'd use more sophisticated timing analysis
duration should be < (size * size * 1000L) // Very loose upper bound
}
}
"Map lookup performance" should "be roughly constant" in {
forAll(Gen.choose(1000, 50000)) { size =>
val map = (1 to size).map(i => i -> s"value$i").toMap
val lookupKeys = (1 to 100).map(_ => scala.util.Random.nextInt(size) + 1)
val startTime = System.nanoTime()
lookupKeys.foreach(key => map.get(key))
val endTime = System.nanoTime()
val duration = endTime - startTime
// Performance should be roughly constant regardless of map size
// (This is a very simplified check)
duration should be < 10000000L // 10ms for 100 lookups
}
}
}
Advanced Mocking and Test Doubles
// Mockito integration for Scala
import org.mockito.Mockito._
import org.mockito.ArgumentMatchers._
import org.mockito.{Mock, MockitoSugar}
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.BeforeAndAfterEach
import scala.concurrent.{Future, ExecutionContext}
import scala.util.{Success, Failure}
// Service interfaces for testing
trait UserRepository {
def findById(id: Long): Future[Option[User]]
def save(user: User): Future[User]
def delete(id: Long): Future[Boolean]
def findByEmail(email: String): Future[Option[User]]
}
trait EmailService {
def sendWelcomeEmail(user: User): Future[Boolean]
def sendPasswordResetEmail(email: String, token: String): Future[Boolean]
}
trait AuditService {
def logUserAction(userId: Long, action: String, details: Map[String, Any]): Future[Unit]
}
case class User(id: Long, name: String, email: String, age: Int, active: Boolean = true)
class UserService(
userRepository: UserRepository,
emailService: EmailService,
auditService: AuditService
)(implicit ec: ExecutionContext) {
def createUser(name: String, email: String, age: Int): Future[Either[String, User]] = {
// Check if user already exists
userRepository.findByEmail(email).flatMap {
case Some(_) => Future.successful(Left("User with this email already exists"))
case None =>
if (age < 18) {
Future.successful(Left("User must be at least 18 years old"))
} else {
val user = User(0, name, email, age)
userRepository.save(user).flatMap { savedUser =>
// Send welcome email
emailService.sendWelcomeEmail(savedUser).flatMap { emailSent =>
// Log the action
auditService.logUserAction(savedUser.id, "USER_CREATED", Map(
"email" -> email,
"emailSent" -> emailSent
)).map { _ =>
Right(savedUser)
}
}
}
}
}
}
def updateUser(id: Long, name: Option[String], email: Option[String]): Future[Either[String, User]] = {
userRepository.findById(id).flatMap {
case None => Future.successful(Left("User not found"))
case Some(user) =>
val updatedUser = user.copy(
name = name.getOrElse(user.name),
email = email.getOrElse(user.email)
)
userRepository.save(updatedUser).flatMap { saved =>
auditService.logUserAction(id, "USER_UPDATED", Map(
"oldName" -> user.name,
"newName" -> saved.name,
"oldEmail" -> user.email,
"newEmail" -> saved.email
)).map { _ =>
Right(saved)
}
}
}
}
def deactivateUser(id: Long): Future[Either[String, Unit]] = {
userRepository.findById(id).flatMap {
case None => Future.successful(Left("User not found"))
case Some(user) if !user.active => Future.successful(Left("User already deactivated"))
case Some(user) =>
val deactivatedUser = user.copy(active = false)
userRepository.save(deactivatedUser).flatMap { _ =>
auditService.logUserAction(id, "USER_DEACTIVATED", Map(
"email" -> user.email
)).map { _ =>
Right(())
}
}
}
}
}
// Comprehensive mocking tests
class UserServiceSpec extends AnyFlatSpec with Matchers with MockitoSugar with BeforeAndAfterEach {
implicit val ec: ExecutionContext = ExecutionContext.global
var userRepository: UserRepository = _
var emailService: EmailService = _
var auditService: AuditService = _
var userService: UserService = _
override def beforeEach(): Unit = {
userRepository = mock[UserRepository]
emailService = mock[EmailService]
auditService = mock[AuditService]
userService = new UserService(userRepository, emailService, auditService)
}
"UserService.createUser" should "create a new user successfully" in {
// Setup mocks
when(userRepository.findByEmail("john@example.com")).thenReturn(Future.successful(None))
when(userRepository.save(any[User])).thenReturn(Future.successful(User(1, "John Doe", "john@example.com", 25)))
when(emailService.sendWelcomeEmail(any[User])).thenReturn(Future.successful(true))
when(auditService.logUserAction(any[Long], any[String], any[Map[String, Any]])).thenReturn(Future.successful(()))
// Execute
val result = userService.createUser("John Doe", "john@example.com", 25).futureValue
// Verify
result shouldBe a[Right[_, _]]
result.right.get.name shouldEqual "John Doe"
result.right.get.email shouldEqual "john@example.com"
// Verify interactions
verify(userRepository).findByEmail("john@example.com")
verify(userRepository).save(any[User])
verify(emailService).sendWelcomeEmail(any[User])
verify(auditService).logUserAction(eq(1L), eq("USER_CREATED"), any[Map[String, Any]])
}
it should "reject users under 18" in {
// Execute
val result = userService.createUser("Young User", "young@example.com", 17).futureValue
// Verify
result shouldBe Left("User must be at least 18 years old")
// Verify no interactions with dependencies for invalid input
verify(userRepository, never()).save(any[User])
verify(emailService, never()).sendWelcomeEmail(any[User])
}
it should "reject duplicate email addresses" in {
// Setup
val existingUser = User(1, "Existing User", "existing@example.com", 30)
when(userRepository.findByEmail("existing@example.com")).thenReturn(Future.successful(Some(existingUser)))
// Execute
val result = userService.createUser("New User", "existing@example.com", 25).futureValue
// Verify
result shouldBe Left("User with this email already exists")
verify(userRepository).findByEmail("existing@example.com")
verify(userRepository, never()).save(any[User])
}
it should "handle email service failures gracefully" in {
// Setup mocks
when(userRepository.findByEmail("john@example.com")).thenReturn(Future.successful(None))
when(userRepository.save(any[User])).thenReturn(Future.successful(User(1, "John Doe", "john@example.com", 25)))
when(emailService.sendWelcomeEmail(any[User])).thenReturn(Future.successful(false))
when(auditService.logUserAction(any[Long], any[String], any[Map[String, Any]])).thenReturn(Future.successful(()))
// Execute
val result = userService.createUser("John Doe", "john@example.com", 25).futureValue
// Verify user is still created even if email fails
result shouldBe a[Right[_, _]]
// Verify audit log includes email failure
verify(auditService).logUserAction(eq(1L), eq("USER_CREATED"), argThat { (map: Map[String, Any]) =>
map("emailSent") == false
})
}
"UserService.updateUser" should "update user information" in {
// Setup
val existingUser = User(1, "John Doe", "john@example.com", 25)
when(userRepository.findById(1L)).thenReturn(Future.successful(Some(existingUser)))
when(userRepository.save(any[User])).thenReturn(Future.successful(existingUser.copy(name = "John Smith")))
when(auditService.logUserAction(any[Long], any[String], any[Map[String, Any]])).thenReturn(Future.successful(()))
// Execute
val result = userService.updateUser(1L, Some("John Smith"), None).futureValue
// Verify
result shouldBe a[Right[_, _]]
result.right.get.name shouldEqual "John Smith"
verify(auditService).logUserAction(eq(1L), eq("USER_UPDATED"), any[Map[String, Any]])
}
it should "handle non-existent users" in {
// Setup
when(userRepository.findById(999L)).thenReturn(Future.successful(None))
// Execute
val result = userService.updateUser(999L, Some("New Name"), None).futureValue
// Verify
result shouldBe Left("User not found")
verify(userRepository, never()).save(any[User])
verify(auditService, never()).logUserAction(any[Long], any[String], any[Map[String, Any]])
}
}
// Advanced mocking patterns
class AdvancedMockingPatterns extends AnyFlatSpec with Matchers with MockitoSugar {
implicit val ec: ExecutionContext = ExecutionContext.global
// Argument matchers and custom verification
"Advanced argument matching" should "work with complex types" in {
val userRepository = mock[UserRepository]
// Custom argument matcher
def userWithEmail(email: String): User = argThat { (user: User) =>
user.email == email
}
when(userRepository.save(userWithEmail("test@example.com")))
.thenReturn(Future.successful(User(1, "Test", "test@example.com", 25)))
val user = User(0, "Test", "test@example.com", 25)
val result = userRepository.save(user).futureValue
result.id shouldEqual 1
verify(userRepository).save(userWithEmail("test@example.com"))
}
// Stubbing with side effects
"Mock with side effects" should "execute additional logic" in {
val emailService = mock[EmailService]
var emailsSent = 0
when(emailService.sendWelcomeEmail(any[User])).thenAnswer { _ =>
emailsSent += 1
Future.successful(true)
}
val user = User(1, "Test", "test@example.com", 25)
emailService.sendWelcomeEmail(user).futureValue shouldBe true
emailsSent shouldEqual 1
}
// Verification with timeouts and ordering
"Verification patterns" should "support timeouts and ordering" in {
val auditService = mock[AuditService]
// Setup async operation
Future {
Thread.sleep(100)
auditService.logUserAction(1L, "TEST", Map.empty)
}
// Verify with timeout
verify(auditService, timeout(200)).logUserAction(1L, "TEST", Map.empty)
}
// Spy pattern for partial mocking
"Spy pattern" should "allow partial mocking" in {
class RealEmailService extends EmailService {
def sendWelcomeEmail(user: User): Future[Boolean] = {
// Real implementation
Future.successful(true)
}
def sendPasswordResetEmail(email: String, token: String): Future[Boolean] = {
// Real implementation
Future.successful(true)
}
}
val emailService = spy(new RealEmailService)
// Mock only specific methods
when(emailService.sendPasswordResetEmail(any[String], any[String]))
.thenReturn(Future.successful(false))
// Real method works normally
emailService.sendWelcomeEmail(User(1, "Test", "test@example.com", 25)).futureValue shouldBe true
// Mocked method returns mocked value
emailService.sendPasswordResetEmail("test@example.com", "token").futureValue shouldBe false
}
}
Integration Testing Strategies
Database Integration Testing
// Database integration testing with embedded database
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
import slick.jdbc.H2Profile.api._
import scala.concurrent.duration._
import scala.concurrent.{Await, ExecutionContext}
// Database schema definition
object DatabaseSchema {
class UsersTable(tag: Tag) extends Table[(Long, String, String, Int, Boolean)](tag, "users") {
def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
def name = column[String]("name")
def email = column[String]("email", O.Unique)
def age = column[Int]("age")
def active = column[Boolean]("active", O.Default(true))
def * = (id, name, email, age, active)
}
val users = TableQuery[UsersTable]
class OrdersTable(tag: Tag) extends Table[(Long, Long, String, BigDecimal)](tag, "orders") {
def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
def userId = column[Long]("user_id")
def status = column[String]("status")
def total = column[BigDecimal]("total")
def * = (id, userId, status, total)
def user = foreignKey("fk_user", userId, users)(_.id)
}
val orders = TableQuery[OrdersTable]
}
// Repository implementation
class SlickUserRepository(db: Database)(implicit ec: ExecutionContext) extends UserRepository {
import DatabaseSchema._
def findById(id: Long): Future[Option[User]] = {
val query = users.filter(_.id === id).result.headOption
db.run(query).map(_.map(tupleToUser))
}
def save(user: User): Future[User] = {
if (user.id == 0) {
// Insert new user
val insertQuery = (users.map(u => (u.name, u.email, u.age, u.active))
returning users.map(_.id)
into ((userData, id) => tupleToUser((id, userData._1, userData._2, userData._3, userData._4)))
) += (user.name, user.email, user.age, user.active)
db.run(insertQuery)
} else {
// Update existing user
val updateQuery = users.filter(_.id === user.id)
.map(u => (u.name, u.email, u.age, u.active))
.update((user.name, user.email, user.age, user.active))
db.run(updateQuery).map(_ => user)
}
}
def delete(id: Long): Future[Boolean] = {
val deleteQuery = users.filter(_.id === id).delete
db.run(deleteQuery).map(_ > 0)
}
def findByEmail(email: String): Future[Option[User]] = {
val query = users.filter(_.email === email).result.headOption
db.run(query).map(_.map(tupleToUser))
}
private def tupleToUser(tuple: (Long, String, String, Int, Boolean)): User = {
User(tuple._1, tuple._2, tuple._3, tuple._4, tuple._5)
}
}
// Database integration test
class DatabaseIntegrationTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll with BeforeAndAfterEach {
implicit val ec: ExecutionContext = ExecutionContext.global
var database: Database = _
var userRepository: SlickUserRepository = _
override def beforeAll(): Unit = {
// Setup in-memory H2 database
database = Database.forConfig("h2test", ConfigFactory.parseString(
"""
h2test {
driver = "slick.driver.H2Driver$"
db {
driver = "org.h2.Driver"
url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"
user = "sa"
password = ""
}
}
"""
))
userRepository = new SlickUserRepository(database)
// Create schema
val setupFuture = database.run(DBIO.seq(
DatabaseSchema.users.schema.create,
DatabaseSchema.orders.schema.create
))
Await.result(setupFuture, 10.seconds)
}
override def afterAll(): Unit = {
if (database != null) {
database.close()
}
}
override def beforeEach(): Unit = {
// Clean data before each test
val cleanupFuture = database.run(DBIO.seq(
DatabaseSchema.orders.delete,
DatabaseSchema.users.delete
))
Await.result(cleanupFuture, 5.seconds)
}
"UserRepository" should "save and retrieve users" in {
val user = User(0, "John Doe", "john@example.com", 30)
val savedUser = userRepository.save(user).futureValue
savedUser.id should be > 0L
savedUser.name shouldEqual "John Doe"
val retrievedUser = userRepository.findById(savedUser.id).futureValue
retrievedUser shouldBe Some(savedUser)
}
it should "find users by email" in {
val user = User(0, "Jane Doe", "jane@example.com", 28)
val savedUser = userRepository.save(user).futureValue
val foundUser = userRepository.findByEmail("jane@example.com").futureValue
foundUser shouldBe Some(savedUser)
val notFound = userRepository.findByEmail("nonexistent@example.com").futureValue
notFound shouldBe None
}
it should "update existing users" in {
val user = User(0, "Bob Smith", "bob@example.com", 35)
val savedUser = userRepository.save(user).futureValue
val updatedUser = savedUser.copy(name = "Robert Smith", age = 36)
val result = userRepository.save(updatedUser).futureValue
result.name shouldEqual "Robert Smith"
result.age shouldEqual 36
val retrieved = userRepository.findById(savedUser.id).futureValue
retrieved.get.name shouldEqual "Robert Smith"
}
it should "delete users" in {
val user = User(0, "Delete Me", "delete@example.com", 25)
val savedUser = userRepository.save(user).futureValue
val deleted = userRepository.delete(savedUser.id).futureValue
deleted shouldBe true
val notFound = userRepository.findById(savedUser.id).futureValue
notFound shouldBe None
}
it should "handle unique constraint violations" in {
val user1 = User(0, "User One", "same@example.com", 25)
val user2 = User(0, "User Two", "same@example.com", 30)
userRepository.save(user1).futureValue
an[Exception] should be thrownBy {
userRepository.save(user2).futureValue
}
}
}
// Transaction testing
class TransactionIntegrationTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll with BeforeAndAfterEach {
implicit val ec: ExecutionContext = ExecutionContext.global
var database: Database = _
override def beforeAll(): Unit = {
database = Database.forConfig("h2test")
// Setup schema...
}
override def afterAll(): Unit = {
database.close()
}
"Transaction handling" should "rollback on failure" in {
import DatabaseSchema._
val transactionAction = DBIO.seq(
users += (0L, "User 1", "user1@example.com", 25, true),
users += (0L, "User 2", "user2@example.com", 30, true),
// This should cause a failure due to duplicate email
users += (0L, "User 3", "user1@example.com", 35, true)
).transactionally
an[Exception] should be thrownBy {
Await.result(database.run(transactionAction), 5.seconds)
}
// Verify rollback - no users should be inserted
val count = Await.result(database.run(users.length.result), 5.seconds)
count shouldEqual 0
}
it should "commit on success" in {
import DatabaseSchema._
val transactionAction = DBIO.seq(
users += (0L, "User 1", "user1@example.com", 25, true),
users += (0L, "User 2", "user2@example.com", 30, true),
users += (0L, "User 3", "user3@example.com", 35, true)
).transactionally
Await.result(database.run(transactionAction), 5.seconds)
// Verify commit - all users should be inserted
val count = Await.result(database.run(users.length.result), 5.seconds)
count shouldEqual 3
}
}
HTTP API Integration Testing
// HTTP API integration testing
import akka.actor.testkit.typed.scaladsl.ActorTestKit
import akka.actor.typed.ActorSystem
import akka.http.scaladsl.Http
import akka.http.scaladsl.model._
import akka.http.scaladsl.testkit.{RouteTestTimeout, ScalatestRouteTest}
import akka.http.scaladsl.unmarshalling.Unmarshal
import org.scalatest.concurrent.ScalaFutures
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import spray.json._
import DefaultJsonProtocol._
import scala.concurrent.duration._
// JSON protocols
object JsonProtocols {
implicit val userFormat: RootJsonFormat[User] = jsonFormat5(User)
case class CreateUserRequest(name: String, email: String, age: Int)
implicit val createUserRequestFormat: RootJsonFormat[CreateUserRequest] = jsonFormat3(CreateUserRequest)
case class UpdateUserRequest(name: Option[String], email: Option[String])
implicit val updateUserRequestFormat: RootJsonFormat[UpdateUserRequest] = jsonFormat2(UpdateUserRequest)
case class ErrorResponse(error: String, code: String)
implicit val errorResponseFormat: RootJsonFormat[ErrorResponse] = jsonFormat2(ErrorResponse)
}
// HTTP routes
class UserRoutes(userService: UserService)(implicit system: ActorSystem[_]) {
import akka.http.scaladsl.server.Directives._
import JsonProtocols._
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
implicit val ec = system.executionContext
val routes =
pathPrefix("api" / "v1" / "users") {
concat(
// POST /api/v1/users
pathEnd {
post {
entity(as[CreateUserRequest]) { request =>
onComplete(userService.createUser(request.name, request.email, request.age)) {
case Success(Right(user)) =>
complete(StatusCodes.Created, user)
case Success(Left(error)) =>
complete(StatusCodes.BadRequest, ErrorResponse(error, "VALIDATION_ERROR"))
case Failure(exception) =>
complete(StatusCodes.InternalServerError, ErrorResponse("Internal server error", "INTERNAL_ERROR"))
}
}
}
},
// GET /api/v1/users/{id}
path(LongNumber) { id =>
get {
onComplete(userService.findById(id)) {
case Success(Some(user)) =>
complete(StatusCodes.OK, user)
case Success(None) =>
complete(StatusCodes.NotFound, ErrorResponse("User not found", "NOT_FOUND"))
case Failure(exception) =>
complete(StatusCodes.InternalServerError, ErrorResponse("Internal server error", "INTERNAL_ERROR"))
}
}
},
// PUT /api/v1/users/{id}
path(LongNumber) { id =>
put {
entity(as[UpdateUserRequest]) { request =>
onComplete(userService.updateUser(id, request.name, request.email)) {
case Success(Right(user)) =>
complete(StatusCodes.OK, user)
case Success(Left(error)) =>
complete(StatusCodes.BadRequest, ErrorResponse(error, "VALIDATION_ERROR"))
case Failure(exception) =>
complete(StatusCodes.InternalServerError, ErrorResponse("Internal server error", "INTERNAL_ERROR"))
}
}
}
}
)
}
}
// Route testing
class UserRoutesSpec extends AnyFlatSpec with Matchers with ScalatestRouteTest with MockitoSugar with ScalaFutures {
import JsonProtocols._
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
implicit val timeout: RouteTestTimeout = RouteTestTimeout(5.seconds)
val mockUserService = mock[UserService]
val userRoutes = new UserRoutes(mockUserService)
"POST /api/v1/users" should "create a new user" in {
val request = CreateUserRequest("John Doe", "john@example.com", 30)
val expectedUser = User(1, "John Doe", "john@example.com", 30)
when(mockUserService.createUser("John Doe", "john@example.com", 30))
.thenReturn(Future.successful(Right(expectedUser)))
Post("/api/v1/users", request) ~> userRoutes.routes ~> check {
status shouldEqual StatusCodes.Created
responseAs[User] shouldEqual expectedUser
}
}
it should "return validation error for invalid data" in {
val request = CreateUserRequest("John Doe", "john@example.com", 17)
when(mockUserService.createUser("John Doe", "john@example.com", 17))
.thenReturn(Future.successful(Left("User must be at least 18 years old")))
Post("/api/v1/users", request) ~> userRoutes.routes ~> check {
status shouldEqual StatusCodes.BadRequest
responseAs[ErrorResponse].error shouldEqual "User must be at least 18 years old"
}
}
"GET /api/v1/users/{id}" should "return user when found" in {
val user = User(1, "John Doe", "john@example.com", 30)
when(mockUserService.findById(1L))
.thenReturn(Future.successful(Some(user)))
Get("/api/v1/users/1") ~> userRoutes.routes ~> check {
status shouldEqual StatusCodes.OK
responseAs[User] shouldEqual user
}
}
it should "return 404 when user not found" in {
when(mockUserService.findById(999L))
.thenReturn(Future.successful(None))
Get("/api/v1/users/999") ~> userRoutes.routes ~> check {
status shouldEqual StatusCodes.NotFound
responseAs[ErrorResponse].code shouldEqual "NOT_FOUND"
}
}
"PUT /api/v1/users/{id}" should "update user successfully" in {
val request = UpdateUserRequest(Some("Jane Doe"), None)
val updatedUser = User(1, "Jane Doe", "john@example.com", 30)
when(mockUserService.updateUser(1L, Some("Jane Doe"), None))
.thenReturn(Future.successful(Right(updatedUser)))
Put("/api/v1/users/1", request) ~> userRoutes.routes ~> check {
status shouldEqual StatusCodes.OK
responseAs[User] shouldEqual updatedUser
}
}
}
// End-to-end API testing
class UserAPIIntegrationTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll with ScalaFutures {
import JsonProtocols._
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
implicit val system: ActorSystem[Nothing] = ActorSystem(Behaviors.empty, "test-system")
implicit val ec = system.executionContext
var serverBinding: Http.ServerBinding = _
var database: Database = _
override def beforeAll(): Unit = {
// Setup test database
database = Database.forConfig("h2test")
val userRepository = new SlickUserRepository(database)
val mockEmailService = mock[EmailService]
val mockAuditService = mock[AuditService]
// Setup mocks
when(mockEmailService.sendWelcomeEmail(any[User])).thenReturn(Future.successful(true))
when(mockAuditService.logUserAction(any[Long], any[String], any[Map[String, Any]])).thenReturn(Future.successful(()))
val userService = new UserService(userRepository, mockEmailService, mockAuditService)
val userRoutes = new UserRoutes(userService)
// Start server
serverBinding = Await.result(
Http().newServerAt("localhost", 0).bind(userRoutes.routes),
10.seconds
)
}
override def afterAll(): Unit = {
if (serverBinding != null) {
Await.result(serverBinding.unbind(), 5.seconds)
}
if (database != null) {
database.close()
}
Await.result(system.terminate(), 5.seconds)
}
"User API" should "handle complete user lifecycle" in {
val baseUrl = s"http://localhost:${serverBinding.localAddress.getPort}"
// Create user
val createRequest = HttpRequest(
method = HttpMethods.POST,
uri = s"$baseUrl/api/v1/users",
entity = HttpEntity(
ContentTypes.`application/json`,
CreateUserRequest("Integration Test", "integration@example.com", 25).toJson.toString
)
)
val createResponse = Http().singleRequest(createRequest).futureValue
createResponse.status shouldEqual StatusCodes.Created
val createdUser = Unmarshal(createResponse.entity).to[User].futureValue
createdUser.name shouldEqual "Integration Test"
createdUser.id should be > 0L
// Get user
val getRequest = HttpRequest(
method = HttpMethods.GET,
uri = s"$baseUrl/api/v1/users/${createdUser.id}"
)
val getResponse = Http().singleRequest(getRequest).futureValue
getResponse.status shouldEqual StatusCodes.OK
val retrievedUser = Unmarshal(getResponse.entity).to[User].futureValue
retrievedUser shouldEqual createdUser
// Update user
val updateRequest = HttpRequest(
method = HttpMethods.PUT,
uri = s"$baseUrl/api/v1/users/${createdUser.id}",
entity = HttpEntity(
ContentTypes.`application/json`,
UpdateUserRequest(Some("Updated Name"), None).toJson.toString
)
)
val updateResponse = Http().singleRequest(updateRequest).futureValue
updateResponse.status shouldEqual StatusCodes.OK
val updatedUser = Unmarshal(updateResponse.entity).to[User].futureValue
updatedUser.name shouldEqual "Updated Name"
updatedUser.email shouldEqual createdUser.email
}
}
Test Automation and CI Integration
Comprehensive Test Suite Organization
// Test categories and organization
import org.scalatest.Tag
object TestTags {
object UnitTest extends Tag("UnitTest")
object IntegrationTest extends Tag("IntegrationTest")
object PerformanceTest extends Tag("PerformanceTest")
object SlowTest extends Tag("SlowTest")
object DatabaseTest extends Tag("DatabaseTest")
object ExternalServiceTest extends Tag("ExternalServiceTest")
}
// Base test traits
trait UnitTestBase extends AnyFlatSpec with Matchers with MockitoSugar {
// Common setup for unit tests
}
trait IntegrationTestBase extends AnyFlatSpec with Matchers with BeforeAndAfterAll with BeforeAndAfterEach {
// Common setup for integration tests
}
trait DatabaseTestBase extends IntegrationTestBase {
// Database-specific setup
var database: Database = _
override def beforeAll(): Unit = {
super.beforeAll()
// Setup test database
}
override def afterAll(): Unit = {
if (database != null) {
database.close()
}
super.afterAll()
}
}
// Test data builders
object TestDataBuilders {
def userBuilder(
id: Long = 0,
name: String = "Test User",
email: String = "test@example.com",
age: Int = 25,
active: Boolean = true
): User = User(id, name, email, age, active)
def createUserRequestBuilder(
name: String = "Test User",
email: String = "test@example.com",
age: Int = 25
): CreateUserRequest = CreateUserRequest(name, email, age)
// Builder pattern for complex objects
case class UserBuilder(
id: Long = 0,
name: String = "Test User",
email: String = "test@example.com",
age: Int = 25,
active: Boolean = true
) {
def withId(id: Long): UserBuilder = copy(id = id)
def withName(name: String): UserBuilder = copy(name = name)
def withEmail(email: String): UserBuilder = copy(email = email)
def withAge(age: Int): UserBuilder = copy(age = age)
def inactive: UserBuilder = copy(active = false)
def build: User = User(id, name, email, age, active)
}
// Factory methods for common scenarios
object Users {
def validUser: User = UserBuilder().build
def userWithId(id: Long): User = UserBuilder().withId(id).build
def minorUser: User = UserBuilder().withAge(17).build
def inactiveUser: User = UserBuilder().inactive.build
def userWithEmail(email: String): User = UserBuilder().withEmail(email).build
}
}
// Performance testing framework
class PerformanceTestSuite extends AnyFlatSpec with Matchers with ScalaFutures {
import TestTags._
import scala.concurrent.duration._
"UserService performance" should "handle high load" taggedAs(PerformanceTest, SlowTest) in {
val userService = createUserService() // Implementation-specific
val startTime = System.nanoTime()
val futures = (1 to 1000).map { i =>
userService.createUser(s"User $i", s"user$i@example.com", 25)
}
val results = Future.sequence(futures).futureValue(timeout(30.seconds))
val endTime = System.nanoTime()
val duration = (endTime - startTime).nanos
val throughput = results.size.toDouble / duration.toSeconds
results should have size 1000
results.count(_.isRight) should be >= 900 // Allow some failures
throughput should be > 100.0 // requests per second
println(s"Performance: ${results.size} operations in ${duration.toMillis}ms, throughput: $throughput ops/sec")
}
private def createUserService(): UserService = {
// Create service with real or mock dependencies
// This would be implementation-specific
???
}
}
// Test report generation
object TestReporting {
case class TestResult(
testName: String,
className: String,
status: String,
duration: Duration,
error: Option[String] = None
)
case class TestSummary(
totalTests: Int,
passed: Int,
failed: Int,
skipped: Int,
duration: Duration,
coverage: Double
)
def generateHTMLReport(results: List[TestResult], summary: TestSummary): String = {
s"""
<!DOCTYPE html>
<html>
<head>
<title>Test Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.summary { background: #f5f5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
.passed { color: green; }
.failed { color: red; }
.skipped { color: orange; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
</style>
</head>
<body>
<h1>Test Report</h1>
<div class="summary">
<h2>Summary</h2>
<p>Total Tests: ${summary.totalTests}</p>
<p class="passed">Passed: ${summary.passed}</p>
<p class="failed">Failed: ${summary.failed}</p>
<p class="skipped">Skipped: ${summary.skipped}</p>
<p>Duration: ${summary.duration.toSeconds}s</p>
<p>Coverage: ${summary.coverage}%</p>
</div>
<h2>Test Results</h2>
<table>
<tr>
<th>Test Name</th>
<th>Class</th>
<th>Status</th>
<th>Duration</th>
<th>Error</th>
</tr>
${results.map { result =>
s"""
<tr>
<td>${result.testName}</td>
<td>${result.className}</td>
<td class="${result.status.toLowerCase}">${result.status}</td>
<td>${result.duration.toMillis}ms</td>
<td>${result.error.getOrElse("")}</td>
</tr>
"""
}.mkString}
</table>
</body>
</html>
"""
}
}
Conclusion
Advanced testing in Scala encompasses multiple strategies and techniques that work together to ensure application reliability:
Property-Based Testing Benefits:
- Discovers edge cases through random input generation
- Tests invariants and mathematical properties
- Provides confidence in algorithm correctness
- Scales testing beyond manual test case creation
Mocking and Test Doubles:
- Isolates units under test from dependencies
- Enables fast, reliable unit testing
- Supports complex interaction verification
- Facilitates testing of error conditions
Integration Testing Strategies:
- Validates component interactions
- Tests database integration and transactions
- Verifies HTTP API behavior end-to-end
- Ensures system functionality across boundaries
Test Automation Framework:
- Organizes tests by type and execution requirements
- Provides consistent test data creation
- Enables performance and load testing
- Generates comprehensive test reports
Best Practices:
- Use appropriate testing strategy for each component level
- Balance between unit, integration, and end-to-end tests
- Maintain test independence and reliability
- Focus on meaningful assertions and clear test intent
- Automate test execution in CI/CD pipelines
Testing Pyramid Structure:
- Many fast, isolated unit tests
- Moderate number of integration tests
- Few but comprehensive end-to-end tests
- Performance tests for critical paths
Quality Metrics:
- Code coverage measurement and targets
- Test execution time and reliability
- Defect detection effectiveness
- Regression prevention capability
Continuous Improvement:
- Regular review of test effectiveness
- Refactoring of test code for maintainability
- Investment in test infrastructure and tooling
- Team education on testing best practices
Modern Scala testing leverages the language's strengths while addressing the complexities of distributed systems, concurrent programming, and integration challenges. The goal is building confidence in system behavior through comprehensive, maintainable, and efficient test suites that catch issues early and prevent regressions.
Comments
Be the first to comment on this lesson!