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:
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 .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
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.
packagedaosimporthelper.TestHelperimportmodels.Userimportmodels.daos.UserDAOimportorg.h2.jdbc.JdbcSQLIntegrityConstraintViolationExceptionimportzio._importzio.prelude.data.Optional.AllValuesAreNullableimportzio.test.Assertion._importzio.test.TestAspect._importzio.test._importjava.util.DateobjectUserDAOSpecextendsTestHelper{deftest1:Spec[UserDAO, Throwable]=suite("test1")(test("create user returned user "){valuser=newUser(None,"test","testTest@gmail.com",newDate(2002,2,2))for{user<-ZIO.serviceWithZIO[UserDAO](_.create(user))}yieldassert(user.nonEmpty)(equalTo(true))})deftest2:Spec[UserDAO, Nothing]=suite("test2")(test("create user with same email"){valuser=newUser(None,"test","testTest@gmail.com",newDate(2002,2,2))valcreateTask=ZIO.serviceWithZIO[UserDAO](_.create(user))valvalue=for{user<-createTask}yielduserassertZIO(value.exit)(fails(isSubtype[JdbcSQLIntegrityConstraintViolationException](anything)))})deftest3:Spec[UserDAO, Throwable]=suite("test3")(test("read user with right id"){valuser=newUser(None,"test","testForTest@gmail.com",newDate(2002,2,2))for{createdUser<-ZIO.serviceWithZIO[UserDAO](_.create(user))readUser<-ZIO.serviceWithZIO[UserDAO](_.read(createdUser.id.get))}yieldassert(readUser.get.email)(equalTo(user.email))})deftest4:Spec[UserDAO, Throwable]=suite("test4")(test("read user with wrong id"){valvalue=for{readUser<-ZIO.serviceWithZIO[UserDAO](_.read(1000))}yieldreadUserassertZIO(value)(isNone)})defspec: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:
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
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
packageservicesimportmodels.Userimportorg.joda.time.DateTimeimportzio.ZIOimportzio.test.Assertion.equalToimportzio.test.{Spec,ZIOSpecDefault,assert}objectDOBServiceSpecextendsZIOSpecDefault{valnowDate:DateTime=DateTime.now()deftest1:Spec[DOBService, Nothing]=suite("test1")(test("calculates days to birthday correctly for upcoming birthday"){valexpectedDays=1valdob=newDateTime(2002,nowDate.monthOfYear().get(),nowDate.dayOfMonth().get(),0,0).plusDays(expectedDays)valuser=User(Some(1L),"name","email",dob.toDate)for{daysToBirth<-ZIO.serviceWithZIO[DOBService](_.getDaysToBirth(user))}yieldassert(daysToBirth)(equalTo(expectedDays.toLong))})deftest2:Spec[DOBService, Nothing]=suite("test2")(test("calculates days to birthday for "+"next year's birthday if already passed"){valexpectedDays=12valdob=newDateTime(2002,nowDate.monthOfYear().get(),nowDate.dayOfMonth().get(),0,0).minusDays(expectedDays)valuser=User(Some(1L),"name","email",dob.toDate)valdays=(if(nowDate.year().isLeap)365else366)-expectedDaysfor{daysToBirth<-ZIO.serviceWithZIO[DOBService](_.getDaysToBirth(user))}yieldassert(daysToBirth)(equalTo(days.toLong))})deftest3:Spec[DOBService, Nothing]=suite("test3")(test("returns 0 for birthday today"){valdob=newDateTime(2002,nowDate.monthOfYear().get(),nowDate.dayOfMonth().get(),0,0)valuser=User(Some(1L),"name","email",dob.toDate)for{daysToBirth<-ZIO.serviceWithZIO[DOBService](_.getDaysToBirth(user))}yieldassert(daysToBirth)(equalTo(0L))})defspec=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!