Exit e-book
Show all chapters
06
Writing the tests
06. 
Writing the tests
Mastering Modularity in ZIO with Zlayer
06

Writing the tests

As we have successfully implemented the TicTacToe application using ZLayers, let’s now write the application’s tests. We’ll cover just some of them here, and of course, you can take a look at the complete tests in the jorge-vasquez-2301/zio-zlayer-tictactoe repository.

Writing GameCommandParserSpec

Here’s the test suite for GameCommandParser:


object GameCommandParserSpec extends DefaultRunnableSpec {
  def spec =
    suite("GameCommandParser")(
      suite("parse")(
        testM("menu returns Menu command") {
          val result = GameCommandParser.parse("menu").either
          assertM(result)(isRight(equalTo(GameCommand.Menu)))
        },
        testM("number in range 1-9 returns Put command") {
          val results = ZIO.foreach(1 to 9) { n =>
            for {
              result        <- GameCommandParser.parse(s"$n").either
              expectedField <- ZIO.fromOption(Field.make(n))
            } yield assert(result)(isRight(equalTo(GameCommand.Put(expectedField))))
          }
          results.flatMap(results => ZIO.fromOption(results.reduceOption(_ && _)))
        },
        testM("invalid command returns error") {
          checkM(invalidCommandsGen) { input =>
            val result = GameCommandParser.parse(input).either
            assertM(result)(isLeft(equalTo(ParseError)))
          }
        }
      )
    ).provideCustomLayer(GameCommandParser.live)

  private val validCommands      = List(1 to 9)
  private val invalidCommandsGen = Gen.anyString.filter(!validCommands.contains(_))
}

As you can see, all tests depend on the GameCommandParser module, then we need to provide it so zio-test is able to run the tests. So, we can provide the GameCommandParser live implementation to the whole suite by using Spec#provideCustomLayer. This method provides each test with that part of the environment that does not belong to the standard TestEnvironment, leaving a spec that only depends on it.

Writing TerminalSpec

Let’s take a look to the spec:


object TerminalSpec extends DefaultRunnableSpec {
 def spec = suite("Terminal")(
   testM("getUserInput delegates to Console") {
     checkM(Gen.anyString) { input =>
       val consoleMock: ULayer[Console] = MockConsole.GetStrLn(value(input))
       val env: ULayer[Terminal]        = consoleMock >>> Terminal.live
       val result                       = Terminal.getUserInput.provideLayer(env)
       assertM(result)(equalTo(input))
     }
   },
   testM("display delegates to Console") {
     checkM(Gen.anyString) { frame =>
       val consoleMock: ULayer[Console] =
         MockConsole.PutStr(equalTo(Terminal.ansiClearScreen), unit) ++
           MockConsole.PutStrLn(equalTo(frame), unit)
       val env: ULayer[Terminal] = consoleMock >>> Terminal.live
       val result                = Terminal.display(frame).provideLayer(env)
       assertM(result)(isUnit)
     }
   }
 )
}

Some important things worth noting:

Each test needs a Terminal environment to run, and Terminal itself depends on the Console module. So we create a consoleMock, using the MockConsole that zio-test provides us with:

val consoleMock: ULayer[Console] = MockConsole.GetStrLn(value(input))

In the above line, we are stating that when calling Console.getStrLn, it should return a value equal to input. And also, there’s something interesting: If we take a closer look to this expression:

MockConsole.GetStrLn(value(input))

It returns a value of type Expectation[Console], but we are storing it as a ULayer[Console], and there are no compilation errors… The reason is that ZIO provides an implicit function Expectation#toLayer, which converts an Expectation[R] to a ULayer[R].

  • Because mocks can be defined as ZLayers, we can easily compose them with other ZLayers, using horizontal and vertical composition! For example, we are using the vertical composition here:
val env: ULayer[Terminal] = consoleMock >>> Terminal.live
  • For providing the environment for each test, we are using ZIO#provideLayer. This means that you can provide an environment separately to each test, or you can provide an environment to a whole suite like we did for GameCommandParserSpec.

Finally, remember we can use inject from ZIO Magic to inject the environments that are required by our tests! Let’s see how that looks:

import zio.magic._

object TerminalSpec extends DefaultRunnableSpec {
 def spec = suite("Terminal")(
   testM("getUserInput delegates to Console") {
     checkM(Gen.anyString) { input =>
       val consoleMock: ULayer[Has[Console.Service]] = MockConsole.GetStrLn(value(input))
       val result                                    = Terminal.getUserInput.inject(consoleMock, TerminalLive.layer)
       assertM(result)(equalTo(input))
     }
   },
   testM("display delegates to Console") {
     checkM(Gen.anyString) { frame =>
       val consoleMock: ULayer[Has[Console.Service]] =
         MockConsole.PutStr(equalTo(TerminalLive.ansiClearScreen), unit) ++ MockConsole.PutStrLn(equalTo(frame), unit)
       val result = Terminal.display(frame).inject(consoleMock, TerminalLive.layer)
       assertM(result)(isUnit)
     }
   }
 )
}

 

