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 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.
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:
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:
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)