'Example of using ZIO Test' post illustration

Example of using ZIO Test

avatar

In this article, our focus will be on guiding you through the intricacies of setting up effective testing for a Scala program using the ZIO. ZIO Test is specially designed for testing applications written using ZIO, offering a streamlined approach that enhances efficiency and reliability compared to traditional testing libraries. While other testing libraries are available, leveraging ZIO Test often results in faster, more efficient testing that better reveals the potential of your application.

Model and Structure

Before delving into the testing procedures, it's crucial to familiarize ourselves with the specific Scala program that will serve as the focal point of our testing endeavors.

This project constitutes a RestApi service, intricately designed to execute Create, Read, Update, and Delete (CRUD) functions seamlessly between Users and Posts.

We use the following user and post model:

Data Model Schema

Now we will consider the structure of the project and the technologies used in this project, you can see the full project at this link.

The technology stack is:

And the project structure is the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
├───main
│   ├───resources
│   │   │   application.conf
│   │   │
│   │   └───db
│   │           V01__CREATE.sql
│   │           V02__ADD_DOB.sql
│   │
│   └───scala
│       │   Main.scala
│       │
│       ├───controllers
│       │       PostController.scala
│       │       UserController.scala
│       │
│       ├───models
│       │   │   Migrations.scala
│       │   │   Post.scala
│       │   │   User.scala
│       │   │
│       │   ├───daos
│       │   │       GeneralDAO.scala
│       │   │       PostDAO.scala
│       │   │       UserDAO.scala
│       │   │
│       │   └───tables
│       │           PostTable.scala
│       │           UserTable.scala
│       │
│       └───services
│               DOBService.scala
└───test
    └───scala
        ├───controllers
        │       UserControllerSpec.scala
        ├───daos
        │       UserDAOSpec.scala
        └───services
                DOBServiceSpec.scala

Project Configuration

Here are all the dependencies we used in the project:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
ThisBuild / version := "0.1.0-SNAPSHOT"

ThisBuild / scalaVersion := "2.13.12"

lazy val root = (project in file("."))
  .settings(
    name := "zio-test-example"
  ).settings(
  Test / parallelExecution := false
)
libraryDependencies ++= Seq(
  "dev.zio"            %% "zio"                 % "2.0.13",
  "dev.zio"            %% "zio-http"            % "3.0.0-RC3",
  "dev.zio"            %% "zio-json"            % "0.5.0",
  "dev.zio"            %% "zio-config"          % "3.0.7",
  "dev.zio"            %% "zio-config-typesafe" % "3.0.7",
  "dev.zio"            %% "zio-config-magnolia" % "3.0.7",
  "dev.zio"            %% "zio-test"            % "2.0.20" % Test,
  "dev.zio"            %% "zio-test-sbt"        % "2.0.20" % Test,
  "dev.zio"            %% "zio-test-magnolia"   % "2.0.20" % Test,
  "org.scalatest"      %% "scalatest"           % "3.2.15" % Test,

  "dev.zio"            %% "zio-config-magnolia" % "3.0.7",

  "io.scalac"          %% "zio-slick-interop"   % "0.6.0",
  "com.typesafe.slick" %% "slick"               % "3.4.1",

  "com.h2database"     % "h2"                   % "2.1.214",

  "com.github.blemale" %% "scaffeine"           % "5.2.1",
  "org.flywaydb"       % "flyway-core"          % "9.16.0",
  "joda-time"          % "joda-time"            % "2.12.7"
)
testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework")

The .settings( Test / parallelExecution := false) code configures the behavior of the project during testing and disables parallel test execution. This means that the tests will be executed sequentially, one after the other to avoid potential conflicts in environments where parallel testing can cause problems.

Preparing for testing

Let's create a trait TestHelper that imports a few key dependencies required for all test classes in our Scala app. Each particular test class will extend TestHelper to avoid duplicating dependencies.

Let's take a closer look at this trait:

  • The class extending ZIOSpecDefault facilitates ZIO test specification and execution. In ZIO Test, any Scala object implementing this trait becomes a runnable test. To be runnable, the object must implement the spec method, which defines a test specification
  • val config - Loads test-specific database configuration from "application.test", ensuring isolation from production settings
  • val databaseProvider - Creates a ZIO layer for managing database connections
  • val migrations - Instantiates a Migrations object (for managing database schema changes) using the test configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package helper