Writing GameModeSpec

In this case, let’s concentrate on just one test instead of the whole suite:

testM("returns state with added piece and turn advanced to next player if field is unoccupied") {
 val gameCommandParserMock: ULayer[Has[GameCommandParser]] =
   GameCommandParserMock.Parse(equalTo("put 6"), value(GameCommand.Put(Field.East)))
 val gameLogicMock: ULayer[Has[GameLogic]] =
   GameLogicMock.PutPiece(
     equalTo((gameState.board, Field.East, Piece.Cross)),
     value(pieceAddedEastState.board)
   ) ++
     GameLogicMock.GameResult(equalTo(pieceAddedEastState.board), value(GameResult.Ongoing)) ++
     GameLogicMock.NextTurn(equalTo(Piece.Cross), value(Piece.Nought))
 val env: ULayer[Has[GameMode]] =
   (gameCommandParserMock ++ GameViewMock.empty ++ OpponentAiMock.empty ++ gameLogicMock) >>> GameModeLive.layer

 val result = GameMode.process("put 6", gameState).provideLayer(env)
 assertM(result)(equalTo(pieceAddedEastState))
}

The above test is for GameMode.process, and GameMode depends on several modules: GameCommandParser, GameView, OpponentAi and GameLogic. So, to be able to run the test, we need to provide mocks for those modules, and that’s what’s precisely happening in the above lines. First, we write a mock for GameCommandParser:

val gameCommandParserMock: ULayer[Has[GameCommandParser]] =
  GameCommandParserMock.Parse(equalTo("put 6"), value(GameCommand.Put(Field.East)))

As you may have realized, this line depends on a GameCommandParserMock object, and we are stating that when we call GameCommandParser.parse with an input equal to “put 6”, it should return a value of GameCommand.Put(Field.East). By the way, the GameCommandParserMock is defined in the mocks.scala file:

@mockable[GameCommandParser]
object GameCommandParserMock

As shown above, we are now using the @mockable annotation that is included in the zio-test library. This annotation is a really nice macro that generates a lot of boilerplate code for us automatically, otherwise we would need to write it ourselves. For reference, here is the generated code:

object GameCommandParserMock extends Mock[Has[GameCommandParser]] {
 object Parse extends Effect[String, AppError, GameCommand]

 val compose: URLayer[Has[Proxy], Has[GameCommandParser]] =
   ZLayer.fromServiceM { proxy =>
     withRuntime.map { rts =>
       new GameCommandParser {
         def parse(input: String): IO[AppError, GameCommand] = proxy(Parse, input)
       }
     }
   }
}

I won’t go into more details about how mocks work in zio-test.  However if you do want to know more about this, you can take a look at the ZIO documentation page.

Then we have to write a mock for GameLogic:

val gameLogicMock: ULayer[Has[GameLogic]] =
 GameLogicMock.PutPiece(
   equalTo((gameState.board, Field.East, Piece.Cross)),
   value(pieceAddedEastState.board)
 ) ++
   GameLogicMock.GameResult(equalTo(pieceAddedEastState.board), value(GameResult.Ongoing)) ++
   GameLogicMock.NextTurn(equalTo(Piece.Cross), value(Piece.Nought))

The idea here is pretty much the same as how we defined gameCommandParserMock:

  • The mock is defined as a ZLayer.
  • We need to define a GameLogicMock object, similarly as we did above for GameCommandParserMock.
  • For combining expectations sequentially, we use the ++ operator (which is just an alias for the Expectation#andThen method).

Next, we should define mocks for GameView and OpponentAi. However, there’s a difference.  The reason is these modules are not actually called by GameMode.process (which is the function being tested), so these mocks should say that we expect them not to be called. Thankfully, in the current ZIO version there’s an easy way of stating that. Basically, the only thing we need to do is to define GameViewMock and OpponentAiMock objects as above (using the @mockable annotation), and then we can call  GameViewMock.empty and OpponentAiMock.empty to generate the mocks we want.

Next, we need to build the environment for running the test:

val env: ULayer[Has[GameMode]] =
  (gameCommandParserMock ++ GameViewMock.empty ++ OpponentAiMock.empty ++ gameLogicMock) >>> GameMode.live

val result = GameMode.process("put 6", gameState).provideLayer(env)

Notice that building the environment is just a matter of applying horizontal and vertical composition of ZLayers, and this can be provided to the test using ZIO#provideLayer.

Finally, remember we can simplify this last step by using ZIO Magic:

import zio.magic._

val result = GameMode
 .process("put 6", gameState)
 .inject(
   gameCommandParserMock,
   GameViewMock.empty,
   OpponentAiMock.empty,
   gameLogicMock,
   GameModeLive.layer
 )

 

PREVIOUS
Chapter
05
NEXT
Chapter
07