Testing Strategies: Unit, Integration, Property-Based Testing

Comprehensive testing is fundamental to building reliable Scala applications. This lesson covers advanced testing strategies including unit testing, integration testing, property-based testing, and test-driven development practices using ScalaTest, ScalaCheck, and other testing frameworks in the Scala ecosystem.

Unit Testing with ScalaTest

Advanced ScalaTest Features and Testing Styles

// TestConfiguration.scala - Centralized test configuration
package com.example.testing.config

import org.scalatest._
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import org.scalatest.funspec.AnyFunSpec
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.concurrent.ScalaFutures
import org.scalatest.time.{Millis, Seconds, Span}
import cats.effect.testing.scalatest.AsyncIOSpec
import cats.effect.IO
import scala.concurrent.duration._

// Base test traits for consistency
trait BaseSpec extends AnyWordSpec with Matchers with ScalaFutures {

  // Timeout configuration
  implicit override val patienceConfig: PatienceConfig = PatienceConfig(
    timeout = scaled(Span(5, Seconds)),
    interval = scaled(Span(150, Millis))
  )

  // Common test utilities
  def eventually[T](assertion: => T): T = {
    org.scalatest.concurrent.Eventually.eventually {
      assertion
    }
  }

  // Test data builders
  def randomString(length: Int = 10): String = {
    scala.util.Random.alphanumeric.take(length).mkString
  }

  def randomId: Long = scala.util.Random.nextLong().abs

  // Custom matchers
  def beValidEmail = Matcher[String] { email =>
    val emailRegex = """^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$""".r
    MatchResult(
      emailRegex.matches(email),
      s"$email was not a valid email address",
      s"$email was a valid email address"
    )
  }

  def beWithinTolerance(expected: Double, tolerance: Double) = 
    Matcher[Double] { actual =>
      val diff = math.abs(actual - expected)
      MatchResult(
        diff <= tolerance,
        s"$actual was not within $tolerance of $expected (difference: $diff)",
        s"$actual was within $tolerance of $expected"
      )
    }
}

trait AsyncBaseSpec extends AsyncIOSpec with Matchers {

  // IO-specific test utilities
  def eventuallyIO[T](assertion: IO[T]): IO[T] = {
    def retry(attempt: Int): IO[T] = {
      assertion.attempt.flatMap {
        case Right(value) => IO.pure(value)
        case Left(_) if attempt < 10 => 
          IO.sleep(100.millis) *> retry(attempt + 1)
        case Left(error) => IO.raiseError(error)
      }
    }
    retry(0)
  }
}

// User.scala - Domain model for testing
case class User(
  id: Long,
  email: String,
  name: String,
  age: Int,
  isActive: Boolean = true
) {
  require(email.contains("@"), "Email must contain @")
  require(age >= 0, "Age must be non-negative")
  require(name.nonEmpty, "Name cannot be empty")
}

object User {
  def create(email: String, name: String, age: Int): Either[String, User] = {
    for {
      _ <- validateEmail(email)
      _ <- validateName(name)
      _ <- validateAge(age)
    } yield User(
      id = scala.util.Random.nextLong().abs,
      email = email,
      name = name,
      age = age
    )
  }

  private def validateEmail(email: String): Either[String, Unit] = {
    if (email.contains("@") && email.contains(".")) Right(())
    else Left("Invalid email format")
  }

  private def validateName(name: String): Either[String, Unit] = {
    if (name.trim.nonEmpty) Right(())
    else Left("Name cannot be empty")
  }

  private def validateAge(age: Int): Either[String, Unit] = {
    if (age >= 0 && age <= 150) Right(())
    else Left("Age must be between 0 and 150")
  }
}

// UserService.scala - Service layer for testing
import cats.effect.IO
import cats.implicits._

trait UserRepository {
  def save(user: User): IO[User]
  def findById(id: Long): IO[Option[User]]
  def findByEmail(email: String): IO[Option[User]]
  def update(user: User): IO[User]
  def delete(id: Long): IO[Boolean]
  def findAll(): IO[List[User]]
}

class UserService(repository: UserRepository) {

  def createUser(email: String, name: String, age: Int): IO[Either[String, User]] = {
    User.create(email, name, age) match {
      case Right(user) =>
        repository.findByEmail(email).flatMap {
          case Some(_) => IO.pure(Left("User with this email already exists"))
          case None => repository.save(user).map(Right(_))
        }
      case Left(error) => IO.pure(Left(error))
    }
  }

  def getUserById(id: Long): IO[Option[User]] = {
    repository.findById(id)
  }

  def updateUser(user: User): IO[Either[String, User]] = {
    repository.findById(user.id).flatMap {
      case Some(_) => repository.update(user).map(Right(_))
      case None => IO.pure(Left("User not found"))
    }
  }

  def deactivateUser(id: Long): IO[Either[String, User]] = {
    repository.findById(id).flatMap {
      case Some(user) => 
        val deactivatedUser = user.copy(isActive = false)
        repository.update(deactivatedUser).map(Right(_))
      case None => IO.pure(Left("User not found"))
    }
  }

  def getActiveUsers(): IO[List[User]] = {
    repository.findAll().map(_.filter(_.isActive))
  }

  def getUserStatistics(): IO[UserStatistics] = {
    repository.findAll().map { users =>
      UserStatistics(
        totalUsers = users.length,
        activeUsers = users.count(_.isActive),
        averageAge = if (users.nonEmpty) users.map(_.age).sum.toDouble / users.length else 0.0,
        ageDistribution = users.groupBy(u => u.age / 10 * 10).view.mapValues(_.length).toMap
      )
    }
  }
}

case class UserStatistics(
  totalUsers: Int,
  activeUsers: Int,
  averageAge: Double,
  ageDistribution: Map[Int, Int]
)

// UserSpec.scala - Comprehensive unit tests
package com.example.testing.unit

import com.example.testing.config.BaseSpec
import org.scalatest.prop.TableDrivenPropertyChecks

