In my unit tests I need to generate various events that all inherit from an abstract Event
class but are otherwise created differently. For instance, events A and B have the following signatures:
def makeEventA(a: Int, b: String): EventA
def makeEventB(p: String, q: Long, r: Long): EventB
The core logic for both events is the same and is defined on any subclass of Event
, so I'd like to create behaviour functions and re-use these too. For that I was thinking of creating a 'Maker' trait from which I can make each individual event and use the result inside the unit tests:
trait EventMaker {
def make[T <: Event](...): T
}
class EventAMaker extends EventMaker {
override def make[EventA](...) = /* custom implementation */
}
// similar for EventBMaker
The idea was to have unit tests where I mix in the right class and based on that the appropriate make
method is called. I'd like to vary the arguments with ScalaTest's built-in support for ScalaCheck, so that I do not have to hard-code all values and different events and basically copy-paste the same code (except for the parameter lists).
What I'm struggling with is the signature of make
. It should take the appropriate arguments for the specific class, which both differ in number, types, and names. Is what I'm trying to achieve even doable/sensible, is there a better way, or how do I proceed?
The alternative I thought about is to match on the various events and call the respective make
method (factory design pattern), but that leads to the same issue:
def make[T <: Event](eventType: T, ...): T = eventType match {
case EventA => new EventMakerA(...)
case EventB => new EventMakerB(...)
case _ => ...
}
Another option is to use Option
for all parameters with a default of None
, so that the signature match. However, this seems wasteful and doesn't exactly improve the legibility.
I believe, your best bet is to pack arguments into an opaque Tuple
and then dynamically or statically check, if the tuple matches the arguments required for the Event
.
Assuming the following setup:
import scala.reflect._
sealed trait Event
case class EventA(a: Int, b: String) extends Event
object EventA {
// this val is needed only for the dynamic checking approach
final val tag = classTag[EventA]
}
case class EventB(p: String, q: Long, r: Long) extends Event
object EventB {
// this val is needed only for the dynamic checking approach
final val tag = classTag[EventB]
}
When you check dynamically, you just match against the arguments, and check if they are in a tuple with a correct length and correct element types. This is not type-safe, and can throw ClassCastException
s at runtime, if event arguments have types with type arguments (e.g., l: List[Int]
or t: (Int, Double)
), so type erasure kicks in.
The code can be organised in different ways:
def make[T <: Event, Args](obj: Class[T], args: Args)(implicit tag: ClassTag[T]): T = ((tag, args) match {
case (EventA.tag, (a: Int, b: String)) => EventA(a, b)
case (EventB.tag, (p: String, q: Long, r: Long)) => EventB(p, q, r)
case (otherTag, otherArgs) => sys.error("wrong args for tag")
}).asInstanceOf[T]
scala> make(classOf[EventA], (10, "foo"))
res4: EventA = EventA(10,foo)
scala> make(classOf[EventB], ("bar", 10L, 20L))
res5: EventB = EventB(bar,10,20)
Or you can extract it into a class to be able to pass exact Event
type as a type argument:
class EventMaker[T <: Event] {
def apply[Args](args: Args)(implicit tag: ClassTag[T]): T = ((tag, args) match {
case (EventA.tag, (a: Int, b: String)) => EventA(a, b)
case (EventB.tag, (p: String, q: Long, r: Long)) => EventB(p, q, r)
case (otherTag, otherArgs) => sys.error("wrong args for tag")
}).asInstanceOf[T]
}
def make2[T <: Event] = new EventMaker[T]
scala> make2[EventA](10, "foo")
res6: EventA = EventA(10,foo)
scala> make2[EventB]("bar", 10L, 20L)
res7: EventB = EventB(bar,10,20)
You can also check arguments statically using shapeless
library (or alternatively with hand-written macros). This will result in a compilation error, if provided arguments don't match arguments required for the case class. Also, this works best if individual events are represented by case classes, but can also be adapted to support some functions with shapeless.ops.function.FnToProduct
import shapeless._
class EventMaker3[T <: Event] {
def apply[Args, H <: HList, H0 <: HList](args: Args)(implicit
genObj: Generic.Aux[T, H], // conversion between HList and case class
genArgs: Generic.Aux[Args, H0], // conversion between HList and arguments tuple
ev: H0 =:= H // assert that HList representations of the case class
// and provided arguments are the same
): T = genObj.from(genArgs.to(args))
}
def make3[T <: Event] = new EventMaker3[T]
scala> make3[EventA](10, "foo")
res8: EventA = EventA(10,foo)
scala> make3[EventB]("bar", 10L, 20L)
res9: EventB = EventB(bar,10,20)
scala> make3[EventA](1, 2)
<console>:21: error: Cannot prove that H0 =:= H.
make3[EventA](1, 2)
^