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 ZIOSpecDefault {
 def spec =
  suite("GameCommandParser")(
   suite("parse")(
    test("menu returns Menu command") {
     for {
      result <- GameCommandParser.parse("menu").either.right
     } yield assertTrue(result == GameCommand.Menu)
    },
    test("number in range 1-9 returns Put command") {
     val results = ZIO.foreach(1 to 9) { n =>
      for {
       result <- GameCommandParser.parse(s"$n").either.right
       expectedField <- ZIO.from(Field.make(n))
      } yield assertTrue(result == GameCommand.Put(expectedField))
     }
     results.flatMap(results => ZIO.from(results.reduceOption(_ && _)))
    },
    test("invalid command returns error") {
     check(invalidCommandsGen) { input =>
      for {
       result <- GameCommandParser.parse(input).either.left
      } yield assertTrue(result == ParseError)
     }
    }
   )
  ).provideLayer(GameCommandParserLive.layer)

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

As you can see, all of the tests depend on the GameCommandParser service, therefore we will need to provide it so zio-test is able to run the tests. We can now provide the GameCommandParserLive implementation to the whole suite by using Spec#provideLayer.

Writing TerminalSpec

Let’s take a look at the spec:

object TerminalSpec extends ZIOSpecDefault {
 def spec =
  suite("Terminal")(
   test("getUserInput delegates to Console") {
    check(Gen.string) { input =>
     for {
     _ <- TestConsole.feedLines(input)
     result <- Terminal.getUserInput
     } yield assertTrue(result == input)
    }
   },
   test("display delegates to Console") {
    check(Gen.string) { frame =>
     for {
      result <- Terminal.display(frame)
    } yield assertTrue(result == ())
   }
  }
 ).provideLayer(TerminalLive.layer) @@ TestAspect.silent
}

 

Some important things worth noting:

  • Each test needs a TerminalLive environment to run, and TerminalLive uses the standard Console service from ZIO to be able to print texts to the console.
  • What’s great about zio-test is that it doesn’t use the live implementations of standard ZIO services such as Console, but test implementations instead. So, for instance, TestConsole doesn’t just print texts to the console when you call Console.printLine, but it also stores them to a TestConsole.output vector which you can use to make assertions. Also, you can simulate user input by calling TestConsole.feedLines.
  • Something very nice as well is that you can tweak the behavior of TestConsole such that texts are not actually printed to the console but just stored in a vector. For that, you can apply an aspect to your test suite, more specifically TestAspect.silent

Writing GameModeSpec

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

test("returns state with added piece and turn advanced to next player if field is unoccupied") {
 val gameCommandParserMock: ULayer[GameCommandParser] =
  GameCommandParserMock.Parse(Assertion.equalTo("put 6"), Expectation.value(GameCommand.Put(Field.East)))
 val gameLogicMock: ULayer[GameLogic] =
  GameLogicMock.PutPiece(
   Assertion.equalTo((gameState.board, Field.East, Piece.Cross)),
   Expectation.value(pieceAddedEastState.board)
 ) ++
   GameLogicMock
   .GameResult(Assertion.equalTo(pieceAddedEastState.board), Expectation.value(GameResult.Ongoing)) ++
   GameLogicMock.NextTurn(Assertion.equalTo(Piece.Cross), Expectation.value(Piece.Nought))
for {
 result <- GameMode
     .process("put 6", gameState)
     .provide(
      gameCommandParserMock,
      GameViewMock.empty,
      OpponentAiMock.empty,
      gameLogicMock,
      GameModeLive.layer
      )
 } yield assertTrue(result == pieceAddedEastState)
}

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

import zio.mock._

val gameCommandParserMock: ULayer[GameCommandParser] =
 GameCommandParserMock.Parse(Assertion.equalTo("put 6"), Expectation.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:

import zio.mock._
 
@mockable[GameCommandParser]
object GameCommandParserMock 

As shown above, we are now using the @mockable annotation that is included in the zio-mock 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.

By the way, there’s something else of interest, if we take a closer look at this expression:

GameCommandParserMock.Parse(Assertion.equalTo("put 6"), Expectation.value(GameCommand.Put(Field.East)))

It returns a value of type Expectation[GameCommandParser], but we are storing it as a ULayer[GameCommandParser], 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]. This means that, because mocks can be defined as ZLayers, we can easily provide them to ZIO effects!

I won’t go into more details about how ZIO mocks work. 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[GameLogic] =
  GameLogicMock.PutPiece(
   Assertion.equalTo((gameState.board, Field.East, Piece.Cross)),
   Expectation.value(pieceAddedEastState.board)
 ) ++
   GameLogicMock
   .GameResult(Assertion.equalTo(pieceAddedEastState.board), Expectation.value(GameResult.Ongoing)) ++
   GameLogicMock.NextTurn(Assertion.equalTo(Piece.Cross), Expectation.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 services 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-mock 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 provide these mocks (remember they can be treated as normal ZLayers) for running the test:

for {
result <- GameMode
    .process("put 6", gameState)
    .provide(
    gameCommandParserMock,
    GameViewMock.empty,
    OpponentAiMock.empty,
    gameLogicMock,
    GameModeLive.layer
    )
} yield assertTrue(result == pieceAddedEastState)

 

PREVIOUS
Chapter
05
NEXT
Chapter
07