class UserSpec extends BaseSpec with TableDrivenPropertyChecks {

  "User" when {

    "creating a valid user" should {
      "create user successfully with valid data" in {
        val user = User(1L, "test@example.com", "John Doe", 25)

        user.id shouldBe 1L
        user.email shouldBe "test@example.com"
        user.name shouldBe "John Doe"
        user.age shouldBe 25
        user.isActive shouldBe true
      }

      "use factory method successfully" in {
        val result = User.create("test@example.com", "John Doe", 25)

        result should be a 'right
        result.right.get.email shouldBe "test@example.com"
        result.right.get.name shouldBe "John Doe"
        result.right.get.age shouldBe 25
      }
    }

    "validating user data" should {
      val validationTestCases = Table(
        ("email", "name", "age", "expectedResult"),
        ("test@example.com", "John Doe", 25, "success"),
        ("invalid-email", "John Doe", 25, "invalid email"),
        ("test@example.com", "", 25, "empty name"),
        ("test@example.com", "John Doe", -1, "negative age"),
        ("test@example.com", "John Doe", 200, "age too high"),
        ("", "John Doe", 25, "empty email"),
        ("test@", "John Doe", 25, "invalid email format")
      )

      forAll(validationTestCases) { (email, name, age, expectedResult) =>
        s"return $expectedResult for email=$email, name=$name, age=$age" in {
          val result = User.create(email, name, age)

          expectedResult match {
            case "success" => result should be a 'right
            case "invalid email" | "empty email" | "invalid email format" => 
              result should be a 'left
              result.left.get should include("email")
            case "empty name" => 
              result should be a 'left
              result.left.get should include("Name")
            case "negative age" | "age too high" => 
              result should be a 'left
              result.left.get should include("Age")
          }
        }
      }
    }

    "enforcing business rules" should {
      "require valid email format" in {
        an[IllegalArgumentException] should be thrownBy {
          User(1L, "invalid-email", "John Doe", 25)
        }
      }

      "require non-negative age" in {
        an[IllegalArgumentException] should be thrownBy {
          User(1L, "test@example.com", "John Doe", -1)
        }
      }

      "require non-empty name" in {
        an[IllegalArgumentException] should be thrownBy {
          User(1L, "test@example.com", "", 25)
        }
      }
    }

    "comparing users" should {
      "be equal when all fields match" in {
        val user1 = User(1L, "test@example.com", "John Doe", 25)
        val user2 = User(1L, "test@example.com", "John Doe", 25)

        user1 shouldEqual user2
      }

      "be different when fields don't match" in {
        val user1 = User(1L, "test@example.com", "John Doe", 25)
        val user2 = User(2L, "test@example.com", "John Doe", 25)

        user1 should not equal user2
      }
    }
  }

  "User email validation" should {
    "accept valid email addresses" in {
      val validEmails = List(
        "test@example.com",
        "user.name@domain.co.uk",
        "123@test.org"
      )

      validEmails.foreach { email =>
        email should beValidEmail
      }
    }

    "reject invalid email addresses" in {
      val invalidEmails = List(
        "invalid-email",
        "@domain.com",
        "user@",
        ""
      )

      invalidEmails.foreach { email =>
        email should not(beValidEmail)
      }
    }
  }
}

// UserServiceSpec.scala - Service layer testing with mocks
package com.example.testing.unit

import com.example.testing.config.AsyncBaseSpec
import cats.effect.IO
import org.scalamock.scalatest.MockFactory

class UserServiceSpec extends AsyncBaseSpec with MockFactory {

