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.
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.
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].
val env: ULayer[Terminal] = consoleMock >>> Terminal.live
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)
}
}
)
}
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:
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
)