Building ZIO Web App. Part 2. Unit testing
If you’ve always been curious about Scala, Functional Programming, and ZIO, then welcome to my series of articles. In this series, I will…
Building ZIO Web App. Part 2. Unit testing

If you’ve always been curious about Scala, Functional Programming, and ZIO, then welcome to my series of articles. In this series, I will introduce you to the fundamental principles and approaches of functional programming with Scala and ZIO through the example of building a simple web application.
This is the second article in the series, and here we will work on unit tests for the web server that we built in the previous article .
Articles
- Building ZIO Web App. Part 1. ZIO and zio-http
- Building ZIO Web App. Part 2. Unit testing
- Building ZIO Web App. Part 3. ZLayers and Dependency Injection
- Building ZIO Web App. Part 4. Designing ZIO application
- Building ZIO Web App. Part 5. ZIO basic primitives
You can refer to the final version of the source code in my GitHub repository.
Unit testing
First, it is worth briefly talk about Unit Tests. Unit Tests are the crucial part of developer’s activity. Working on them along with the code helps you to avoid errors, control quality, modularity and usability of the code. They should be easy and fast to run, providing almost instant feedback for the developer, meaning that they are providing all the aforementioned benefits without overcomplicating the developer’s experience.
Personally, I consider code that is not covered by unit tests as “dead code”: it may become stale or even invalid at any point, and without tests, you may never notice it. Furthermore, I believe that one of the main criteria for code quality is how easily it can be covered with unit tests. If it is easy to write unit tests for all parts of your code, there is a good chance that it is well-designed. Conversely, with poor-quality code, you may never know how to write the unit test, or it can only be done with great effort.
Another important aspect of unit testing is the presence of a unit-testing framework along with the main framework. When choosing a framework, this can be a crucial criterion as it indicates the maturity and good design of the tool. For ZIO, we have zio-test and zio-http-testkit, which means we made a good choice. Let's dive into writing the unit tests.
Dependencies
We need dependencies for zio-test and zio-http-testkit. Add the following elements to libraryDependencies in the build.sbt file:
"dev.zio" %% "zio-test" % "2.0.20" % Test,
"dev.zio" %% "zio-test-sbt" % "2.0.20" % Test,
"dev.zio" %% "zio-http-testkit" % "3.0.0-RC4" % Test
To explore the ZIO testing framework, let’s start by writing a dummy test. This test won’t check any specific functionality, but we can perceive it as a validation of the framework APIs themselves. If there is any compatibility issue (e.g., while upgrading the version of the framework), the test will fail, alerting us to potential breaking changes.
Let’s create a file src/test/scala/WebServerSuite.scala with our first test suite:
import zio._
import zio.test._
import zio.http._
object WebServerSuite extends ZIOSpecDefault:
def spec = suite("WebServerTest") {
test("FrameworkTest") {
ZIO.succeed(assertTrue(true))
}
}
This test obviously tests nothing, but from it, we can learn the structure of tests in zio-test:
- The test suite object should inherit from the
ZIOSpecDefaultclass, and thespecmethod should be implemented. - Here we are assigning a suite that contains a single test. In fact, suites are hierarchical and can be nested, and each suite can contain multiple tests.
- To make the test compile and be able to run, we’re providing a dummy assertion
ZIO.succeed(assertTrue(true)).
Finally, to run the test, you can use your favorite IDE or simply type the following command in the command line:
sbt test
TestServer
zio-http-testkit, which is the ‘testing companion’ of zio-http, provides us with a special class for testing called TestServer. Again, for the purpose of learning, now we will not execute any tests specific to our app. Instead, we will write another dummy test, this time involving TestServer.
The test should 'mock' a response for a specific request, then execute that request, and finally check that the result matches the mocked response.
But before that, we will create a ‘factory’ for test requests. We will use it later as a base for specific HTTP requests against our TestServer. Note that requests should be in the context of Server, so ZIO should have that environment as the first type parameter:
def baseRequest(): ZIO[Server, Nothing, Request] =
ZIO.serviceWith[Server](_.port)
.map(port => Request.get(url = URL.root.port(port)))
Now we can use it in the test:
test("TestServerTest") {
for {
client <- ZIO.service[Client]
testRequest <- baseRequest()
_ <- TestServer.addRequestResponse(testRequest, Response(Status.Ok))
response <- client(testRequest)
} yield assertTrue(response.status == Status.Ok)
}
- We take a
Clientfrom the environment and create atestRequestusing our 'factory' (we will discussZLayersand environments in the next article) TestServer.addRequestResponseis used for 'mocking' the server behavior.- finally, we can submit the
testRequestusing the client and make assertions against the response.
Note, that we’re operating on ZIO effects by using for-comprehensions, which makes code more readable.
Unfortunately, this code won’t work as it will complain about ZLayers. Yes, we still need to provide some environment for the test by using ZLayers. We will discuss ZLayer and Dependency Injection in the next article, but for now, just to get the idea, we should understand that the environment we are using should be provided somehow to ZIO. We will do this using the provide method:
test("TestServerTest") {
for {
client <- ZIO.service[Client]
testRequest <- baseRequest()
_ <- TestServer.addRequestResponse(testRequest, Response(Status.Ok))
response <- client(testRequest)
} yield assertTrue(response.status == Status.Ok)
}.provide(
TestServer.layer,
Client.default,
Scope.default,
Driver.default,
ZLayer.succeed(Server.Config.default.onAnyOpenPort)
)
For the environment, we need TestServer and Client. Additionally, Scope is required because it manages resources that need to be initialized and properly closed. Driver is the core ZIO engine (Netty by default), and Config should be provided for the Server as well.
Note that suite methods allow us to combine multiple tests together, and they should be appended to the suite using the + operator. Here is the full code for WebServerSuite:
import zio._
import zio.test._
import zio.http._
def baseRequest(): ZIO[Server, Nothing, Request] =
ZIO.serviceWith[Server](_.port)
.map(port => Request.get(url = URL.root.port(port)))
object WebServerSuite extends ZIOSpecDefault:
def spec = suite("WebServerTest") {
test("FrameworkTest") {
ZIO.succeed(assertTrue(true))
} +
test("TestServerTest") {
for {
client <- ZIO.service[Client]
testRequest <- baseRequest()
_ <- TestServer.addRequestResponse(testRequest, Response(Status.Ok))
response <- client(testRequest)
} yield assertTrue(response.status == Status.Ok)
}.provide(
TestServer.layer,
Client.default,
Scope.default,
Driver.default,
ZLayer.succeed(Server.Config.default.onAnyOpenPort)
)
}
Now both of our tests are passing, but we still haven’t tested our actual application!
Testing routes
Now let’s move on to testing our app. But first, let’s do a small refactoring. Currently, TestServer can accept handlers as a PartialFunction, which is fine, but for code cleanliness, it seems like a good idea to have the ability to provide Routes instead. This functionality should be available on TestServer in the next version of the library, but for now, we can easily implement it as an extension method for TestServer:
extension(testServer: TestServer)
def addRoutes[R](
routes: Routes[R, Response],
): ZIO[R, Nothing, Unit] =
for {
r <- ZIO.environment[R]
provided = routes.provideEnvironment(r)
app: HttpApp[Any] = provided.toHttpApp
_ <- testServer.driver.addApp(app, r)
} yield ()
Next, let’s perform a small refactoring and extract the routes variable in our Main object so that it can be used in the tests:
object Main extends ZIOAppDefault:
val routes = Routes (
Method.GET / "health" -> handler {
Random.nextInt
.map(number => Health("ok", number))
.map(health => Response.json(health.toJson))
}
)
val apps: HttpApp[Any] = routes.toHttpApp
//def run is still the same
We are ready to compose a test by appending it to the suite in the same way by using + operator
test("HealthRequestTest") {
for {
testRequest <- baseRequest()
server <- ZIO.service[TestServer]
client <- ZIO.service[Client]
_ <- server.addRoutes(routes)
response <- client(Request.get(testRequest.url / "health"))
body <- response.body.asString
} yield assertTrue(body == Health("ok", ???).toJson) //How to test Random?
}
Notice, that this code is incomplete because there is a reasonable question: how can we test a random number generation?
One option is to provide an alternative ZLayer with a seed, which should then be passed to our server so that the Random service is initialized with it. While this approach might work, the problem appears straightforward but the solution is not. Fortunately, ZIO offers a way to handle such nondeterministic behavior.
When running tests, ZIO replaces implementations of certain services, such as Random, with special test implementations like TestRandom. This allows us to pass a seed to the service with the values we want to see as output. Let’s explore the first option:
test("HealthRequestTest") {
for {
_ <- TestRandom.setSeed(123L)
testRequest <- baseRequest()
server <- ZIO.service[TestServer]
client <- ZIO.service[Client]
_ <- server.addRoutes(routes)
response <- client(Request.get(testRequest.url / "health"))
body <- response.body.asString
} yield assertTrue(body == Health("ok", -535098017).toJson)
}
We know that with a seed of 123, the Random service will deterministically generate -535098017.
To ensure consistency, we need to provide the exact same set of ZLayers. Instead of duplicating our code, we can refactor it by using nested suites, allowing the layers to be provided to the specific suite. Here is the full code of our test file:
import zio._
import zio.test._
import zio.http._
import Main.routes
import zio.json._
def baseRequest(): ZIO[Server, Nothing, Request] =
ZIO.serviceWith[Server](_.port)
.map(port => Request.get(url = URL.root.port(port)))
extension(testServer: TestServer)
def addRoutes[R](
routes: Routes[R, Response],
): ZIO[R, Nothing, Unit] =
for {
r <- ZIO.environment[R]
provided = routes.provideEnvironment(r)
app: HttpApp[Any] = provided.toHttpApp
_ <- testServer.driver.addApp(app, r)
} yield ()
object WebServerSuite extends ZIOSpecDefault:
def spec = suite("WebServerTest") {
suite("NonEnvironmental") {
test("FrameworkTest") {
ZIO.succeed(assertTrue(true))
}
} +
suite("Environmental") {
test("TestServerTest") {
for {
client <- ZIO.service[Client]
testRequest <- baseRequest()
_ <- TestServer.addRequestResponse(testRequest, Response(Status.Ok))
response <- client(testRequest)
} yield assertTrue(response.status == Status.Ok)
} +
test("HealthRequestTest") {
for {
_ <- TestRandom.setSeed(123L)
testRequest <- baseRequest()
server <- ZIO.service[TestServer]
client <- ZIO.service[Client]
_ <- server.addRoutes(routes)
response <- client(Request.get(testRequest.url / "health"))
body <- response.body.asString
} yield assertTrue(body == Health("ok", -535098017).toJson)
}
}.provide(
TestServer.layer,
Scope.default,
Client.default,
Driver.default,
ZLayer.succeed(Server.Config.default.onAnyOpenPort)
)
}
Conclusion
In this article, we explored the zio-test and zio-http-testkit libraries. As demonstrated, ZIO offers a DSL and special test services to make unit testing easy, intuitive, and straightforward.
Even though our web server currently has very basic functionality and there isn’t much to cover with unit tests, we have still learned the structure and basic principles of unit testing in ZIO.
In the next article, we will focus on Dependency Injection and delve deeper into ZLayers. Stay tuned!
메타데이터
- post_id
- bb3e3bb6dec2
- slug
- building-zio-web-app-part-2-unit-testing-bb3e3bb6dec2
- url
- https://medium.com/@ankochem/building-zio-web-app-part-2-unit-testing-bb3e3bb6dec2
- canonical_url
- https://medium.com/@ankochem/building-zio-web-app-part-2-unit-testing-bb3e3bb6dec2
- author_url
- https://medium.com/@ankochem
- status
- ok
- fetched_at
- 2026-06-27 18:20:27