  "UserService" when {

    "creating a user" should {
      "create user successfully when email is unique" in {
        val mockRepo = mock[UserRepository]
        val service = new UserService(mockRepo)

        val newUser = User(1L, "test@example.com", "John Doe", 25)

        (mockRepo.findByEmail _)
          .expects("test@example.com")
          .returning(IO.pure(None))

        (mockRepo.save _)
          .expects(*)
          .returning(IO.pure(newUser))

        val result = service.createUser("test@example.com", "John Doe", 25)

        result.asserting { either =>
          either should be a 'right
          either.right.get.email shouldBe "test@example.com"
        }
      }

      "fail when email already exists" in {
        val mockRepo = mock[UserRepository]
        val service = new UserService(mockRepo)

        val existingUser = User(1L, "test@example.com", "Existing User", 30)

        (mockRepo.findByEmail _)
          .expects("test@example.com")
          .returning(IO.pure(Some(existingUser)))

        val result = service.createUser("test@example.com", "John Doe", 25)

        result.asserting { either =>
          either should be a 'left
          either.left.get should include("already exists")
        }
      }

      "fail with invalid user data" in {
        val mockRepo = mock[UserRepository]
        val service = new UserService(mockRepo)

        val result = service.createUser("invalid-email", "John Doe", 25)

        result.asserting { either =>
          either should be a 'left
          either.left.get should include("email")
        }
      }
    }

    "getting user by ID" should {
      "return user when exists" in {
        val mockRepo = mock[UserRepository]
        val service = new UserService(mockRepo)

        val user = User(1L, "test@example.com", "John Doe", 25)

        (mockRepo.findById _)
          .expects(1L)
          .returning(IO.pure(Some(user)))

        val result = service.getUserById(1L)

        result.asserting { maybeUser =>
          maybeUser shouldBe defined
          maybeUser.get shouldEqual user
        }
      }

      "return None when user doesn't exist" in {
        val mockRepo = mock[UserRepository]
        val service = new UserService(mockRepo)

        (mockRepo.findById _)
          .expects(999L)
          .returning(IO.pure(None))

        val result = service.getUserById(999L)

        result.asserting { maybeUser =>
          maybeUser shouldBe empty
        }
      }
    }

    "updating a user" should {
      "update successfully when user exists" in {
        val mockRepo = mock[UserRepository]
        val service = new UserService(mockRepo)

        val originalUser = User(1L, "test@example.com", "John Doe", 25)
        val updatedUser = originalUser.copy(name = "Jane Doe")

        (mockRepo.findById _)
          .expects(1L)
          .returning(IO.pure(Some(originalUser)))

        (mockRepo.update _)
          .expects(updatedUser)
          .returning(IO.pure(updatedUser))

        val result = service.updateUser(updatedUser)

        result.asserting { either =>
          either should be a 'right
          either.right.get.name shouldBe "Jane Doe"
        }
      }

      "fail when user doesn't exist" in {
        val mockRepo = mock[UserRepository]
        val service = new UserService(mockRepo)

        val user = User(999L, "test@example.com", "John Doe", 25)

        (mockRepo.findById _)
          .expects(999L)
          .returning(IO.pure(None))

        val result = service.updateUser(user)

        result.asserting { either =>
          either should be a 'left
          either.left.get should include("not found")
        }
      }
    }

    "deactivating a user" should {
      "deactivate successfully when user exists" in {
        val mockRepo = mock[UserRepository]
        val service = new UserService(mockRepo)

        val activeUser = User(1L, "test@example.com", "John Doe", 25, isActive = true)
        val deactivatedUser = activeUser.copy(isActive = false)

        (mockRepo.findById _)
          .expects(1L)
          .returning(IO.pure(Some(activeUser)))

        (mockRepo.update _)
          .expects(deactivatedUser)
          .returning(IO.pure(deactivatedUser))

        val result = service.deactivateUser(1L)

        result.asserting { either =>
          either should be a 'right
          either.right.get.isActive shouldBe false
        }
      }
    }

    "getting active users" should {
      "return only active users" in {
        val mockRepo = mock[UserRepository]
        val service = new UserService(mockRepo)

        val users = List(
          User(1L, "user1@example.com", "User 1", 25, isActive = true),
          User(2L, "user2@example.com", "User 2", 30, isActive = false),
          User(3L, "user3@example.com", "User 3", 35, isActive = true)
        )

        (mockRepo.findAll _)
          .expects()
          .returning(IO.pure(users))

        val result = service.getActiveUsers()

        result.asserting { activeUsers =>
          activeUsers should have length 2
          activeUsers.forall(_.isActive) shouldBe true
        }
      }
    }

    "calculating user statistics" should {
      "return correct statistics" in {
        val mockRepo = mock[UserRepository]
        val service = new UserService(mockRepo)

        val users = List(
          User(1L, "user1@example.com", "User 1", 25, isActive = true),
          User(2L, "user2@example.com", "User 2", 35, isActive = false),
          User(3L, "user3@example.com", "User 3", 45, isActive = true),
          User(4L, "user4@example.com", "User 4", 22, isActive = true)
        )

        (mockRepo.findAll _)
          .expects()
          .returning(IO.pure(users))

        val result = service.getUserStatistics()

        result.asserting { stats =>
          stats.totalUsers shouldBe 4
          stats.activeUsers shouldBe 3
          stats.averageAge should beWithinTolerance(31.75, 0.01)
          stats.ageDistribution should contain key 20
          stats.ageDistribution should contain key 30
          stats.ageDistribution should contain key 40
        }
      }
    }
  }
}

Property-Based Testing with ScalaCheck

Advanced ScalaCheck Generators and Properties

// UserGenerators.scala - Custom generators for property-based testing
package com.example.testing.generators

import org.scalacheck._
import org.scalacheck.Arbitrary._

object UserGenerators {

  // Basic generators
  val genValidEmail: Gen[String] = for {
    username <- Gen.alphaNumStr.suchThat(_.nonEmpty)
    domain <- Gen.alphaNumStr.suchThat(_.nonEmpty)
    tld <- Gen.oneOf("com", "org", "net", "edu", "gov")
  } yield s"$username@$domain.$tld"

  val genInvalidEmail: Gen[String] = Gen.oneOf(
    Gen.alphaNumStr.suchThat(_.nonEmpty), // No @ symbol
    Gen.const("@domain.com"), // No username
    Gen.const("username@"), // No domain
    Gen.const(""), // Empty string
    genValidEmail.map(_ + " ") // Trailing space
  )

  val genValidName: Gen[String] = for {
    firstName <- Gen.alphaUpperStr.suchThat(_.nonEmpty)
    lastName <- Gen.alphaUpperStr.suchThat(_.nonEmpty)
  } yield s"$firstName $lastName"

  val genInvalidName: Gen[String] = Gen.oneOf(
    Gen.const(""), // Empty string
    Gen.const("   "), // Only whitespace
    Gen.numStr // Only numbers
  )

  val genValidAge: Gen[Int] = Gen.choose(0, 150)
  val genInvalidAge: Gen[Int] = Gen.oneOf(
    Gen.choose(-100, -1), // Negative ages
    Gen.choose(151, 1000) // Ages too high
  )

  // Complex generators
  val genValidUser: Gen[User] = for {
    id <- Gen.posNum[Long]
    email <- genValidEmail
    name <- genValidName
    age <- genValidAge
    isActive <- arbitrary[Boolean]
  } yield User(id, email, name, age, isActive)

  val genUserWithInvalidEmail: Gen[User] = for {
    id <- Gen.posNum[Long]
    email <- genInvalidEmail
    name <- genValidName
    age <- genValidAge
    isActive <- arbitrary[Boolean]
  } yield User(id, email, name, age, isActive)

  // Generator for user updates
  def genUserUpdate(original: User): Gen[User] = for {
    newName <- Gen.option(genValidName)
    newAge <- Gen.option(genValidAge)
    newActive <- Gen.option(arbitrary[Boolean])
  } yield original.copy(
    name = newName.getOrElse(original.name),
    age = newAge.getOrElse(original.age),
    isActive = newActive.getOrElse(original.isActive)
  )

  // Generator for lists of users
  val genUserList: Gen[List[User]] = Gen.listOfN(10, genValidUser)

  val genNonEmptyUserList: Gen[List[User]] = 
    Gen.nonEmptyListOf(genValidUser).suchThat(_.nonEmpty)

