Motivation
Wherever in your code you use ReactiveMongo driver, you can pass Acolyte MongoDB driver instead tests.
Then any connection created will be managed by your Acolyte (query & writer) handlers.
Usage
-
- Configure connection handler according expected behaviour: which response to which query, which result for which write request.
-
- Allow the persistence code to be given a
MongoDriver
according environment (e.g. test, dev, …, prod).
- Allow the persistence code to be given a
-
- Provide this testing driver to persistence code during validation.
// 1. On one side configure the Mongo handler
import acolyte.reactivemongo.{
AcolyteDSL, ConnectionHandler, QueryResponse, Request, WriteOp, WriteResponse
}
val connectionHandler1: ConnectionHandler = AcolyteDSL.handleQuery {
req: Request => // returns result according executed query
QueryResponse.empty
}.withWriteHandler { (op: WriteOp, req: Request) =>
// returns result according executed write operation
WriteResponse(1/* = update count */)
}
// 2. In Mongo persistence code, allowing (e.g. cake pattern)
// to provide driver according environment.
import scala.concurrent.Future
import reactivemongo.api.MongoDriver
trait MongoPersistence {
def driver: MongoDriver
def foo: Future[Boolean] =
??? /* Function using driver, whatever is the way it's provided */
}
object ProdPersistence extends MongoPersistence {
/* e.g. Resolve driver according configuration file */
def driver: MongoDriver = ???
}
// 3. Finally in unit tests
import scala.concurrent.ExecutionContext.Implicits.global
import acolyte.reactivemongo.AcolyteDSL
def isOk: Future[Boolean] = AcolyteDSL.withDriver { d =>
val persistenceWithTestingDriver = new MongoPersistence {
val driver: MongoDriver = d // provide testing driver
}
persistenceWithTestingDriver.foo
}
When result Future is complete, MongoDB resources initialized by Acolyte are released (driver and connections).
For persistence code expecting driver as parameter, resolving testing driver is straightforward.
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import reactivemongo.api.{ MongoConnection, MongoDriver }
import acolyte.reactivemongo.AcolyteDSL,
AcolyteDSL.{ withConnection, withDriver }
import acolyte.reactivemongo.Request
def yourFunctionUsingMongo(drv: MongoDriver) = "foo"
def yourConnectionHandler = AcolyteDSL.handleQuery { req: Request =>
acolyte.reactivemongo.QueryResponse( // any query result
reactivemongo.api.bson.BSONDocument("foo" -> "bar")
)
}
val res: Future[String] = withDriver { implicit driver: MongoDriver =>
withConnection(yourConnectionHandler) { c =>
val con: MongoConnection = c // configured with `yourConnectionHandler`
val s: String = yourFunctionUsingMongo(driver)
// ... dispatch query and write request as you want using pattern matching
s
}
}
As in previous example, main API object is Acolyte DSL.
Dependency can be added to SBT project with "org.eu.acolyte" %% "reactive-mongo" % "1.2.7"
, or in a Maven one as following:
<dependency>
<groupId>org.eu.acolyte</groupId>
<artifactId>reactive-mongo_2.12</artifactId>
<version>1.2.7</version>
</dependency>
Get started
Setup in your project
Driver behaviour is configured using connection handlers, themselves based on query and write handlers, managing respectively MongoDB queries or write operations, and returning appropriate result.
You can start looking at empty/no-op connection handler. With driver configured in this way, there is no query or write handler. So as no response is provided whatever is the command performed, it will raise explicit error No response: ...
for every request.
import scala.concurrent.ExecutionContext.Implicits.global
import reactivemongo.api.MongoConnection
import acolyte.reactivemongo.AcolyteDSL
AcolyteDSL.withDriver { implicit drv: reactivemongo.api.MongoDriver =>
AcolyteDSL.withConnection(
AcolyteDSL.handle/*= ConnectionHandler.empty*/) { c =>
val noOpCon: MongoConnection = c
}
}
Acolyte DSL provides several ways to initialize MongoDB resources (driver, connection, DB and collection) your code could expect.
withDriver
withConnection
withDB
withCollection
withQueryHandler
withQueryResult
withWriteHandler
withWriteResult
The withX
naming denotes these are loan patterns, responsible to releases appropriate the resources.
import scala.concurrent.ExecutionContext.Implicits.global
import reactivemongo.api.{ DefaultDB, MongoConnection, MongoDriver }
import reactivemongo.api.collections.bson.BSONCollection
import acolyte.reactivemongo.{
AcolyteDSL, ConnectionHandler, QueryResponse, PreparedResponse,
Request, WriteResponse, WriteOp
}
// Simple cases
def yourFunctionWorkingWithDriver(drv: MongoDriver) = ???
AcolyteDSL.withDriver { d: MongoDriver =>
yourFunctionWorkingWithDriver(d)
}
def yourHandler: ConnectionHandler = AcolyteDSL.handleWrite {
(_: WriteOp, _: Request) => WriteResponse(1) // update count for all
}
def yourFunctionWorkingWithConnection(con: MongoConnection) = ???
AcolyteDSL.withDriver { implicit drv: MongoDriver =>
AcolyteDSL.withConnection(yourHandler) { c: MongoConnection =>
yourFunctionWorkingWithConnection(c)
}
}
def yourFunctionWorkingWithDB(db: DefaultDB) = ???
AcolyteDSL.withDriver { implicit drv: MongoDriver =>
AcolyteDSL.withDB(yourHandler) { db: DefaultDB =>
yourFunctionWorkingWithDB(db)
}
}
def yourFunctionWorkingWithCol(col: BSONCollection) = ???
AcolyteDSL.withDriver { implicit drv: MongoDriver =>
AcolyteDSL.withCollection(yourHandler, "colName") { col =>
yourFunctionWorkingWithCol(col)
}
}
AcolyteDSL.withDriver { implicit d: MongoDriver =>
AcolyteDSL.withQueryHandler({ req: Request =>
val resp: PreparedResponse = QueryResponse.empty // empty doc list
resp
}) { _ => yourFunctionWorkingWithDriver(d) }
}
def queryResultForAll: PreparedResponse = QueryResponse.empty
{
implicit val shardedDriver = MongoDriver()
// need to be closed
AcolyteDSL.withQueryResult(queryResultForAll) { _ =>
yourFunctionWorkingWithDriver(shardedDriver)
}
val writeRes = WriteResponse(1) // update count
AcolyteDSL.withWriteHandler({ (_: WriteOp, _: Request) => writeRes }) { _ =>
yourFunctionWorkingWithDriver(shardedDriver)
}
val writeResultForAll = WriteResponse.undefined
AcolyteDSL.withWriteResult(writeResultForAll) { _ =>
yourFunctionWorkingWithDriver(shardedDriver)
}
}
// More complexe case
AcolyteDSL.withDriver { implicit d: MongoDriver => // expect a Future
val handler = AcolyteDSL.handleQuery { req: Request =>
QueryResponse.empty // any query result
}
def yourFunction1WorkingWithConnection(con: MongoConnection) = true
def yourFunction2WorkingWithConnection(con: MongoConnection) = ???
AcolyteDSL.withConnection(handler) { c1 =>
if (yourFunction1WorkingWithConnection(c1))
yourFunction2WorkingWithConnection(c1)
}
def yourFunction3WorkingWithConnection(con: MongoConnection) = ???
AcolyteDSL.withConnection(handler) { c2 => // expect a Future
yourFunction3WorkingWithConnection(c2) // return a Future
}
def yourFunctionWorkingWithColl(coll: BSONCollection) = ???
AcolyteDSL.withConnection(handler) { c3 => // expect a Future
AcolyteDSL.withDB(c3) { db => // expect a Future
AcolyteDSL.withCollection(db, "colName") { // expect Future
yourFunctionWorkingWithColl(_) // return a Future
}
}
}
}
Many other combinations are possible: see complete test cases.
Configure connection behaviour
At this point we can focus on playing handlers. To handle MongoDB query and to return the kind of result your code should work with, you can do as following.
import scala.concurrent.ExecutionContext.Implicits.global
import reactivemongo.api.{ MongoConnection, MongoDriver }
import reactivemongo.api.bson.BSONDocument
import acolyte.reactivemongo.{
AcolyteDSL, PreparedResponse, QueryResponse, Request
}
def aResponse: PreparedResponse = // any query result
QueryResponse(BSONDocument("foo" -> 2))
AcolyteDSL.withDriver { implicit driver: MongoDriver =>
AcolyteDSL.withConnection(
AcolyteDSL.handleQuery { req: Request => aResponse }) { c =>
val readOnlyCon: MongoConnection = c
// work with configured driver
}
}
// Then when Mongo code is given this driver instead of production one ...
// (see DI or cake pattern) and resolve a BSON collection `col` by this way:
import scala.util.{ Failure, Success }
import reactivemongo.api.Cursor
import reactivemongo.api.collections.bson.BSONCollection
def bar(col: BSONCollection) = col.find(BSONDocument("anyQuery" -> 1)).
cursor[BSONDocument]().collect[List](
-1, Cursor.FailOnError[List[BSONDocument]]()).onComplete {
case Success(res) => ??? // In case of response given by provided handler
case Failure(err) => ??? // "No response: " if case not handled
}
In the same way, write operations can be responded with appropriate result.
import scala.concurrent.ExecutionContext.Implicits.global
import reactivemongo.api.{ MongoConnection, MongoDriver }
import acolyte.reactivemongo.{ AcolyteDSL, Request, WriteOp }
AcolyteDSL.withDriver { implicit driver: MongoDriver =>
AcolyteDSL.withConnection(
AcolyteDSL handleWrite { (op: WriteOp, req: Request) => aResponse }) { c =>
val writeOnlyDriver: MongoConnection = c
// work with configured driver
}
}
// Then when Mongo code is given this driver instead of production one ...
// (see DI or cake pattern) and resolve a BSON collection `col` by this way:
import scala.util.{ Failure, Success }
import reactivemongo.api.bson.BSONDocument
import reactivemongo.api.collections.bson.BSONCollection
def foo(col: BSONCollection) =
col.insert(BSONDocument("prop" -> "value")).onComplete {
case Success(res) => ??? // In case or response given by provided handler
case Failure(err) => ??? // "No response: " if case not handled
}
Obviously connection handler can manage both queries and write operations:
import scala.concurrent.ExecutionContext.Implicits.global
import acolyte.reactivemongo.{
AcolyteDSL, QueryResponse, Request, WriteOp, WriteResponse
}
val completeHandler = AcolyteDSL.handleQuery { req: Request =>
// First define query handling
QueryResponse.undefined // any query result
} withWriteHandler { (op: WriteOp, req: Request) =>
// Then define write handling
WriteResponse.failed("Simulated error") // any write result
}
import reactivemongo.api.{ DefaultDB, MongoDriver }
AcolyteDSL.withDriver { implicit drv: MongoDriver =>
AcolyteDSL.withDB(completeHandler) { db: DefaultDB =>
// work with configured driver
}
}
Request patterns
Pattern matching can be used in handler to dispatch result accordingly.
import reactivemongo.api.bson.{ BSONInteger, BSONString }
import acolyte.reactivemongo.{
CountRequest, QueryHandler, QueryResponse, Request, InClause, Property,
RequestBody, SimpleBody, ValueDocument, ValueList, &
}
val queryHandler = QueryHandler { queryRequest =>
queryRequest match {
case Request("a-mongo-db.a-col-name", _) =>
// Any request on collection "a-mongo-db.a-col-name"
QueryResponse.undefined // result A
case Request(colNameOfAnyOther, _) => // Any request
QueryResponse.undefined // result B
case Request(colName, SimpleBody((k1, v1) :: (k2, v2) :: Nil)) =>
// Any request with exactly 2 BSON properties
QueryResponse.undefined // result C
case Request("db.col", SimpleBody(("email", BSONString(v)) :: _)) =>
// Request on db.col starting with email string property
QueryResponse.undefined // result D
case Request("db.col", SimpleBody(("name", BSONString("eman")) :: _)) =>
// Request on db.col starting with an "name" string property,
// whose value is "eman"
QueryResponse.undefined // result E
case Request(_, SimpleBody(("age", ValueDocument(
("$gt", BSONInteger(minAge)) :: Nil)) :: _)) =>
// Request on any collection, with an "age" document as property,
// itself with exactly one integer "$gt" property
// e.g. `{ 'age': { '$gt', 10 } }`
QueryResponse.undefined // result F
case Request("db.col", SimpleBody(~(Property("email"), BSONString(e)))) =>
// Request on db.col with an "email" string property,
// anywhere in properties (possible with others which are ignored there)
QueryResponse.undefined // result G
case Request("db.col", SimpleBody(
~(Property("name"), BSONString("eman")))) =>
// Request on db.col with an "name" string property with "eman" as value,
// anywhere in properties (possibly with others which are ignored there).
QueryResponse.undefined // result H
case Request(colName, SimpleBody(
~(Property("age"), BSONInteger(age)) &
~(Property("email"), BSONString(v)))) =>
// Request on any collection, with an "age" integer property
// and an "email" string property, possibly not in this order.
QueryResponse.undefined // result I
case Request(colName, SimpleBody(
~(Property("age"), ValueDocument(
~(Property("$gt"), BSONInteger(minAge)))) &
~(Property("email"), BSONString(email)))) =>
// Request on any collection, with an "age" property with itself
// a operator property "$gt" having an integer value, and an "email"
// property (at the same level as age), without order constraint.
QueryResponse.undefined // result J
case CountRequest(colName, ("email", BSONString("em@il.net")) :: Nil) =>
// Matching on count query
QueryResponse.count(10) // result K
case CountRequest(_, ("property", InClause(
BSONString("A") :: BSONString("B") :: Nil)) :: Nil) => {
// matches count with selector on 'property' using $in operator
QueryResponse.count(11) // result L
}
case Request("col1", SimpleBody(("$in", ValueList(
bsonA :: bsonB :: _)) :: Nil)) =>
// Matching BSONArray using with $in operator
QueryResponse.undefined // result M
case Request(_, RequestBody(List(("sel", BSONString("hector"))) ::
List(("updated", BSONString("property"))) :: Nil)) ⇒
// Matches a request with multiple document in body
// (e.g. update with selector)
QueryResponse.undefined // result N
}
}
Acolyte also provides extractors for inner clauses.
ValueList(List[(String, BSONValue)](_))
to match with[...]
.InClause(List[(String, BSONValue)](_))
to match with{ '$in': [...] }
.NotInClause(List[(String, BSONValue)](_))
to match with{ '$nin': [...] }
.
Pattern matching using rich syntax ~(..., ...)
requires scalac plugin.
Without this plugin, such parametrized extractor need to be declared as stable identifier before match
block:
import reactivemongo.api.bson.BSONString
import acolyte.reactivemongo.{ Property, Request, SimpleBody }
// With scalac plugin
def test1(request: Request) = request match {
case Request("db.col", SimpleBody(
~(Property("email"), BSONString(e)))) => ??? // result
// ...
}
// Without
val EmailXtr = Property("email")
// has declare email extractor before, as stable identifier
def test2(request: Request) = request match {
case Request("db.col", SimpleBody(EmailXtr(BSONString(e)))) =>
??? // result
// ...
}
In case of write operation, handler is given the write operator along with the request itself, so dispatch can be based on this information (and combine with pattern matching on request content).
import acolyte.reactivemongo.{
DeleteOp, InsertOp, Request, UpdateOp, WriteHandler, WriteResponse
}
val handler1 = WriteHandler { (op, wreq) =>
(op, wreq) match {
case (DeleteOp, Request("a-mongo-db.a-col-name", _)) =>
WriteResponse(1) // result delete
case (InsertOp, _) => WriteResponse.undefined // result insert
case (UpdateOp, _) =>
WriteResponse.failed("Simulated error, code = ", 12) // result update
}
}
There is also convenient extractor for write operations.
import acolyte.reactivemongo.{
DeleteRequest,
InsertRequest,
UpdateRequest,
WriteHandler,
WriteResponse
}
val handler2 = WriteHandler { (op, req) =>
case InsertRequest("colname", ("prop1", BSONString("val")) :: _) =>
WriteResponse(1) // update count
case UpdateRequest("colname",
("sel", BSONString("ector")) :: Nil,
("prop1", BSONString("val")) :: _) =>
WriteResponse(2)
case DeleteRequest("colname", ("sel", BSONString("ector")) :: _) =>
WriteResponse.failed("Simulated error")
}
In case of insert operation, the
_id
property is added to original document, so it must be taken in account if pattern matching over properties of saved document.
Result creation for queries
MongoDB result to be returned by query handler, can be created as following:
import reactivemongo.api.bson.BSONDocument
import acolyte.reactivemongo.{ QueryResponse, PreparedResponse }
val error1: PreparedResponse = QueryResponse.failed("Error #1")
val error2 = QueryResponse("Error #1") // equivalent
val success1 = QueryResponse(BSONDocument("name" -> "singleResult"))
val success2 = QueryResponse.successful(BSONDocument("name" -> "singleResult"))
val success3 = QueryResponse(Seq(
BSONDocument("name" -> "singleResult"), BSONDocument("price" -> 1.2D)))
val success4 = QueryResponse.successful(
BSONDocument("name" -> "singleResult"), BSONDocument("price" -> 1.2D))
val success5 = QueryResponse.empty // successful empty response
val success6 = QueryResponse(List.empty[BSONDocument]) // equivalent
val countResponse = QueryResponse.count(4) // response to Mongo Count
When a handler supports some query cases, but not other, it can return an undefined response, to let the chance other handlers would manage it.
import acolyte.reactivemongo.QueryResponse
val undefined1 = QueryResponse(None)
val undefined2 = QueryResponse.undefined
Result creation for write operation
MongoDB result to be returned by write handler, can be created as following:
import acolyte.reactivemongo.{ WriteResponse, PreparedResponse }
val error3: PreparedResponse = WriteResponse.failed("Error #1")
val error4 = WriteResponse("Error #1") // equivalent
val error5 = WriteResponse.failed("Error #2", 1/* code */)
val error6 = WriteResponse("Error #2" -> 1/* code */) // equivalent
val success7 = WriteResponse(1/* update count */ -> true/* updatedExisting */)
val success8 = WriteResponse.successful(1, true) // equivalent
val success9 = WriteResponse() // = WriteResponse.successful(0, false)
When a handler supports some write cases, but not other, it can return an undefined response, to let the chance other handlers would manage it.
import acolyte.reactivemongo.WriteResponse
val undefined3 = WriteResponse(None)
val undefined4 = WriteResponse.undefined
Integration
Acolyte for ReactiveMongo can be used with various test and persistence frameworks.
Specs2
It can be used with specs2 to write executable specification for function accessing persistence.
import reactivemongo.api.bson.BSONDocument
import acolyte.reactivemongo.{ AcolyteDSL, QueryResponse }
import org.specs2.concurrent.ExecutionEnv
class MySpec1(implicit ee: ExecutionEnv)
extends org.specs2.mutable.Specification {
implicit def driverProvider: reactivemongo.api.MongoDriver = ???
"Mongo persistence" should {
"properly work with query result" in {
def res = QueryResponse(BSONDocument("foo" -> 1))
AcolyteDSL.withQueryResult(res) { driver =>
// code executing query with driver,
// and parsing result as expected
} aka "result" must beEqualTo(???).
await // as ReactiveMongo is async and returns Future
}
}
// ...
}
In order to use same driver across several example, a custom After
trait can be used.
import acolyte.reactivemongo.AcolyteDSL
sealed trait WithDriver extends org.specs2.mutable.After {
implicit lazy val driver = AcolyteDSL.driver
def after = driver.close()
}
class MySpec2 extends org.specs2.mutable.Specification {
"Foo" should {
"Bar" >> new WithDriver {
implicit val d = driver
// many examples...
}
}
}
To make all Acolyte handlers in a specification share the same driver, it’s possible to benefit from specs2 global tear down.
import org.specs2.specification.core.Fragments
import org.specs2.mutable.Specification
import acolyte.reactivemongo.AcolyteDSL
sealed trait WithDriver { specs: Specification =>
implicit lazy val driver = AcolyteDSL.driver
override def map(fs: => Fragments) = fs ^ step(driver.close())
}
class MySpec3 extends Specification with WithDriver {
// `driver` available for all examples
}
Play Framework
Acolyte can be used with the ReactiveMongo plugin for Play Framework, with instances of Play ReactiveMongoApi
managed with Acolyte Handlers.
import scala.concurrent.ExecutionContext.Implicits.global
import reactivemongo.api.{ MongoConnection, MongoDriver }
import play.modules.reactivemongo.ReactiveMongoApi
import acolyte.reactivemongo.{ AcolyteDSL, PlayReactiveMongoDSL }
def codeBasedOnPlayReactiveMongo(api: ReactiveMongoApi) = ???
AcolyteDSL.withDriver { implicit drv: MongoDriver =>
AcolyteDSL.withConnection(connectionHandler1) { con: MongoConnection =>
val mongo: ReactiveMongoApi = PlayReactiveMongoDSL.mongoApi(drv, con)
codeBasedOnPlayReactiveMongo(mongo)
}
}
See online API documentation
SBT
Using SBT, a single driver/handler pool can be used for all tests, configuring testOptions
with Tests.Cleanup
.
First in test sources, define the shared driver.
package your.pkg
object Shared {
lazy val driver = acolyte.reactivemongo.AcolyteDSL.driver
def closeDriver = driver.close()
}
Then in SBT settings, this driver can be closed after testing.
testOptions in Test += Tests.Cleanup(cl => {
val c = cl.loadClass("your.pkg.Shared$")
type M = { def closeDriver(): Unit }
val m: M = c.getField("MODULE$").get(null).asInstanceOf[M]
m.closeDriver()
})