spray-testkit

One of sprays core design goals is good testability of the created services. Since actor-based systems can sometimes be cumbersome to test spray fosters the separation of processing logic from actor code in most of its modules.

For services built with spray-routing spray provides a dedicated test DSL that makes actor-less testing of route logic easy and convenient. This “route test DSL” is made available with the spray-testkit module.

Dependencies

Apart from the Scala library (see Current Versions chapter) spray-testkit depends on

  • spray-http (with ‘provided’ scope)
  • spray-httpx (with ‘provided’ scope)
  • spray-routing (with ‘provided’ scope)
  • spray-util
  • akka-actor 2.2.x (with ‘provided’ scope, i.e. you need to pull it in yourself)
  • akka-testkit 2.2.x (with ‘provided’ scope, i.e. you need to pull it in yourself)
  • scalatest (with ‘provided’ scope, for the ScalatestRouteTest)
  • specs2 (with ‘provided’ scope, for the Specs2RouteTest)

Installation

The Maven Repository chapter contains all the info about how to pull spray-testkit into your classpath. However, since you normally don’t need to have access to spray-testkit from your production code, you should limit the dependency to the test scope:

libraryDependencies += "io.spray" % "spray-testkit" % version % "test"

Currently spray-testkit supports the two most popular scala testing frameworks, scalatest and specs2. Depending on which one you are using you need to mix either the ScalatestRouteTest or the Specs2RouteTest trait into your test specification.

Usage

Here is an example of what a simple test with spray-testkit might look like:

import org.specs2.mutable.Specification
import spray.testkit.Specs2RouteTest
import spray.routing.HttpService
import spray.http.StatusCodes._

class FullTestKitExampleSpec extends Specification with Specs2RouteTest with HttpService {
  def actorRefFactory = system // connect the DSL to the test ActorSystem

  val smallRoute =
    get {
      pathSingleSlash {
        complete {
          <html>
            <body>
              <h1>Say hello to <i>spray</i>!</h1>
            </body>
          </html>
        }
      } ~
      path("ping") {
        complete("PONG!")
      }
    }

  "The service" should {

    "return a greeting for GET requests to the root path" in {
      Get() ~> smallRoute ~> check {
        responseAs[String] must contain("Say hello")
      }
    }

    "return a 'PONG!' response for GET requests to /ping" in {
      Get("/ping") ~> smallRoute ~> check {
        responseAs[String] === "PONG!"
      }
    }

    "leave GET requests to other paths unhandled" in {
      Get("/kermit") ~> smallRoute ~> check {
        handled must beFalse
      }
    }

    "return a MethodNotAllowed error for PUT requests to the root path" in {
      Put() ~> sealRoute(smallRoute) ~> check {
        status === MethodNotAllowed
        responseAs[String] === "HTTP method not allowed, supported methods: GET"
      }
    }
  }
}

The basic structure of a test built with spray-testkit is this (expression placeholder in all-caps):

REQUEST ~> ROUTE ~> check {
  ASSERTIONS
}

In this template REQUEST is an expression evaluating to an HttpRequest instance. Since both RouteTest traits extend the spray-httpx Request Building trait you have access to its mini-DSL for convenient and concise request construction. [1]

ROUTE is an expression evaluating to a spray-routing Route. You can specify one inline or simply refer to the route structure defined in your service.

The final element of the ~> chain is a check call, which takes a block of assertions as parameter. In this block you define your requirements onto the result produced by your route after having processed the given request. Typically you use one of the defined “inspectors” to retrieve a particular element of the routes response and express assertions against it using the test DSL provided by your test framework. For example, with specs2, in order to verify that your route responds to the request with a status 200 response, you’d use the status inspector and express an assertion like this:

status mustEqual 200

The following inspectors are defined:

Inspector Description
body: HttpEntity.NonEmpty Returns the contents of the response entity. If the response entity is empty a test failure is triggered.
charset: HttpCharset Identical to contentType.charset
chunks: List[MessageChunk] Returns the list of message chunks produced by the route.
closingExtension: String Returns chunk extensions the route produced with a ChunkedMessageEnd response part.
contentType: ContentType Identical to body.contentType
definedCharset: Option[HttpCharset] Identical to contentType.definedCharset
entity: HttpEntity Identical to response.entity
handled: Boolean Indicates whether the route produced an HttpResponse for the request. If the route rejected the request handled evaluates to false.
header(name: String): Option[HttpHeader] Returns the response header with the given name or None if no such header can be found.
header[T <: HttpHeader: ClassTag]: Option[T] Identical to response.header[T]
headers: List[HttpHeader] Identical to response.headers
mediaType: MediaType Identical to contentType.mediaType
rejection: Rejection The rejection produced by the route. If the route did not produce exactly one rejection a test failure is triggered.
rejections: List[Rejection] The rejections produced by the route. If the route did not reject the request a test failure is triggered.
response: HttpResponse The HttpResponse returned by the route. If the route did not return an HttpResponse instance (e.g. because it rejected the request) a test failure is triggered.
responseAs[T: Unmarshaller: ClassTag]: T Unmarshals the response entity using the in-scope FromResponseUnmarshaller for the given type. Any errors in the process trigger a test failure.
status: StatusCode Identical to response.status
trailer: List[HttpHeader] Returns the list of trailer headers the route produced with a ChunkedMessageEnd response part.
[1]If the request URI is relative it will be made absolute using an implicitly available instance of DefaultHostInfo whose value is “http://example.com” by default. This mirrors the behavior of spray-can which always produces absolute URIs for incoming request based on the request URI and the Host-header of the request. You can customize this behavior by bringing an instance of DefaultHostInfo into scope.

Sealing Routes

The section above describes how to test a “regular” branch of your route structure, which reacts to incoming requests with HTTP response parts or rejections. Sometimes, however, you will want to verify that your service also translates Rejections to HTTP responses in the way you expect.

You do this by wrapping your route with the sealRoute method defined by the HttpService trait. The sealRoute wrapper applies the logic of the in-scope ExceptionHandler and RejectionHandler to all exceptions and rejections coming back from the route, and translates them to the respective HttpResponse.

The on-spray-can examples defines a simple test using sealRoute like this:

"return a MethodNotAllowed error for PUT requests to the root path" in {
  Put() ~> sealRoute(demoRoute) ~> check {
    status === MethodNotAllowed
    responseAs[String] === "HTTP method not allowed, supported methods: GET, POST"
  }
}

Examples

A full example of how an API service definition can be structured in order to be testable with spray-testkit and without actor involvement is shown with the on-spray-can example. This is its test definition.

Another great pool of examples are the tests for all the predefined directives in spray-routing. They can be found here.