  // Shrinkers for better test case minimization
  implicit val shrinkUser: Shrink[User] = Shrink { user =>
    Shrink.shrink(user.name).filter(_.nonEmpty).map(name => user.copy(name = name)) #:::
    Shrink.shrink(user.age).filter(_ >= 0).map(age => user.copy(age = age))
  }

  // Arbitrary instances
  implicit val arbitraryUser: Arbitrary[User] = Arbitrary(genValidUser)
  implicit val arbitraryValidEmail: Arbitrary[String] = Arbitrary(genValidEmail)
}

// UserPropertySpec.scala - Property-based tests
package com.example.testing.properties

import com.example.testing.config.BaseSpec
import com.example.testing.generators.UserGenerators._
import org.scalacheck.Prop._
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
import cats.effect.IO
import cats.effect.unsafe.implicits.global

class UserPropertySpec extends BaseSpec with ScalaCheckPropertyChecks {

  "User validation properties" should {

    "always succeed with valid data" in {
      forAll(genValidEmail, genValidName, genValidAge) { (email, name, age) =>
        val result = User.create(email, name, age)
        result should be a 'right
      }
    }

    "always fail with invalid emails" in {
      forAll(genInvalidEmail, genValidName, genValidAge) { (email, name, age) =>
        whenever(email.trim.isEmpty || !email.contains("@")) {
          val result = User.create(email, name, age)
          result should be a 'left
        }
      }
    }

    "always fail with invalid names" in {
      forAll(genValidEmail, genInvalidName, genValidAge) { (email, name, age) =>
        whenever(name.trim.isEmpty) {
          val result = User.create(email, name, age)
          result should be a 'left
        }
      }
    }

    "always fail with invalid ages" in {
      forAll(genValidEmail, genValidName, genInvalidAge) { (email, name, age) =>
        whenever(age < 0 || age > 150) {
          val result = User.create(email, name, age)
          result should be a 'left
        }
      }
    }

    "preserve email format" in {
      forAll(genValidUser) { user =>
        user.email should include("@")
        user.email should include(".")
        user.email.count(_ == '@') shouldBe 1
      }
    }

    "maintain age constraints" in {
      forAll(genValidUser) { user =>
        user.age should be >= 0
        user.age should be <= 150
      }
    }

    "ensure name is not empty" in {
      forAll(genValidUser) { user =>
        user.name.trim should not be empty
      }
    }
  }

  "User equality properties" should {

    "be reflexive" in {
      forAll(genValidUser) { user =>
        user shouldEqual user
      }
    }

    "be symmetric" in {
      forAll(genValidUser, genValidUser) { (user1, user2) =>
        (user1 == user2) shouldEqual (user2 == user1)
      }
    }

    "be transitive" in {
      forAll(genValidUser) { user =>
        val sameUser1 = user.copy()
        val sameUser2 = user.copy()

        if (user == sameUser1 && sameUser1 == sameUser2) {
          user shouldEqual sameUser2
        }
      }
    }
  }

  "UserService properties" should {

    "creating and retrieving users" in {
      forAll(genValidUser) { user =>
        val mockRepo = new InMemoryUserRepository()
        val service = new UserService(mockRepo)

        val result = for {
          created <- service.createUser(user.email, user.name, user.age)
          retrieved <- created match {
            case Right(u) => service.getUserById(u.id).map(Some(_))
            case Left(_) => IO.pure(None)
          }
        } yield retrieved

        val maybeUser = result.unsafeRunSync().flatten
        maybeUser should be(defined)
        maybeUser.get.email shouldBe user.email
        maybeUser.get.name shouldBe user.name
        maybeUser.get.age shouldBe user.age
      }
    }

    "user statistics consistency" in {
      forAll(genUserList) { users =>
        val mockRepo = new InMemoryUserRepository()
        users.foreach(user => mockRepo.users.put(user.id, user))

        val service = new UserService(mockRepo)
        val stats = service.getUserStatistics().unsafeRunSync()

        stats.totalUsers shouldBe users.length
        stats.activeUsers shouldBe users.count(_.isActive)

        if (users.nonEmpty) {
          val expectedAverage = users.map(_.age).sum.toDouble / users.length
          stats.averageAge should beWithinTolerance(expectedAverage, 0.01)
        }
      }
    }

    "deactivation preserves user data except active status" in {
      forAll(genValidUser) { user =>
        whenever(user.isActive) {
          val mockRepo = new InMemoryUserRepository()
          mockRepo.users.put(user.id, user)

          val service = new UserService(mockRepo)
          val result = service.deactivateUser(user.id).unsafeRunSync()

          result should be a 'right
          val deactivatedUser = result.right.get

          deactivatedUser.id shouldBe user.id
          deactivatedUser.email shouldBe user.email
          deactivatedUser.name shouldBe user.name
          deactivatedUser.age shouldBe user.age
          deactivatedUser.isActive shouldBe false
        }
      }
    }
  }

  "Performance properties" should {

    "finding users should be fast" in {
      forAll(genNonEmptyUserList) { users =>
        val mockRepo = new InMemoryUserRepository()
        users.foreach(user => mockRepo.users.put(user.id, user))

        val service = new UserService(mockRepo)
        val startTime = System.nanoTime()

        users.foreach { user =>
          service.getUserById(user.id).unsafeRunSync()
        }

        val endTime = System.nanoTime()
        val duration = (endTime - startTime) / 1000000 // Convert to milliseconds

        // This should complete in reasonable time
        duration should be < 1000L // Less than 1 second
      }
    }
  }
}

// InMemoryUserRepository.scala - Test implementation
import scala.collection.mutable
import cats.effect.IO

class InMemoryUserRepository extends UserRepository {
  val users: mutable.Map[Long, User] = mutable.Map.empty
  private var nextId: Long = 1L

  def save(user: User): IO[User] = IO {
    val savedUser = if (user.id == 0) user.copy(id = nextId) else user
    if (user.id == 0) nextId += 1
    users.put(savedUser.id, savedUser)
    savedUser
  }

