Consider following simplified user authentication by password example:
import zio.*
enum UserStatus:
case Normal, Blocked
enum FailedAuthResult:
case UserNotFound, UserNotAllowed, WrongPassword
case class User(id: Long, status: UserStatus)
trait UserService:
def findUser(userId: Long): UIO[Option[User]]
trait PasswordService:
def checkPassword(id: Long, password: String): UIO[Boolean]
class UserAuthService(
userService: UserService,
passwordService: PasswordService
):
def authorize(id: Long, password: String): IO[FailedAuthResult, Unit] =
userService.findUser(id).flatMap:
case None =>
ZIO.fail(FailedAuthResult.UserNotFound)
case Some(user) if user.status == UserStatus.Blocked =>
ZIO.fail(FailedAuthResult.UserNotAllowed)
case Some(user) =>
passwordService.checkPassword(id, password)
.filterOrFail(identity)(FailedAuthResult.WrongPassword)
.unit
Letâs start with happy path example:
import zio.test.*
import org.scalamock.stubs.*
object ZIOUserAuthServiceSpec extends ZIOSpecDefault, ZIOStubs:
val unknownUserId = 0
val user = User(1, UserStatus.Normal)
val blockedUser = User(2, UserStatus.Blocked)
val validPassword = "valid"
val invalidPassword = "invalid"
val spec =
suite("UserAuthService")(
test("successful auth") {
val userService = stub[UserService]
val passwordService = stub[PasswordService]
val userAuthService = UserAuthService(userService, passwordService)
for
_ <- userService.findUser.returnsZIO(_ => ZIO.some(user))
_ <- passwordService.checkPassword.returnsZIO(_ => ZIO.succeed(true))
result <- userAuthService.authorize(id, validPassword).exit
yield assertTrue(
result == expectedResult,
passwordService.checkPassword.times == 0
)
}
)
Quite simple, but we need 4 test-cases:
- success
- user not found
- user blocked
- password wrong
Letâs write more abstract test-case:
case class Verify(
passwordCheckedTimes: Option[Int]
)
def testCase(
description: String,
id: Long,
password: String,
expectedResult: Exit[FailedAuthResult, Unit],
verify: Verify
) = test(description) {
val userService = stub[UserService]
val passwordService = stub[PasswordService]
val userAuthService = UserAuthService(userService, passwordService)
for
_ <- userService.findUser.returnsZIO:
case user.id => ZIO.some(user)
case blockedUser.id => ZIO.some(blockedUser)
case _ => ZIO.none
_ <- passwordService.checkPassword.returnsZIO:
case (_, password) => ZIO.succeed(password == validPassword)
result <- userAuthService.authorize(id, password).exit
yield assertTrue(
result == expectedResult,
verify.passwordCheckedTimes.contains(passwordService.checkPassword.times)
)
Now we can use it like this:
val spec =
suite("UserAuthService")(
testCase(
description = "error if user not found",
id = unknownUserId,
password = validPassword,
expectedResult = Exit.fail(FailedAuthResult.UserNotFound),
verify = Verify(passwordCheckedTimes = Some(0))
),
testCase(
description = "error if user is blocked",
id = blockedUser.id,
password = validPassword,
expectedResult = Exit.fail(FailedAuthResult.UserNotAllowed),
verify = Verify(passwordCheckedTimes = Some(0))
),
testCase(
description = "error if password is invalid",
id = user.id,
password = invalidPassword,
expectedResult = Exit.fail(FailedAuthResult.WrongPassword),
verify = Verify(passwordCheckedTimes = Some(1))
),
testCase(
description = "password valid",
id = user.id,
password = validPassword,
expectedResult = Exit.unit,
verify = Verify(passwordCheckedTimes = Some(1))
)
)
Code for this example can be found here.
If you prefer cats-effect IO
. You can find an example of how to test you cats-effect IO code with scalamock here. It is almost same
Do you like this approach or not?