import com.typesafe.config.{Config, ConfigFactory}
import models.Migrations
import slick.interop.zio.DatabaseProvider
import slick.jdbc.JdbcProfile
import zio.ZLayer
import zio.test.ZIOSpecDefault

trait TestHelper extends ZIOSpecDefault{

  val config: Config = ConfigFactory.load("application.test").getConfig("database")

  implicit val databaseProvider: ZLayer[Any, Throwable, DatabaseProvider] =
    (ZLayer.succeed(config) ++ ZLayer.succeed[JdbcProfile](
      slick.jdbc.H2Profile
    )) >>> DatabaseProvider.fromConfig()

  val migrations: Migrations = new Migrations(config)

}

We will also need a config file for the tests in order to use a separate database for testing, so create application.test.conf in the test/resources folder. A configuration file provides a crucial tool for isolating environments, managing databases, and customizing settings for various test scenarios.

1
2
3
4
5
6
7
database {
  url = "jdbc:h2:./data-dir/my-h2-db-test;MODE=PostgreSQL;AUTO_SERVER=TRUE"
  driver = "org.h2.Driver"
  connectionPool = "disabled"
  user = "sa"
  password = ""
}

Writing tests

Let's now focus on testing the code for the UserDAO. For that, we create a class UserDAOSpec, which extends TestHelper.

General description of the tests:

  • test1: Tests if creating a user with valid data returns the created user
  • test2: Tests if creating a user with a duplicate email fails with a constraint violation
  • test3: Tests if reading a user with the correct ID returns the expected user data
  • test4: Tests if reading a user with an invalid ID returns None
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package daos

import helper.TestHelper
import models.User
import models.daos.UserDAO
import org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException
import zio._
import zio.prelude.data.Optional.AllValuesAreNullable
import zio.test.Assertion._
import zio.test.TestAspect._
import zio.test._

import java.util.Date

object UserDAOSpec extends TestHelper {

  def test1: Spec[UserDAO, Throwable] = suite("test1")(test("create user returned user ") {
    val user = new User(None, "test", "testTest@gmail.com", new Date(2002, 2, 2))
    for {
      user <- ZIO.serviceWithZIO[UserDAO](_.create(user))
    } yield assert(user.nonEmpty)(equalTo(true))
  })

  def test2: Spec[UserDAO, Nothing] = suite("test2")(test("create user with same email") {
    val user = new User(None, "test", "testTest@gmail.com", new Date(2002, 2, 2))
    val createTask = ZIO.serviceWithZIO[UserDAO](_.create(user))
    val value = for {
      user <- createTask
    } yield user
    assertZIO(value.exit)(fails(isSubtype[JdbcSQLIntegrityConstraintViolationException](anything)))
  })

  def test3: Spec[UserDAO, Throwable] = suite("test3")(test("read user with right id") {
    val user = new User(None, "test", "testForTest@gmail.com", new Date(2002, 2, 2))
    for {
      createdUser <- ZIO.serviceWithZIO[UserDAO](_.create(user))
      readUser <- ZIO.serviceWithZIO[UserDAO](_.read(createdUser.id.get))
    } yield assert(readUser.get.email)(equalTo(user.email))
  })

  def test4: Spec[UserDAO, Throwable] = suite("test4")(test("read user with wrong id") {
    val value = for {
      readUser <- ZIO.serviceWithZIO[UserDAO](_.read(1000))
    } yield readUser
    assertZIO(value)(isNone)
  })

  def spec: Spec[Any, Throwable] = suite("UserDAO")(
    test1,
    test2,
    test3
  ).provideLayerShared(databaseProvider >>> UserDAO.live(databaseProvider)) @@ beforeAll(ZIO.from(migrations.run))  @@ sequential @@afterAll(ZIO.succeed(migrations.dropTables))
}

Now we can take a closer look at some of the features that we will use in the following tests.

Notably, the provideLayerShared function is used to supply the necessary dependencies for the UserDAO tests. In this case, the dependencies include a databaseProvider and the UserDAO.live(databaseProvider). This ensures that the tests can interact with a live database, offering a realistic testing environment. You can read more about it here.

The use of

