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.
Comments
Be the first to comment on this lesson!