Testing Incrementally with ZIO Environment
In my last post, I introduced ZIO Environment, which is a new feature in ZIO that bakes in a high-performance, type-safe, and fully-inferred reader effect into the ZIO data type.
This capability leads to a way of describing and testing effects that I call environmental effects. Unlike tagless-final, which is difficult to teach, difficult to abstract over, and does not infer, environmental effects are simple, abstract well, and infer completely.
Moreover, while proponents of tagless-final argue that tagless-final parametrically constrains effects, my last post demonstrated this is not quite correct: not only can you embed raw effects anywhere in Scala, but even without leaving Scalazzi (the purely functional subset of Scala), you can lift arbitrary effects into any
The inability of tagless-final to constrain effects is more than just theoretical:
- New Scala functional programmers use effect type classes like
Synceverywhere (which are themselves lawless and serve only to embed effects), and they embed effects using lazy methods, like
- Even some experienced Scala functional programmers embed effects in pure methods (for example, exceptions in the functions they pass to
flatMap), and some effect types encourage this behavior.
Tagless-final can be used by a well-trained and highly-disciplined team to constrain effects, but the same can be said for many approaches, including environmental effects.
After professionally and pedagogically wrestling with these issues for several years now, I’ve come to the conclusion there are just two legitimately compelling reasons to use tagless-final:
- Avoiding commitment to a specific effect type, which can be useful for library authors, but which is less useful for application developers (often it’s a hinderance!);
- Writing testable functional code, which is fairly straightforward with tagless-final because you can just create test instances for different effect type classes.
While testability is a compelling reason to use tagless-final, it’s not necessarily a compelling reason to choose tagless-final over other approaches—in particular, over environmental effects.
In this post, I’m going to show you how to use environmental effects to achieve testability. I hope to demonstrate that environmental effects provide easier and more incremental testability—all without sacrificing teachability, abstraction, or type inference.
A Web App
Let’s say we are building a web application with ZIO. Suppose the application was originally written with
Future or perhaps some version of
Later, the application was ported to ZIO’s
Task[A], which is a type alias for
ZIO[Any, Throwable, A]—representing an effect that requires no specific environment and that may fail with any
Now let’s say one of the functions in our application, called
inviteFriends, invites the friends of a given user to the application by sending them emails:
Portions of the
As currently written, our web application is not very testable. The function
inviteFriends makes direct calls to database functions, Facebook API functions, and email service functions.
While we may have automated tests for our web service, because our application interacts directly with the real world, the tests are actually system tests, not unit tests. Such tests are very difficult to write, they run slowly, they randomly fail, and they test much more than our application logic.
We do not have time to rewrite our application, and we cannot make it testable all at once. Instead, let’s try to remove dependency on the live database for the
If we succeed in doing this, we will make our test code a little better, and after we ship the new code, we can incrementally use the same technique to make the function fully testable—fast, deterministic, and without any external dependencies.
Steps Toward Testability
To incrementally refactor
inviteFriends to be more testable, we’re going to perform the following series of refactorings:
- Introduce a type alias.
- Introduce a module for the database.
- Implement a production database module.
- Integrate the production module.
- Implement a test database module.
- Test the
Each of these steps will be covered in the sections that follow.
Introduce A Type Alias
To simplify the process of refactoring our application, we’re going to first introduce a simple type alias that we can use in the definition of
inviteFriends and the functions that call it:
Now we will mechanically update the
lookupUser function and any functions that call it to use the type alias:
As an alternative to this technique, we could simply delete the return types entirely. However, it’s a good practice to place return types on top-level function signatures, so developers without IDEs can easily determine the return type of functions.
After this step, we are ready to introduce a service for the database.
Introduce a Database Module
The database module will provide access to a database service.
As discussed in my post on ZIO Environment, the database module is an ordinary interface with a single field, which contains the database service.
We can define both the module and the service very simply:
Notice how we have decided to place just one method inside the database service: the
lookupUser method. Although there may be many database methods, we don’t have time to make all of them testable, so we will focus on the one required by the
We are now ready to implement a production version of the service.
Implement Production Module
We will call the production database module
DatabaseLive. To implement the module, we need only copy and paste the implementation of
Database.lookupUser into our implementation of the service interface:
For maximum flexibility and convenience, we have defined both a trait that implements the database module, which can be mixed into other traits, and an object that extends the trait, which can be used standalone.
Integrate Production Module
We now have all the pieces we need to replace the original
DB.lookupUser method, whose actual implementation now resides inside our
lookupUser method merely delegates to the database module, by accessing the model through ZIO Environment (
Here we don’t use the
Webapp type alias, because the functions in
DB will not necessarily have the same dependencies as our web application.
However, after enough refactoring, we might introduce a new type alias in the
type DB[A] = ZIO[Database, Throwable, A]. Eventually, all methods in
DB might return effects of this type.
At this point, our refactoring is nearly complete. But we have to take care of one last detail: we have to provide our database module to the production application.
There are two main ways to provide the database module to our application. If it is inconvenient to propagate the
Webapp type signature to the top of our application, we can always supply the production module somewhere inside our application.
In the worst case, if we are pressed for time and need to ship code today, maybe we choose to provide the production database wherever we call
If we have a bit more time, we can push the
Webapp type synonym to the entry point of our purely functional application, which might be the main function, or it might be where our web framework calls into our code.
In this case, instead of using the
DefaultRuntime that ships with ZIO, we can define our own
Runtime, which provides the production database module):
The custom runtime can be used to run many different effects that all require the same environment, so we don’t have to call
provide on all of them before we run them.
Once we have this custom runtime, we can run our top-level effect, which will supply its required environment:
At this point, we have not changed the behavior of our application at all—it will work exactly as it did before. We’ve just moved the code around a bit, so we can access a tiny effect through ZIO environment.
Now it’s time to build a database module specifically for testing.
Implement Test Module
We could implement the test database module using a mocking framework. However, to avoid all magic and use of reflection, in this post, we will build one from scratch.
For maximum flexibility, our test database module will track all calls to
lookupUser, and supply responses using a
Map, which can be dynamically changed by the test suite.
To support this stateful behavior, we will need a
Ref, which is a concurrent-safe ZIO data structure that models mutable references. We will also need a simple (immutable) data structure to hold the state of the test database module.
We define the following test data structure, which is capable of tracking a list of
UserID values, and holding data that maps from
Now we can define the service of our test database module. The service will require a
Ref[TestDatabaseState], so it can not only use test data, but update the test state:
Notice how the
lookupUser function stores the
UserID of every call in the
lookups field of the
TestDatabaseState. In addition, the function retrieves test responses from the map. If there is no response in the map, the function fails, presumably in the same way the production database would fail.
The test service must be placed in a module. In general, we should wait to create the module until the test suite, because then we will know the full set of dependencies for each test.
However, at this stage, the database service is the only dependency in our application, so we can make a helper function to create the test database module:
We now have all the pieces necessary to write a test of the
inviteFriends function, which will use our test database module.
Write the Test
To more easily test the
lookupFriends function, we will define a helper function. Given test data and input to the function, the helper will return the final test state and the output of the
The helper function creates a
Ref with the initial test data, uses the
Ref to create the
TestDatabase module, and then supplies the database module to the effect returned by
With this helper function, writing a test becomes quite simple:
This test for
inviteFriends is not perfect. It still interacts with a real Facebook API and a real email service. But compared to whatever tests already exist, at least this test does not interact with a real database.
Moreover, we were able to make this change in a minimally disruptive manner.
A Glimpse Beyond
After a little more refactoring, of course, we would succeed in making
inviteFriends fully testable. Even after the full refactoring, the code for
lookupFriends would not change.
Instead, our type alias for
Webapp would expand to include new environmental effects:
Now all the methods in the
Running our application would now look a little different:
lookupFriends would be entirely fast, deterministic, and type-safe, without any dependencies on external systems, or use of any reflection.
Environmental effects make it easy to test purely functional applications—significantly easier and with less ceremony than tagless-final, with full type inference, and without false promises.
Morever, with environmental effects, we can make even just a single function testable, by pushing that function into an environmental effect. This requires just a few small changes that can be done incrementally without major disruption to the application.
This ability lets us make incremental progress towards better application architecture. We don’t have to solve all the problems in our code base at once. We can focus on making our application a little better each day.
While this post focused on ZIO Environment, if you’re using
Future, Monix, or Cats IO, you can still use this approach with a
ReaderT monad transformer. With a monad transformer, you will lose type inference and some performance, but you will gain the other benefits in a more familiar package.
In future posts, I will cover how to provide partial dependencies, how to model services that require other services (the graph problem), how to hide implementation details, and how this approach differs from the classic cake pattern. Stay tuned for more!