4
votes

Using Scala, Play Framework, Slick 3, Specs2.

I have a repository layer and a service layer. The repositories are quite dumb, and I use specs2 to make sure the service layer does its job.

My repositories used to return futures, like this:

def findById(id: Long): Future[Option[Foo]] =
  db.run(fooQuery(id))

Then it would be used in the service:

def foonicate(id: Long): Future[Foo] = {
  fooRepository.findById(id).flatMap { optFoo =>
    val foo: Foo = optFoo match {
      case Some(foo) => [business logic returning Foo]
      case None => [business logic returning Foo]
    }

    fooRepository.save(foo)
  }
}

Services were easy to spec. In the service spec, the FooRepository would be mocked like this:

fooRepository.findById(3).returns(Future(Foo(3)))

I recently found the need for database transactions. Several queries should be combined into a single transaction. The prevailing opinion seems to be that it's perfectly ok to handle transaction logic in the service layer.

With that in mind, I've changed my repositories to return slick.dbio.DBIO and added a helper method to run the query transactionally:

def findById(id: Long): DBIO[Option[Foo]] =
  fooQuery(id)

def run[T](query: DBIO[T]): Future[T] =
  db.run(query.transactionally)

The services compose the DBIOs and finally call the repository to run the query:

def foonicate(id: Long): Future[Foo] = {
  val query = fooRepository.findById(id).flatMap { optFoo =>
    val foo: Foo = optFoo match {
      case Some(foo) => [business logic finally returning Foo]
      case None => [business logic finally returning Foo]
    }

    fooRepository.save(foo)
  }

  fooRepository.run(query)
}

That seems to work, but now I can only spec it like this:

val findFooDbio = DBIO.successful(None))
val saveFooDbio = DBIO.successful(Foo(3))

fooRepository.findById(3) returns findFooDbio
fooRepository.save(Foo(3)) returns saveFooDbio
fooRepository.run(any[DBIO[Foo]]) returns Future(Foo(3))

That any in the run mock sucks! Now I'm not testing the actual logic but instead accept any DBIO[Foo]! I've tried to use the following:

fooRepository.run(findFooDbio.flatMap(_ => saveFooDbio)) returns Future(Foo(3))

But it breaks with java.lang.NullPointerException: null, which is specs2's way of saying "sorry mate, the method with this parameter wasn't found". I've tried various variations, but none of them worked.

I suspect that might be because functions can't be compared:

scala> val a: Int => String = x => "hi"
a: Int => String = <function1>
scala> val b: Int => String = x => "hi"
b: Int => String = <function1>
scala> a == b
res1: Boolean = false

Any ideas how to spec DBIO composition without cheating?

1

1 Answers

4
votes

I had a similar idea and also investigated it to see if I could:

  • create the same DBIO composition
  • use it with matcher in mocking

However I found out that it is actually infeasible in practice:

  • as you noticed, you cannot compare functions
  • additionally, when you investigate internals of DBIO, it is basically Free-monad-like structure - it has implementation for plain values and directly generated queries (then, if you extract the statements you could compare some part of the query), but there are also mappings which stores functions
  • even if you somehow managed to reuse functions, so that they had reference equality, implementations of DBIO do not care about overriding equals, so they would be different beast anyway

So knowing that, I gave up the initial idea. What I can recommend instead:

  • mock on any input of database.run - it is more error prone, as it won't notify you if test expectations start differing from returned results, but it's better than nothing
  • replace DBIO by some intermediate structure, that you know you can compare safely. E.g. Cats' Free monad implementation uses case classes so as long as you manage to ensure that functions are somehow comparable (e.g. by not creating them ad hoc, but instead using vals and objects), you could compare on intermediate representation, and mock whole interpret -> run process
  • replace unit tests with mocked database with integration tests with an actual database
  • try out Typed Tagless Final Interpreter pattern for handling databases - and basically inject in tests different monad than in production (e.g. prod -> service returning DBIO, production -> service returning Futures you want)

Actually, you could try many other things with Free, TTFI and swapping implementations. The bottom line is - you cannot reliably compare on DBIO, so design your code in a way, that you could test without doing so. It's not a pleasant answer, especially if you just wanted to put together test, and move on, but AFAIK there is no other way.