  def findById(id: Long): IO[Option[User]] = IO {
    users.get(id)
  }

  def findByEmail(email: String): IO[Option[User]] = IO {
    users.values.find(_.email == email)
  }

  def update(user: User): IO[User] = IO {
    users.put(user.id, user)
    user
  }

  def delete(id: Long): IO[Boolean] = IO {
    users.remove(id).isDefined
  }

  def findAll(): IO[List[User]] = IO {
    users.values.toList
  }
}

Integration Testing

Database Integration and HTTP API Testing

// DatabaseIntegrationSpec.scala - Database integration tests
package com.example.testing.integration

import com.example.testing.config.AsyncBaseSpec
import cats.effect.IO
import doobie._
import doobie.implicits._
import doobie.hikari.HikariTransactor
import com.dimafeng.testcontainers.PostgreSQLContainer
import com.dimafeng.testcontainers.scalatest.TestContainerForAll
import org.testcontainers.utility.DockerImageName

class DatabaseIntegrationSpec extends AsyncBaseSpec with TestContainerForAll {

  override val containerDef = PostgreSQLContainer.Def(
    dockerImageName = DockerImageName.parse("postgres:15"),
    databaseName = "testdb",
    username = "test",
    password = "test"
  )

  def transactor(container: PostgreSQLContainer): IO[HikariTransactor[IO]] = {
    HikariTransactor.newHikariTransactor[IO](
      "org.postgresql.Driver",
      container.jdbcUrl,
      container.username,
      container.password,
      cats.effect.unsafe.implicits.global.compute
    )
  }

  "Database operations" should {

    "create and retrieve users" in withContainers { container =>
      val setup = for {
        xa <- transactor(container)
        _ <- createUserTable.run.transact(xa)
        user = User(0L, "test@example.com", "Test User", 25)
        saved <- insertUser(user).run.transact(xa)
        retrieved <- selectUserById(saved).unique.transact(xa)
      } yield retrieved

      setup.asserting { user =>
        user.email shouldBe "test@example.com"
        user.name shouldBe "Test User"
        user.age shouldBe 25
      }
    }

    "handle duplicate email constraints" in withContainers { container =>
      val setup = for {
        xa <- transactor(container)
        _ <- createUserTable.run.transact(xa)
        user1 = User(0L, "test@example.com", "User 1", 25)
        user2 = User(0L, "test@example.com", "User 2", 30)
        _ <- insertUser(user1).run.transact(xa)
        result <- insertUser(user2).run.transact(xa).attempt
      } yield result

      setup.asserting { result =>
        result should be a 'left
      }
    }

    "update user information" in withContainers { container =>
      val setup = for {
        xa <- transactor(container)
        _ <- createUserTable.run.transact(xa)
        user = User(0L, "test@example.com", "Original Name", 25)
        saved <- insertUser(user).run.transact(xa)
        updated = User(saved, "test@example.com", "Updated Name", 30)
        _ <- updateUser(updated).run.transact(xa)
        retrieved <- selectUserById(saved).unique.transact(xa)
      } yield retrieved

      setup.asserting { user =>
        user.name shouldBe "Updated Name"
        user.age shouldBe 30
      }
    }

    "delete users" in withContainers { container =>
      val setup = for {
        xa <- transactor(container)
        _ <- createUserTable.run.transact(xa)
        user = User(0L, "test@example.com", "Test User", 25)
        saved <- insertUser(user).run.transact(xa)
        _ <- deleteUser(saved).run.transact(xa)
        result <- selectUserById(saved).option.transact(xa)
      } yield result

      setup.asserting { maybeUser =>
        maybeUser shouldBe empty
      }
    }

    "handle concurrent operations" in withContainers { container =>
      val setup = for {
        xa <- transactor(container)
        _ <- createUserTable.run.transact(xa)
        users = (1 to 10).map(i => User(0L, s"user$i@example.com", s"User $i", 20 + i))
        saved <- users.toList.traverse(user => insertUser(user).run.transact(xa))
        all <- selectAllUsers.to[List].transact(xa)
      } yield (saved, all)

      setup.asserting { case (saved, all) =>
        saved should have length 10
        all should have length 10
        all.map(_.email).toSet should have size 10
      }
    }
  }

  // SQL queries
  val createUserTable: Update0 = sql"""
    CREATE TABLE IF NOT EXISTS users (
      id BIGSERIAL PRIMARY KEY,
      email VARCHAR(255) UNIQUE NOT NULL,
      name VARCHAR(255) NOT NULL,
      age INTEGER NOT NULL,
      is_active BOOLEAN NOT NULL DEFAULT TRUE,
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
      updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
  """.update

  def insertUser(user: User): Update0 = sql"""
    INSERT INTO users (email, name, age, is_active)
    VALUES (${user.email}, ${user.name}, ${user.age}, ${user.isActive})
  """.update

  def selectUserById(id: Long): Query0[User] = sql"""
    SELECT id, email, name, age, is_active
    FROM users
    WHERE id = $id
  """.query[User]

  def updateUser(user: User): Update0 = sql"""
    UPDATE users
    SET email = ${user.email}, name = ${user.name}, age = ${user.age}, is_active = ${user.isActive}
    WHERE id = ${user.id}
  """.update

  def deleteUser(id: Long): Update0 = sql"""
    DELETE FROM users WHERE id = $id
  """.update

  val selectAllUsers: Query0[User] = sql"""
    SELECT id, email, name, age, is_active FROM users
  """.query[User]
}

// HttpApiIntegrationSpec.scala - HTTP API integration tests
package com.example.testing.integration

import com.example.testing.config.AsyncBaseSpec
import cats.effect.IO
import org.http4s._
import org.http4s.client.Client
import org.http4s.ember.client.EmberClientBuilder
import org.http4s.ember.server.EmberServerBuilder
import org.http4s.server.Server
import org.http4s.circe.CirceEntityCodec._
import io.circe.generic.auto._
import com.comcast.ip4s._

