Skip to content

Commit

Permalink
Add example protocol unit testing (#49)
Browse files Browse the repository at this point in the history
* Add BookingCommandProtocolSuite

* Update documentation with mentions of protocol unit testing

* Set compatibility to full

Co-authored-by: Jonas Chapuis <[email protected]>
  • Loading branch information
jchapuis and Jonas Chapuis authored Nov 16, 2021
1 parent 916403e commit 9c8fe44
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 3 deletions.
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ inThisBuild(
scalaVersion := "2.13.7",
Global / onChangedBuildSource := ReloadOnSourceChanges,
PB.protocVersion := "3.17.3", // works on Apple Silicon,
versionPolicyIntention := Compatibility.None,
versionPolicyIntention := Compatibility.BinaryAndSourceCompatible,
versionPolicyIgnoredInternalDependencyVersions := Some(
"^\\d+\\.\\d+\\.\\d+\\+\\d+".r
) // Support for versions generated by sbt-dynver
Expand Down
6 changes: 5 additions & 1 deletion documentation/src/main/paradox/example.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,8 @@ Unit testing for entity algebra implementation, event handling and effector is e

@@snip [BookingEffectorSuite](/example/src/test/scala/endless/example/logic/BookingEffectorSuite.scala) { #example }

`CommandProtocol` is more effectively covered via component tests as it is mostly about serialization and switchboard boilerplate.
Command protocol can be also easily be covered with synchronous round-trip tests:

@@snip [BookingCommandProtocolSuite](/example/src/test/scala/endless/example/protocol/BookingCommandProtocolSuite.scala) { #example }

Component and integration tests using akka testkit are also advisable and work as usual, see @github[ExampleAppSuite](/example/src/test/scala/endless/example/ExampleAppSuite.scala).
6 changes: 5 additions & 1 deletion documentation/src/main/paradox/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ In other words, `client` materializes algebra invocations into concrete serializ
@@@ note { .info title="Explicit or implicit representations" }
`CommandProtocol` is the entry point for implementations to map algebra entries to concrete commands and replies. We tend to prefer explicit materialization for migration safety but nothing prevents protocol implementers to opt for automatic serialization via macros.
We provide helpers for definition of binary protocols in `endless-scodec-helpers` and JSON protocols in `endless-circe-helpers`.
@@@
@@@

@@@ note { .tip title="Testing" }
Command protocols can be tested in isolation via synchronous round-trip exercise of the journey _client invocation -> command materialization -> command encoding -> command decoding -> behavior invocation -> reply materialization -> reply encoding -> reply decoding_. See @github[BookingCommandProtocolSuite](/example/src/test/scala/endless/example/protocol/BookingCommandProtocolSuite.scala) for an example.
@@@
9 changes: 9 additions & 0 deletions example/src/test/scala/endless/example/logic/Generators.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package endless.example.logic

import endless.example.algebra.BookingAlg.{BookingAlreadyExists, BookingUnknown}
import endless.example.data.Booking
import endless.example.data.Booking.{BookingID, LatLon}
import org.scalacheck.{Arbitrary, Gen}
Expand All @@ -16,6 +17,14 @@ trait Generators {
destination <- latLonGen
passengerCount <- Gen.posNum[Int]
} yield Booking(id, origin, destination, passengerCount)
implicit val bookingAlreadyExists: Gen[BookingAlreadyExists] =
Gen.uuid.map(uuid => BookingAlreadyExists(BookingID(uuid)))
implicit val bookingUnknown: Gen[BookingUnknown.type] = Gen.const(BookingUnknown)

implicit val arbBooking: Arbitrary[Booking] = Arbitrary(bookingGen)
implicit val arbLatLon: Arbitrary[LatLon] = Arbitrary(latLonGen)
implicit val arbBookingAlreadyExists: Arbitrary[BookingAlreadyExists] = Arbitrary(
bookingAlreadyExists
)
implicit val arbBookingUnknown: Arbitrary[BookingUnknown.type] = Arbitrary(bookingUnknown)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package endless.example.protocol

import cats.Id
import endless.\/
import endless.example.algebra.BookingAlg
import endless.example.data.Booking
import endless.example.logic.Generators
import org.scalacheck.Prop.forAll
import cats.syntax.functor._

//#example
class BookingCommandProtocolSuite extends munit.ScalaCheckSuite with Generators {
val protocol = new BookingCommandProtocol

test("place booking") {
forAll { (booking: Booking, reply: BookingAlg.BookingAlreadyExists \/ Unit) =>
val outgoingCommand = protocol.client.place(
booking.id,
booking.passengerCount,
booking.origin,
booking.destination
)
val incomingCommand = protocol.server[Id].decode(outgoingCommand.payload)
val encodedReply = incomingCommand
.runWith(new TestBookingAlg {
override def place(
bookingID: Booking.BookingID,
passengerCount: Int,
origin: Booking.LatLon,
destination: Booking.LatLon
): Id[BookingAlg.BookingAlreadyExists \/ Unit] = reply
})
.map(incomingCommand.replyEncoder.encode(_))
assertEquals(outgoingCommand.replyDecoder.decode(encodedReply), reply)
}
}
//#example

test("get booking") {
forAll { (reply: BookingAlg.BookingUnknown.type \/ Booking) =>
val outgoingCommand = protocol.client.get
val incomingCommand = protocol.server[Id].decode(outgoingCommand.payload)
val encodedReply = incomingCommand
.runWith(new TestBookingAlg {
override def get: Id[BookingAlg.BookingUnknown.type \/ Booking] = reply
})
.map(incomingCommand.replyEncoder.encode(_))
assertEquals(outgoingCommand.replyDecoder.decode(encodedReply), reply)
}
}

test("change origin") {
forAll { (newOrigin: Booking.LatLon, reply: BookingAlg.BookingUnknown.type \/ Unit) =>
val outgoingCommand = protocol.client.changeOrigin(newOrigin)
val incomingCommand = protocol.server[Id].decode(outgoingCommand.payload)
val encodedReply = incomingCommand
.runWith(new TestBookingAlg {
override def changeOrigin(
origin: Booking.LatLon
): Id[BookingAlg.BookingUnknown.type \/ Unit] = reply
})
.map(incomingCommand.replyEncoder.encode(_))
assertEquals(outgoingCommand.replyDecoder.decode(encodedReply), reply)
}
}

test("change destination") {
forAll { (newDestination: Booking.LatLon, reply: BookingAlg.BookingUnknown.type \/ Unit) =>
val outgoingCommand = protocol.client.changeDestination(newDestination)
val incomingCommand = protocol.server[Id].decode(outgoingCommand.payload)
val encodedReply = incomingCommand
.runWith(new TestBookingAlg {
override def changeDestination(
destination: Booking.LatLon
): Id[BookingAlg.BookingUnknown.type \/ Unit] = reply
})
.map(incomingCommand.replyEncoder.encode(_))
assertEquals(outgoingCommand.replyDecoder.decode(encodedReply), reply)
}
}

test("change origin and destination") {
forAll {
(
newOrigin: Booking.LatLon,
newDestination: Booking.LatLon,
reply: BookingAlg.BookingUnknown.type \/ Unit
) =>
val outgoingCommand = protocol.client.changeOriginAndDestination(newOrigin, newDestination)
val incomingCommand = protocol.server[Id].decode(outgoingCommand.payload)
val encodedReply = incomingCommand
.runWith(new TestBookingAlg {
override def changeOriginAndDestination(
origin: Booking.LatLon,
destination: Booking.LatLon
): Id[BookingAlg.BookingUnknown.type \/ Unit] = reply
})
.map(incomingCommand.replyEncoder.encode(_))
assertEquals(outgoingCommand.replyDecoder.decode(encodedReply), reply)
}
}

test("cancel") {
forAll { (reply: BookingAlg.BookingUnknown.type \/ Unit) =>
val outgoingCommand = protocol.client.cancel
val incomingCommand = protocol.server[Id].decode(outgoingCommand.payload)
val encodedReply = incomingCommand
.runWith(new TestBookingAlg {
override def cancel: Id[BookingAlg.BookingUnknown.type \/ Unit] = reply
})
.map(incomingCommand.replyEncoder.encode(_))
assertEquals(outgoingCommand.replyDecoder.decode(encodedReply), reply)
}
}

trait TestBookingAlg extends BookingAlg[Id] {
def place(
bookingID: Booking.BookingID,
passengerCount: Int,
origin: Booking.LatLon,
destination: Booking.LatLon
): Id[BookingAlg.BookingAlreadyExists \/ Unit] = throw new RuntimeException(
"not supposed to be called"
)
def get: Id[BookingAlg.BookingUnknown.type \/ Booking] = throw new RuntimeException(
"not supposed to be called"
)
def changeOrigin(newOrigin: Booking.LatLon): Id[BookingAlg.BookingUnknown.type \/ Unit] =
throw new RuntimeException("not supposed to be called")
def changeDestination(
newDestination: Booking.LatLon
): Id[BookingAlg.BookingUnknown.type \/ Unit] = throw new RuntimeException(
"not supposed to be called"
)
def changeOriginAndDestination(
newOrigin: Booking.LatLon,
newDestination: Booking.LatLon
): Id[BookingAlg.BookingUnknown.type \/ Unit] = throw new RuntimeException(
"not supposed to be called"
)
def cancel: Id[BookingAlg.BookingUnknown.type \/ Unit] = throw new RuntimeException(
"not supposed to be called"
)
}
}

0 comments on commit 9c8fe44

Please sign in to comment.