1
@@ beforeAll(ZIO.from(migrations.run))

and

1
@@ afterAll(ZIO.succeed(migrations.dropTables))

indicates that the database migrations are run before the test suite and tables are dropped after its completion, ensuring a clean and consistent database state for testing. You can read more about it here.

These tests will be executed sequentially due to the @@ sequential annotation, meaning that test2 will run after test1 completes, and test3 will run after test2 completes. This can be beneficial in maintaining a predictable and controlled testing environment.

Let's examine the concluding statements in all the tests. Assertions fall into two categories: assertZIO and assert. Here's a breakdown of their distinctions:

  • assertZIO: Mainly employed within the ZIO testing framework to assert properties of ZIO effects, covering outcomes, error types, and values.
  • assert: Primarily utilized in synchronous, non-ZIO contexts and lacks built-in support for handling asynchronous or effectful computations.

When anticipating an error, we utilize assertZIO. Take, for instance, the following expression:

1
assertZIO(value.exit)(fails(isSubtype[JdbcSQLIntegrityConstraintViolationException](anything)))

Breaking it down:

  • value.exit: Retrieves the result of the ZIO effect value. The exit method grants access to the final outcome, encompassing information about success or failure.
  • fails(...): This combinator checks whether the ZIO effect has failed. It takes an assertion as an argument, succeeding only if the provided assertion holds true for a failed outcome.
  • isSubtype[JdbcSQLIntegrityConstraintViolationException](anything): This assertion verifies whether the failure is of type JdbcSQLIntegrityConstraintViolationException. The isSubtype assertion ensures that the failure type is a subtype of the specified one.

Now we can start writing tests for the controller. The purpose of the UserControllerSpec class is to test the functionality of the UserController, which handles the API endpoints associated with the user.

General description of the tests:

  • test1: Tests user creation with valid data and expects successful response with created user data
  • test2: Tests with invalid request data and verifies bad request response with a warning header
  • test3: Tests getting a user by ID and asserts the response contains the correct user data
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package controllers

import helper.TestHelper
import models.daos.UserDAO
import models.User
import services.DOBService
import slick.interop.zio.DatabaseProvider
import zio.http.{Body, Path, Request, Status, URL}
import zio.json.DecoderOps
import zio.test.Assertion._
import zio.test.TestAspect.{afterAll, beforeAll, sequential}
import zio.test._
import zio.{ZIO, ZLayer}

import java.util.Date

object UserControllerSpec extends TestHelper{

  def test1: Spec[UserDAO with DOBService, Serializable] = suite("suite 1")(test("create user returned user ") {
    val request = Request.post(URL(Path("/user")),
      Body.fromString("""{"name": "John Doe", "email": "john.doe2@example.com", "dateOfBirth":"2002-02-02"}"""))
    for {
      response <- UserController.routes.runZIO(request)
      userRes <- response.body.asString.map(_.fromJson[User]).debug
      user <- ZIO.fromOption(userRes.toOption)
    } yield assert(response.status)(equalTo(Status.Ok)) &&
      assert(user.email)(equalTo("john.doe2@example.com"))
  })

  def test2: Spec[UserDAO with DOBService, Nothing] = suite("suite 2")(test("wrong request") {
    val request = Request.post(URL(Path("/user")),
      Body.fromString("""{"firstName": "John Doe", "email": "john.doe2@example.com", "dateOfBirth":"2002-02-02"}"""))
    for {
      response <- UserController.routes.runZIO(request)
    } yield assert(response.status)(equalTo(Status.BadRequest)) &&
      assert(response.headers.get("warning").get)(equalTo("400 ZIO HTTP Invalid request: Invalid user data"))
  })

  def test3: Spec[UserDAO with DOBService, Serializable] = suite("suite 3")(test("2get request user by id") {
    for {
      createdUser <- ZIO.serviceWithZIO[UserDAO](_.create(User(None, "john", "john@example.com", new Date(1999, 1, 1))))
      response <- UserController.routes.runZIO(Request.get(URL(Path(s"/user/${createdUser.id.get}"))))
      userRes <- response.body.asString.map(_.fromJson[User]).debug
      user <- ZIO.fromOption(userRes.toOption)
    } yield assert(response.status)(equalTo(Status.Ok)) &&
      assert(user.email)(equalTo("john@example.com"))
  })