class HttpApiIntegrationSpec extends AsyncBaseSpec {

  def withServer[A](routes: HttpRoutes[IO])(test: Uri => IO[A]): IO[A] = {
    val httpApp = routes.orNotFound

    EmberServerBuilder
      .default[IO]
      .withHost(host"localhost")
      .withPort(port"0") // Use any available port
      .withHttpApp(httpApp)
      .build
      .use { server =>
        val baseUri = Uri.fromString(s"http://localhost:${server.address.getPort}").toOption.get
        test(baseUri)
      }
  }

  def withClient[A](test: Client[IO] => IO[A]): IO[A] = {
    EmberClientBuilder.default[IO].build.use(test)
  }

  "User API" should {

    "create user via POST" in {
      val routes = userRoutes

      withServer(routes) { baseUri =>
        withClient { client =>
          val createRequest = Request[IO](
            method = Method.POST,
            uri = baseUri / "users"
          ).withEntity(CreateUserRequest("test@example.com", "Test User", 25))

          client.expect[UserResponse](createRequest).asserting { response =>
            response.email shouldBe "test@example.com"
            response.name shouldBe "Test User"
            response.age shouldBe 25
          }
        }
      }
    }

    "get user by ID" in {
      val routes = userRoutes

      withServer(routes) { baseUri =>
        withClient { client =>
          for {
            // First create a user
            createRequest = Request[IO](
              method = Method.POST,
              uri = baseUri / "users"
            ).withEntity(CreateUserRequest("test@example.com", "Test User", 25))
            created <- client.expect[UserResponse](createRequest)

            // Then retrieve it
            getRequest = Request[IO](
              method = Method.GET,
              uri = baseUri / "users" / created.id.toString
            )
            retrieved <- client.expect[UserResponse](getRequest)
          } yield retrieved
        }.asserting { user =>
          user.email shouldBe "test@example.com"
          user.name shouldBe "Test User"
        }
      }
    }

    "return 404 for non-existent user" in {
      val routes = userRoutes

      withServer(routes) { baseUri =>
        withClient { client =>
          val request = Request[IO](
            method = Method.GET,
            uri = baseUri / "users" / "999"
          )

          client.status(request).asserting { status =>
            status shouldBe Status.NotFound
          }
        }
      }
    }

    "update user information" in {
      val routes = userRoutes

      withServer(routes) { baseUri =>
        withClient { client =>
          for {
            // Create user
            createRequest = Request[IO](
              method = Method.POST,
              uri = baseUri / "users"
            ).withEntity(CreateUserRequest("test@example.com", "Original Name", 25))
            created <- client.expect[UserResponse](createRequest)

            // Update user
            updateRequest = Request[IO](
              method = Method.PUT,
              uri = baseUri / "users" / created.id.toString
            ).withEntity(UpdateUserRequest(Some("Updated Name"), Some(30)))
            updated <- client.expect[UserResponse](updateRequest)
          } yield updated
        }.asserting { user =>
          user.name shouldBe "Updated Name"
          user.age shouldBe 30
        }
      }
    }

    "list users with pagination" in {
      val routes = userRoutes

      withServer(routes) { baseUri =>
        withClient { client =>
          for {
            // Create multiple users
            users <- (1 to 5).toList.traverse { i =>
              val request = Request[IO](
                method = Method.POST,
                uri = baseUri / "users"
              ).withEntity(CreateUserRequest(s"user$i@example.com", s"User $i", 20 + i))
              client.expect[UserResponse](request)
            }

            // Get first page
            listRequest = Request[IO](
              method = Method.GET,
              uri = (baseUri / "users").withQueryParam("page", 0).withQueryParam("size", 3)
            )
            page <- client.expect[UserListResponse](listRequest)
          } yield page
        }.asserting { page =>
          page.users should have length 3
          page.totalUsers shouldBe 5
          page.page shouldBe 0
          page.hasNext shouldBe true
        }
      }
    }

    "handle validation errors" in {
      val routes = userRoutes

      withServer(routes) { baseUri =>
        withClient { client =>
          val request = Request[IO](
            method = Method.POST,
            uri = baseUri / "users"
          ).withEntity(CreateUserRequest("invalid-email", "", -1))

          client.status(request).asserting { status =>
            status shouldBe Status.BadRequest
          }
        }
      }
    }
  }

  // Test data classes
  case class CreateUserRequest(email: String, name: String, age: Int)
  case class UpdateUserRequest(name: Option[String], age: Option[Int])
  case class UserResponse(id: Long, email: String, name: String, age: Int, isActive: Boolean)
  case class UserListResponse(users: List[UserResponse], totalUsers: Int, page: Int, hasNext: Boolean)

  // Mock routes for testing
  def userRoutes: HttpRoutes[IO] = {
    val repository = new InMemoryUserRepository()
    val service = new UserService(repository)

    HttpRoutes.of[IO] {
      case req @ POST -> Root / "users" =>
        for {
          createReq <- req.as[CreateUserRequest]
          result <- service.createUser(createReq.email, createReq.name, createReq.age)
          response <- result match {
            case Right(user) => 
              Ok(UserResponse(user.id, user.email, user.name, user.age, user.isActive))
            case Left(error) => 
              BadRequest(error)
          }
        } yield response

      case GET -> Root / "users" / LongVar(id) =>
        service.getUserById(id).flatMap {
          case Some(user) => 
            Ok(UserResponse(user.id, user.email, user.name, user.age, user.isActive))
          case None => 
            NotFound()
        }

      case req @ PUT -> Root / "users" / LongVar(id) =>
        for {
          updateReq <- req.as[UpdateUserRequest]
          maybeUser <- service.getUserById(id)
          response <- maybeUser match {
            case Some(user) =>
              val updated = user.copy(
                name = updateReq.name.getOrElse(user.name),
                age = updateReq.age.getOrElse(user.age)
              )
              service.updateUser(updated).flatMap {
                case Right(u) => Ok(UserResponse(u.id, u.email, u.name, u.age, u.isActive))
                case Left(error) => BadRequest(error)
              }
            case None => NotFound()
          }
        } yield response

      case GET -> Root / "users" :? PageQueryParamMatcher(page) +& SizeQueryParamMatcher(size) =>
        for {
          allUsers <- repository.findAll()
          totalUsers = allUsers.length
          pagedUsers = allUsers.drop(page * size).take(size)
          hasNext = (page + 1) * size < totalUsers
          users = pagedUsers.map(u => UserResponse(u.id, u.email, u.name, u.age, u.isActive))
          response = UserListResponse(users, totalUsers, page, hasNext)
        } yield Ok(response)
    }
  }

