The new ZLayer feature in ZIO 1.0.0-RC18 + is a significant improvement on the old module pattern, making adding new services much faster and easier. However, in practice, I have found that it can take a while to master this idiom.
Below is an annotated example of the final version of my test code in which I look at a number of use cases. Many thanks to Adam Fraser for helping me optimize and refine my work. The services are intentionally simplified, so hopefully they will be clear enough to read quickly.
I assume that you have a basic understanding of ZIO tests and that you are familiar with basic information regarding modules.
All code runs in zio tests and is a single file.
Here's the tip:
import zio._
import zio.test._
import zio.random.Random
import Assertion._
object LayerTests extends DefaultRunnableSpec {
type Names = Has[Names.Service]
type Teams = Has[Teams.Service]
type History = Has[History.Service]
val firstNames = Vector( "Ed", "Jane", "Joe", "Linda", "Sue", "Tim", "Tom")
Names
So, we got to our first service - Names (Names)
type Names = Has[Names.Service]
object Names {
trait Service {
def randomName: UIO[String]
}
case class NamesImpl(random: Random.Service) extends Names.Service {
println(s"created namesImpl")
def randomName =
random.nextInt(firstNames.size).map(firstNames(_))
}
val live: ZLayer[Random, Nothing, Names] =
ZLayer.fromService(NamesImpl)
}
package object names {
def randomName = ZIO.accessM[Names](_.get.randomName)
}
Everything here is within the framework of a typical modular pattern.
- Declare Names as a type alias for Has
- In the object, define Service as a trait
- Create an implementation (of course you can create multiple),
- Create a ZLayer inside the object for the given implementation. The ZIO convention tends to call them in real time.
- A package object is added that provides an easy-to-access shortcut.
In live it is used
ZLayer.fromService
which is defined as:
def fromService[A: Tagged, B: Tagged](f: A => B): ZLayer[Has[A], Nothing, Has[B]
Ignoring Tagged (this is necessary for all Has / Layers to work), you can see that here the function f: A => B is used - which in this case is just a constructor of the case class for
NamesImpl
.
As you can see, Names requires Random from the zio environment to work.
Here's a test:
def namesTest = testM("names test") {
for {
name <- names.randomName
} yield {
assert(firstNames.contains(name))(equalTo(true))
}
}
It uses
ZIO.accessM
to extract Names from the environment. _.get
retrieves the service.
We provide Names for the test as follows:
suite("needs Names")(
namesTest
).provideCustomLayer(Names.live),
provideCustomLayer
adds the Names layer to the existing environment.
Teams
The essence of the Teams (Teams) is to test the dependencies between modules, which we have created.
object Teams {
trait Service {
def pickTeam(size: Int): UIO[Set[String]]
}
case class TeamsImpl(names: Names.Service) extends Service {
def pickTeam(size: Int) =
ZIO.collectAll(0.until(size).map { _ => names.randomName}).map(_.toSet ) // , , < !
}
val live: ZLayer[Names, Nothing, Teams] =
ZLayer.fromService(TeamsImpl)
}
Teams will select a team from the available names by size .
Following the module usage patterns, although pickTeam needs Names to work , we don't put it in the ZIO [Names, Nothing, Set [String]] - instead, we keep a reference to it in
TeamsImpl
.
Our first test is simple.
def justTeamsTest = testM("small team test") {
for {
team <- teams.pickTeam(1)
} yield {
assert(team.size)(equalTo(1))
}
}
To run it, we need to give it a Teams layer:
suite("needs just Team")(
justTeamsTest
).provideCustomLayer(Names.live >>> Teams.live),
What is ">>>"?
This is a vertical composition. It indicates that we need the Names layer , which needs the Teams layer .
However, when running this, there is a small problem.
created namesImpl
created namesImpl
[32m+[0m individually
[32m+[0m needs just Team
[32m+[0m small team test
[36mRan 1 test in 225 ms: 1 succeeded, 0 ignored, 0 failed[0m
Returning to the definition
NamesImpl
case class NamesImpl(random: Random.Service) extends Names.Service {
println(s"created namesImpl")
def randomName =
random.nextInt(firstNames.size).map(firstNames(_))
}
So ours
NamesImpl
is created twice. What is the risk if our service contains some unique application system resource? In fact, it turns out that the problem is not at all in the Layers mechanism - the layers are remembered and not created several times in the dependency graph. This is actually an artifact of the test environment.
Let's change our test suite to:
suite("needs just Team")(
justTeamsTest
).provideCustomLayerShared(Names.live >>> Teams.live),
This fixes an issue, which means that the layer is created only once in the test.
JustTeamsTest only requires teams . But what if I wanted to access Teams and Names ?
def inMyTeam = testM("combines names and teams") {
for {
name <- names.randomName
team <- teams.pickTeam(5)
_ = if (team.contains(name)) println("one of mine")
else println("not mine")
} yield assertCompletes
}
For this to work, we need to provide both:
suite("needs Names and Teams")(
inMyTeam
).provideCustomLayer(Names.live ++ (Names.live >>> Teams.live)),
Here we are using the ++ combinator to create the Names layer with Teams . Pay attention to operator precedence and extra parentheses
(Names.live >>> Teams.live)
In the beginning, I fell for it myself - otherwise the compiler will not do it right.
History
History is a little more complicated.
object History {
trait Service {
def wonLastYear(team: Set[String]): Boolean
}
case class HistoryImpl(lastYearsWinners: Set[String]) extends Service {
def wonLastYear(team: Set[String]) = lastYearsWinners == team
}
val live: ZLayer[Teams, Nothing, History] = ZLayer.fromServiceM { teams =>
teams.pickTeam(5).map(nt => HistoryImpl(nt))
}
}
The constructor
HistoryImpl
requires many Names . But the only way to get it is by pulling it out from Teams . And it requires ZIO - so we use ZLayer.fromServiceM
it to give us what we need.
The test is carried out in the same way as before:
def wonLastYear = testM("won last year") {
for {
team <- teams.pickTeams(5)
ly <- history.wonLastYear(team)
} yield assertCompletes
}
suite("needs History and Teams")(
wonLastYear
).provideCustomLayerShared((Names.live >>> Teams.live) ++ (Names.live >>> Teams.live >>> History.live))
And thatβs all.
Throwable errors
The code above assumes that you are returning ZLayer [R, Nothing, T] β in other words, the environment service construct is of type Nothing. But if it does something like reading from a file or database, then it will most likely be ZLayer [R, Throwable, T] - because this kind of thing often involves the very external factor that is causing the exception. So imagine that there is an error in the Names construct. There is a way for your tests to work around this:
val live: ZLayer[Random, Throwable, Names] = ???
then at the end of the test
.provideCustomLayer(Names.live).mapError(TestFailure.test)
mapError
turns the object throwable
into a test failure - that's what you want - it might say the test file doesn't exist or something like that.
More ZEnv cases
The "standard" elements of the environment include Clock and Random. We have already used Random in our Names. But what if we also want one of these elements to further "lower" our dependencies? To do this, I created a second version of History - History2 - and here Clock is needed to create an instance.
object History2 {
trait Service {
def wonLastYear(team: Set[String]): Boolean
}
case class History2Impl(lastYearsWinners: Set[String], lastYear: Long) extends Service {
def wonLastYear(team: Set[String]) = lastYearsWinners == team
}
val live: ZLayer[Clock with Teams, Nothing, History2] = ZLayer.fromEffect {
for {
someTime <- ZIO.accessM[Clock](_.get.nanoTime)
team <- teams.pickTeam(5)
} yield History2Impl(team, someTime)
}
}
This is not a very useful example, but the important part is that the line
someTime <- ZIO.accessM[Clock](_.get.nanoTime)
forces us to provide the clock in the right place.
Now
.provideCustomLayer
can add our layer to the layer stack and it magically pops Random into Names. But this will not happen for the hours required below in History2. Therefore, the following code does NOT compile:
def wonLastYear2 = testM("won last year") {
for {
team <- teams.pickTeam(5)
_ <- history2.wonLastYear(team)
} yield assertCompletes
}
// ...
suite("needs History2 and Teams")(
wonLastYear2
).provideCustomLayerShared((Names.live >>> Teams.live) ++ (Names.live >>> Teams.live >>> History2.live)),
Instead, you need to provide the
History2.live
clock explicitly, which is done as follows:
suite("needs History2 and Teams")(
wonLastYear2
).provideCustomLayerShared((Names.live >>> Teams.live) ++ (((Names.live >>> Teams.live) ++ Clock.any) >>> History2.live))
Clock.any
Is a function that gets any clock available from above. In this case, it will be a test clock, because we didn't try to use Clock.live
.
Source
The complete source code (excluding the throwable) is shown below:
import zio._
import zio.test._
import zio.random.Random
import Assertion._
import zio._
import zio.test._
import zio.random.Random
import zio.clock.Clock
import Assertion._
object LayerTests extends DefaultRunnableSpec {
type Names = Has[Names.Service]
type Teams = Has[Teams.Service]
type History = Has[History.Service]
type History2 = Has[History2.Service]
val firstNames = Vector( "Ed", "Jane", "Joe", "Linda", "Sue", "Tim", "Tom")
object Names {
trait Service {
def randomName: UIO[String]
}
case class NamesImpl(random: Random.Service) extends Names.Service {
println(s"created namesImpl")
def randomName =
random.nextInt(firstNames.size).map(firstNames(_))
}
val live: ZLayer[Random, Nothing, Names] =
ZLayer.fromService(NamesImpl)
}
object Teams {
trait Service {
def pickTeam(size: Int): UIO[Set[String]]
}
case class TeamsImpl(names: Names.Service) extends Service {
def pickTeam(size: Int) =
ZIO.collectAll(0.until(size).map { _ => names.randomName}).map(_.toSet ) // , , < !
}
val live: ZLayer[Names, Nothing, Teams] =
ZLayer.fromService(TeamsImpl)
}
object History {
trait Service {
def wonLastYear(team: Set[String]): Boolean
}
case class HistoryImpl(lastYearsWinners: Set[String]) extends Service {
def wonLastYear(team: Set[String]) = lastYearsWinners == team
}
val live: ZLayer[Teams, Nothing, History] = ZLayer.fromServiceM { teams =>
teams.pickTeam(5).map(nt => HistoryImpl(nt))
}
}
object History2 {
trait Service {
def wonLastYear(team: Set[String]): Boolean
}
case class History2Impl(lastYearsWinners: Set[String], lastYear: Long) extends Service {
def wonLastYear(team: Set[String]) = lastYearsWinners == team
}
val live: ZLayer[Clock with Teams, Nothing, History2] = ZLayer.fromEffect {
for {
someTime <- ZIO.accessM[Clock](_.get.nanoTime)
team <- teams.pickTeam(5)
} yield History2Impl(team, someTime)
}
}
def namesTest = testM("names test") {
for {
name <- names.randomName
} yield {
assert(firstNames.contains(name))(equalTo(true))
}
}
def justTeamsTest = testM("small team test") {
for {
team <- teams.pickTeam(1)
} yield {
assert(team.size)(equalTo(1))
}
}
def inMyTeam = testM("combines names and teams") {
for {
name <- names.randomName
team <- teams.pickTeam(5)
_ = if (team.contains(name)) println("one of mine")
else println("not mine")
} yield assertCompletes
}
def wonLastYear = testM("won last year") {
for {
team <- teams.pickTeam(5)
_ <- history.wonLastYear(team)
} yield assertCompletes
}
def wonLastYear2 = testM("won last year") {
for {
team <- teams.pickTeam(5)
_ <- history2.wonLastYear(team)
} yield assertCompletes
}
val individually = suite("individually")(
suite("needs Names")(
namesTest
).provideCustomLayer(Names.live),
suite("needs just Team")(
justTeamsTest
).provideCustomLayer(Names.live >>> Teams.live),
suite("needs Names and Teams")(
inMyTeam
).provideCustomLayer(Names.live ++ (Names.live >>> Teams.live)),
suite("needs History and Teams")(
wonLastYear
).provideCustomLayerShared((Names.live >>> Teams.live) ++ (Names.live >>> Teams.live >>> History.live)),
suite("needs History2 and Teams")(
wonLastYear2
).provideCustomLayerShared((Names.live >>> Teams.live) ++ (((Names.live >>> Teams.live) ++ Clock.any) >>> History2.live))
)
val altogether = suite("all together")(
suite("needs Names")(
namesTest
),
suite("needs just Team")(
justTeamsTest
),
suite("needs Names and Teams")(
inMyTeam
),
suite("needs History and Teams")(
wonLastYear
),
).provideCustomLayerShared(Names.live ++ (Names.live >>> Teams.live) ++ (Names.live >>> Teams.live >>> History.live))
override def spec = (
individually
)
}
import LayerTests._
package object names {
def randomName = ZIO.accessM[Names](_.get.randomName)
}
package object teams {
def pickTeam(nPicks: Int) = ZIO.accessM[Teams](_.get.pickTeam(nPicks))
}
package object history {
def wonLastYear(team: Set[String]) = ZIO.access[History](_.get.wonLastYear(team))
}
package object history2 {
def wonLastYear(team: Set[String]) = ZIO.access[History2](_.get.wonLastYear(team))
}
For more advanced questions, please contact Discord # zio-users or visit the zio website and documentation .
Learn more about the course.