  val testLayer: ZLayer[Any, Throwable, DatabaseProvider with UserDAO with DOBService] =
    ZLayer.make[DatabaseProvider with UserDAO with DOBService](databaseProvider, UserDAO.live(databaseProvider), DOBService.live)

  def spec: Spec[TestEnvironment, Serializable] = suite("UserController")(
    test1,
    test2,
    test3
  ).provideLayerShared(testLayer) @@ beforeAll(ZIO.from(migrations.run)) @@ sequential @@afterAll(ZIO.succeed(migrations.dropTables))

}
1
2
3
4
5
6
7
8
  val testLayer: ZLayer[Any, Throwable, DatabaseProvider with UserDAO with DOBService] =
    ZLayer.make[DatabaseProvider with UserDAO with DOBService](databaseProvider, UserDAO.live(databaseProvider), DOBService.live)

  def spec: Spec[TestEnvironment, Serializable] = suite("UserController")(
    test1,
    test2,
    test3
  ).provideLayerShared(testLayer) @@ beforeAll(ZIO.from(migrations.run)) @@ sequential @@afterAll(ZIO.succeed(migrations.dropTables))

In this scenario, a ZIO layer has been created, specifying the types of services it provides: DatabaseProvider, UserDAO and DOBService. These types are amalgamated using the with keyword to form a composite type.

Subsequently, the created layer is utilized with .provideLayerShared(testLayer) to offer these services shared across the ZIO environment.

Let's write tests for the DOBService that calculates the number of days until the user's next birthday.

General description of the tests:

  • test1: Tests if the service calculates the correct days till birthday when it's upcoming
  • test2: Tests if the service calculates the days till next year's birthday if the current date has passed the date of birth
  • test3: Tests if the service returns 0 for a birthday on the same day as the current date
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package services

import models.User
import org.joda.time.DateTime
import zio.ZIO
import zio.test.Assertion.equalTo
import zio.test.{Spec, ZIOSpecDefault, assert}

object DOBServiceSpec extends ZIOSpecDefault {
  val nowDate: DateTime = DateTime.now()

  def test1: Spec[DOBService, Nothing] = suite("test1")(test("calculates days to birthday correctly for upcoming birthday") {
    val expectedDays = 1
    val dob = new DateTime(2002, nowDate.monthOfYear().get(), nowDate.dayOfMonth().get(), 0, 0).plusDays(expectedDays)
    val user = User(Some(1L), "name", "email", dob.toDate)
    for {
      daysToBirth <- ZIO.serviceWithZIO[DOBService](_.getDaysToBirth(user))
    } yield assert(daysToBirth)(equalTo(expectedDays.toLong))
  })

  def test2: Spec[DOBService, Nothing] = suite("test2")(test("calculates days to birthday for " +
    "next year's birthday if already passed") {
    val expectedDays = 12
    val dob = new DateTime(2002, nowDate.monthOfYear().get(), nowDate.dayOfMonth().get(), 0, 0).minusDays(expectedDays)
    val user = User(Some(1L), "name", "email", dob.toDate)
    val days = (if (nowDate.year().isLeap) 365 else 366) - expectedDays
    for {
      daysToBirth <- ZIO.serviceWithZIO[DOBService](_.getDaysToBirth(user))
    } yield assert(daysToBirth)(equalTo(days.toLong))
  })

  def test3: Spec[DOBService, Nothing] = suite("test3")(test("returns 0 for birthday today") {
    val dob = new DateTime(2002, nowDate.monthOfYear().get(), nowDate.dayOfMonth().get(), 0, 0)
    val user = User(Some(1L), "name", "email", dob.toDate)
    for {
      daysToBirth <- ZIO.serviceWithZIO[DOBService](_.getDaysToBirth(user))
    } yield assert(daysToBirth)(equalTo(0L))
  })

  def spec = suite("UserDAO")(
    test1,
    test2,
    test3
  ).provideLayerShared(DOBService.live)

}

The principles and techniques discussed here are adaptable and extendable to meet the unique requirements of your projects. Whether you are developing RESTful API services or working on other Scala projects, harnessing ZIO for testing can significantly improve the reliability and maintainability of your codebase.

If you're looking for a developer or considering starting a new project,
we are always ready to help!