  // Query parameter matchers
  object PageQueryParamMatcher extends QueryParamDecoderMatcher[Int]("page")
  object SizeQueryParamMatcher extends QueryParamDecoderMatcher[Int]("size")
}

Test-Driven Development (TDD) Workflow

TDD Example: Building a Shopping Cart

// ShoppingCartSpec.scala - TDD approach
package com.example.testing.tdd

import com.example.testing.config.BaseSpec
import java.time.Instant

class ShoppingCartSpec extends BaseSpec {

  "Shopping Cart" when {

    "newly created" should {
      "be empty" in {
        val cart = ShoppingCart.empty
        cart.items shouldBe empty
        cart.totalPrice shouldBe BigDecimal(0)
      }
    }

    "adding items" should {
      "add single item" in {
        val cart = ShoppingCart.empty
        val product = Product("1", "Book", BigDecimal(29.99))

        val updatedCart = cart.addItem(product, 1)

        updatedCart.items should have length 1
        updatedCart.items.head.product shouldBe product
        updatedCart.items.head.quantity shouldBe 1
        updatedCart.totalPrice shouldBe BigDecimal(29.99)
      }

      "add multiple quantities of same item" in {
        val cart = ShoppingCart.empty
        val product = Product("1", "Book", BigDecimal(29.99))

        val updatedCart = cart.addItem(product, 3)

        updatedCart.items should have length 1
        updatedCart.items.head.quantity shouldBe 3
        updatedCart.totalPrice shouldBe BigDecimal(89.97)
      }

      "combine quantities when adding existing item" in {
        val product = Product("1", "Book", BigDecimal(29.99))
        val cart = ShoppingCart.empty.addItem(product, 2)

        val updatedCart = cart.addItem(product, 1)

        updatedCart.items should have length 1
        updatedCart.items.head.quantity shouldBe 3
        updatedCart.totalPrice shouldBe BigDecimal(89.97)
      }

      "add different items" in {
        val book = Product("1", "Book", BigDecimal(29.99))
        val pen = Product("2", "Pen", BigDecimal(2.50))

        val cart = ShoppingCart.empty
          .addItem(book, 1)
          .addItem(pen, 2)

        cart.items should have length 2
        cart.totalPrice shouldBe BigDecimal(34.99)
      }
    }

    "removing items" should {
      "remove item completely" in {
        val product = Product("1", "Book", BigDecimal(29.99))
        val cart = ShoppingCart.empty.addItem(product, 2)

        val updatedCart = cart.removeItem(product.id)

        updatedCart.items shouldBe empty
        updatedCart.totalPrice shouldBe BigDecimal(0)
      }

      "reduce quantity when removing partially" in {
        val product = Product("1", "Book", BigDecimal(29.99))
        val cart = ShoppingCart.empty.addItem(product, 3)

        val updatedCart = cart.removeItem(product.id, 1)

        updatedCart.items should have length 1
        updatedCart.items.head.quantity shouldBe 2
        updatedCart.totalPrice shouldBe BigDecimal(59.98)
      }

      "handle removing non-existent item" in {
        val cart = ShoppingCart.empty

        val updatedCart = cart.removeItem("non-existent")

        updatedCart shouldBe cart
      }
    }

    "calculating discounts" should {
      "apply percentage discount" in {
        val product = Product("1", "Book", BigDecimal(100.00))
        val cart = ShoppingCart.empty.addItem(product, 1)
        val discount = PercentageDiscount("10OFF", BigDecimal(0.10))

        val discountedCart = cart.applyDiscount(discount)

        discountedCart.totalPrice shouldBe BigDecimal(90.00)
        discountedCart.appliedDiscounts should contain(discount)
      }

      "apply fixed amount discount" in {
        val product = Product("1", "Book", BigDecimal(100.00))
        val cart = ShoppingCart.empty.addItem(product, 1)
        val discount = FixedAmountDiscount("5OFF", BigDecimal(5.00))

        val discountedCart = cart.applyDiscount(discount)

        discountedCart.totalPrice shouldBe BigDecimal(95.00)
      }

      "not apply discount below minimum order" in {
        val product = Product("1", "Book", BigDecimal(20.00))
        val cart = ShoppingCart.empty.addItem(product, 1)
        val discount = PercentageDiscount("10OFF", BigDecimal(0.10), minOrderAmount = Some(BigDecimal(50.00)))

        val result = cart.applyDiscount(discount)

        result shouldBe cart // Discount not applied
      }

      "apply multiple discounts" in {
        val product = Product("1", "Book", BigDecimal(100.00))
        val cart = ShoppingCart.empty.addItem(product, 1)
        val discount1 = FixedAmountDiscount("5OFF", BigDecimal(5.00))
        val discount2 = PercentageDiscount("10OFF", BigDecimal(0.10))

        val discountedCart = cart
          .applyDiscount(discount1)
          .applyDiscount(discount2)

        // 100 - 5 = 95, then 95 * 0.9 = 85.5
        discountedCart.totalPrice shouldBe BigDecimal(85.50)
      }
    }

    "checking out" should {
      "create order with correct details" in {
        val book = Product("1", "Book", BigDecimal(29.99))
        val pen = Product("2", "Pen", BigDecimal(2.50))
        val cart = ShoppingCart.empty
          .addItem(book, 2)
          .addItem(pen, 1)

        val customer = Customer("123", "John Doe", "john@example.com")
        val order = cart.checkout(customer)

        order.customerId shouldBe customer.id
        order.items should have length 2
        order.totalAmount shouldBe BigDecimal(62.48)
        order.status shouldBe OrderStatus.Pending
      }

      "clear cart after checkout" in {
        val product = Product("1", "Book", BigDecimal(29.99))
        val cart = ShoppingCart.empty.addItem(product, 1)
        val customer = Customer("123", "John Doe", "john@example.com")

        val (order, clearedCart) = cart.checkoutAndClear(customer)

        clearedCart.items shouldBe empty
        clearedCart.totalPrice shouldBe BigDecimal(0)
        order.totalAmount shouldBe BigDecimal(29.99)
      }
    }
  }
}

// Implementation driven by tests
case class Product(id: String, name: String, price: BigDecimal)

case class CartItem(product: Product, quantity: Int) {
  def totalPrice: BigDecimal = product.price * quantity
}

sealed trait Discount {
  def code: String
  def apply(amount: BigDecimal): BigDecimal
  def isApplicable(cart: ShoppingCart): Boolean
}

case class PercentageDiscount(
  code: String, 
  percentage: BigDecimal,
  minOrderAmount: Option[BigDecimal] = None
) extends Discount {
  def apply(amount: BigDecimal): BigDecimal = amount * (1 - percentage)
  def isApplicable(cart: ShoppingCart): Boolean = 
    minOrderAmount.forall(min => cart.subtotal >= min)
}

case class FixedAmountDiscount(
  code: String, 
  amount: BigDecimal,
  minOrderAmount: Option[BigDecimal] = None
) extends Discount {
  def apply(totalAmount: BigDecimal): BigDecimal = (totalAmount - amount).max(BigDecimal(0))
  def isApplicable(cart: ShoppingCart): Boolean = 
    minOrderAmount.forall(min => cart.subtotal >= min)
}

case class ShoppingCart(
  items: List[CartItem] = List.empty,
  appliedDiscounts: List[Discount] = List.empty
) {

  def addItem(product: Product, quantity: Int): ShoppingCart = {
    val existingItem = items.find(_.product.id == product.id)
    existingItem match {
      case Some(item) =>
        val updatedItems = items.map { i =>
          if (i.product.id == product.id) i.copy(quantity = i.quantity + quantity)
          else i
        }
        copy(items = updatedItems)
      case None =>
        copy(items = items :+ CartItem(product, quantity))
    }
  }

  def removeItem(productId: String, quantity: Int = Int.MaxValue): ShoppingCart = {
    val updatedItems = items.flatMap { item =>
      if (item.product.id == productId) {
        val newQuantity = item.quantity - quantity
        if (newQuantity <= 0) None
        else Some(item.copy(quantity = newQuantity))
      } else Some(item)
    }
    copy(items = updatedItems)
  }

  def subtotal: BigDecimal = items.map(_.totalPrice).sum

  def totalPrice: BigDecimal = {
    appliedDiscounts.foldLeft(subtotal) { (amount, discount) =>
      discount.apply(amount)
    }
  }

  def applyDiscount(discount: Discount): ShoppingCart = {
    if (discount.isApplicable(this)) {
      copy(appliedDiscounts = appliedDiscounts :+ discount)
    } else {
      this
    }
  }

  def checkout(customer: Customer): Order = {
    Order(
      id = java.util.UUID.randomUUID().toString,
      customerId = customer.id,
      items = items,
      appliedDiscounts = appliedDiscounts,
      subtotal = subtotal,
      totalAmount = totalPrice,
      status = OrderStatus.Pending,
      createdAt = Instant.now()
    )
  }

  def checkoutAndClear(customer: Customer): (Order, ShoppingCart) = {
    val order = checkout(customer)
    val clearedCart = ShoppingCart.empty
    (order, clearedCart)
  }
}

object ShoppingCart {
  def empty: ShoppingCart = ShoppingCart()
}

case class Customer(id: String, name: String, email: String)

sealed trait OrderStatus
object OrderStatus {
  case object Pending extends OrderStatus
  case object Processing extends OrderStatus
  case object Shipped extends OrderStatus
  case object Delivered extends OrderStatus
  case object Cancelled extends OrderStatus
}

case class Order(
  id: String,
  customerId: String,
  items: List[CartItem],
  appliedDiscounts: List[Discount],
  subtotal: BigDecimal,
  totalAmount: BigDecimal,
  status: OrderStatus,
  createdAt: Instant
)

Conclusion

Comprehensive testing strategies are essential for building reliable Scala applications. Key concepts include:

Unit Testing:

  • ScalaTest styles and matchers
  • Custom test configurations and utilities
  • Mock objects and test doubles
  • Behavior-driven development patterns

Property-Based Testing:

  • ScalaCheck generators and arbitraries
  • Property definitions and invariants
  • Custom generators and shrinkers
  • Performance and stress testing

Integration Testing:

  • Database integration with TestContainers
  • HTTP API testing with real servers
  • End-to-end workflow validation
  • External service integration

Test-Driven Development:

  • Red-Green-Refactor cycle
  • Test-first design approach
  • Incremental implementation
  • Continuous feedback loops

Best Practices:

  • Test organization and structure
  • Test data management
  • Assertion clarity and readability
  • Test isolation and independence

Advanced Techniques:

  • Async testing with IO and Future
  • Concurrent and parallel testing
  • Performance benchmarking
  • Mutation testing for test quality

Testing Infrastructure:

  • CI/CD integration
  • Test reporting and metrics
  • Code coverage analysis
  • Test environment management

Quality Assurance:

  • Test maintainability
  • Test performance optimization
  • Flaky test prevention
  • Test documentation

Effective testing strategies enable teams to deliver high-quality software with confidence, reduce bugs in production, and maintain code quality throughout the development lifecycle.