Testing, scala, spray, specs2, rest technologies

Scala REST API Integration Testing with Spray-testkit

Here you can find out how to create integration tests for RESTful service on the example of application shown in the article "Building REST service with Scala". To create the tests, I am going to use spray-testkit DSL, as it provides a simple way to test route logic for services built with spray-routing.

Also, to keep everything up to date, I've updated the RESTful service built in the article to use Spray version 1.2.1. You can find the updated code that works with newer versions of Spray, Akka and Lift JSON here.

To run the service and be sure that everything works correctly, specify the right mysql parameters in the src/main/resources/application.conf file and then create appropriate MySQL database.

So, let's get to implementing the tests:

Step 1. Configure dependencies

At first, configure build.sbt to use spray-testkit with specs2 by adding the following dependencies:

build.sbt
1
2
3
4
libraryDependencies ++= Seq(
  "io.spray" % "spray-testkit" % "1.2.1" % "test",
  "org.specs2" %% "specs2" % "2.3.13" % "test"
)

spray-testkit also supports ScalaTest, so it can be used instead of specs2.

Step 2. Configure database

To use a separate database for the testing, create the application.conf config file in the src/test/scala/resources/ and specify the db.name property:

src/test/scala/resources/application.conf
1
2
3
4
db {
    // the database name
    name = "restTest"
}

You can also configure any other property that is required for your test environment (see the example of the base config file here).

Step 3. Initialize service for the testing

Move implicits from the RestService trait to the CustomerConversions object to use them later in the tests:

src/main/scala/com/sysgears/example/rest/domain/Customer.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
object CustomerConversions {

    implicit val liftJsonFormats = new Formats {
        val dateFormat = new DateFormat {
            val sdf = new SimpleDateFormat("yyyy-MM-dd")

            def parse(s: String): Option[Date] = try {
                Some(sdf.parse(s))
            } catch {
                case e: Exception => None
            }

            def format(d: Date): String = sdf.format(d)
        }
    }

    implicit def HttpEntityToCustomer(httpEntity: HttpEntity) =
        Serialization.read[Customer](httpEntity.asString(HttpCharsets.`UTF-8`))
}

I've placed the implicits in Customer.scala, but you can use a separate file.

Create the CustomerServiceTestBase trait in the test folder, it will be the base trait for our tests.

CustomerServiceTestHelper should extend: Specification (to use specs2 matchers), Specs2RouteTest (to use spray routing DSL) and HttpService to connect the DSL to the test ActorSystem by setting actorRefFactory. Also, it should mixin the specs2 Before trait to run the code which cleans the database before test execution.

Then add the actorRefFactory to connect the DSL to the test ActorSystem, implement the service initialization to get the spray route instance for test requests, and create the following methods:

  • cleanDB - to clean customers table before test execution
  • implicit HttpEntityToListOfCustomers - to convert JSON response from service to List[Customer]
  • implicit HttpEntityToErrors - to convert JSON response with error description to Map[String, String]
src/test/scala/com/sysgears/example/rest/CustomerServiceTestBase.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import scala.language.existentials
import org.specs2.mutable.{Before, Specification}
import spray.testkit.Specs2RouteTest
import spray.routing.HttpService
import akka.actor.ActorRefFactory
import spray.http.{HttpCharsets, HttpEntity}
import net.liftweb.json.Serialization
import com.sysgears.example.domain.{Customers, Customer}
import com.sysgears.example.domain.CustomerConversions._
import com.sysgears.example.config.Configuration
import scala.slick.session.Database
import scala.slick.driver.MySQLDriver.simple.Database.threadLocalSession
import scala.slick.driver.MySQLDriver.simple._
import slick.jdbc.meta.MTable


trait CustomerServiceTestBase extends Specification
 with Specs2RouteTest with HttpService
 with Configuration with Before {

  // makes test execution sequential and prevents conflicts that may occur when the data is
  // changed simultaneously in the database
  args(sequential = true)

  val customerLink = "/customer"

  // connects the DSL to the test ActorSystem
  implicit def actorRefFactory = system

  val spec = this

  val customerService = new RestService {
    override implicit def actorRefFactory: ActorRefFactory = spec.actorRefFactory
  }.rest

  /**
   * Cleans the database before test execution.
   */
  def cleanDB() = {
    // inits database instance
    val db = Database.forURL(url = s"jdbc:mysql://$dbHost:$dbPort/$dbName",
      user = dbUser, password = dbPassword, driver = "com.mysql.jdbc.Driver")

    // drops tables if exist
    db.withSession {
      if (MTable.getTables("customers").list().nonEmpty) {
        Customers.ddl.drop
        Customers.ddl.create
      }
    }
  }

  // converts responses from the service
  implicit def HttpEntityToListOfCustomers(httpEntity: HttpEntity) =
    Serialization.read[List[Customer]](httpEntity.asString(HttpCharsets.`UTF-8`))

  implicit def HttpEntityToErrors(httpEntity: HttpEntity) =
    Serialization.read[Map[String, String]](httpEntity.asString(HttpCharsets.`UTF-8`))
}

Step 4. Create test data

Obviously, we need test data to run the tests, so let's create the CustomerTestData object with the list of two consumers and the date formatter for responses:

src/test/scala/com/sysgears/example/rest/CustomerTestData.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.text.SimpleDateFormat
import com.sysgears.example.domain.Customer

object CustomerTestData {

  val dateFormat = new SimpleDateFormat("yyyy-MM-dd")

  // the test data
  val birthday0 = Some(dateFormat.parse("1991-01-14"))
  val firstName0 = "Andrey"
  val lastName0 = "Litvinenko"
  val birthday1 = Some(dateFormat.parse("1987-01-14"))
  val firstName1 = "Corwin"
  val lastName1 = "Holmes"
  val customersIds = List(1, 2)
  val customers = List(
    Customer(Some(customersIds(0)), firstName0, lastName0, birthday0),
    Customer(Some(customersIds(1)), firstName1, lastName1, birthday1))
  val nonExistentCustomerId = customers.size + 1
}

If you need more data for tests, you might want to create a function to generate random data.

Step 5. Create tests

Now we are finally ready for writing the tests.

All the tests follow a certain template:

1
2
3
4
5
6
7
8
9
"service" should {
    "do something" in { // action description
         request ~> service ~> check { // request to service
          // response checks:
          status should be equalTo ???
          entity should be equalTo ???
         }
     }
 }

We are going to clean the DB before running the tests, and then test the main service functionality by sending CRUD requests and checking the responses.

Here is the shortened version of tests, you can see the full version in repository:

src/test/scala/com/sysgears/example/rest/CustomerServiceTest.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
import spray.http.HttpEntity
import net.liftweb.json.Serialization
import spray.http.HttpMethods._
import spray.http.StatusCodes._
import com.sysgears.example.domain.Customer
// imports the object with test data:
import com.sysgears.example.rest.CustomerTestData._
import com.sysgears.example.domain.CustomerConversions._
import spray.http.HttpRequest
import scala.Some

class CustomerServiceSpec extends CustomerServiceTestBase {

  def before = cleanDB()

  "Customer service" should {
    "post customers" in {
      HttpRequest(POST, customerLink, entity = HttpEntity(
        Serialization.write(customers(0)))) ~> customerService ~> check {

        response.status should be equalTo Created
        response.entity should not be equalTo(None)
        val respCustomer = responseAs[Customer]
        respCustomer.id.get must be greaterThan 0
        respCustomer must be equalTo customers(0)
      }

      HttpRequest(POST, customerLink, entity = HttpEntity(
        Serialization.write(customers(1)))) ~> customerService ~> check {

        response.status should be equalTo Created
        response.entity should not be equalTo(None)
        val respCustomer = responseAs[Customer]
        respCustomer.id.get must be greaterThan 0
        respCustomer must be equalTo customers(1)
      }
    }

    "return list of posted customers" in {
      Get(customerLink) ~> customerService ~> check {
        response.status should be equalTo OK
        response.entity should not be equalTo(None)
        val respCustomers = responseAs[List[Customer]]
        respCustomers.size should be equalTo 2
        respCustomers(0) must be equalTo customers(0)
        respCustomers(1) must be equalTo customers(1)
      }
    }

    "search for customers" in {
      "search by firstName" in {
        Get(s"$customerLink?firstName=$firstName0") ~> customerService ~> check {
          response.status should be equalTo OK
          response.entity should not be equalTo(None)
          val respCustomer = responseAs[Customer]
          respCustomer must be equalTo customers(0)
        }
      }
    }

    "return customer by id" in {
      Get(s"$customerLink/${customersIds(0)}") ~> customerService ~> check {
        response.status should be equalTo OK
        response.entity should not be equalTo(None)
        responseAs[Customer] must be equalTo customers(0)
      }
    }

    "update customer data" in {
      HttpRequest(PUT, s"$customerLink/${customersIds(0)}", entity = HttpEntity(
        Serialization.write(customers(1)))) ~> customerService ~> check {
        response.status should be equalTo OK
        response.entity should not be equalTo(None)
        responseAs[Customer]
            .copy(id = Some(customersIds(1))) must be equalTo customers(1)
      }

      Get(s"$customerLink/${customersIds(0)}") ~> customerService ~> check {
        response.status should be equalTo OK
        response.entity should not be equalTo(None)
        responseAs[Customer]
            .copy(id = Some(customersIds(1))) must be equalTo customers(1)
      }
    }

    "delete created customers by id" in {
      customersIds.map {
        id =>
          Delete(s"$customerLink/$id") ~> customerService ~> check {
            response.status should be equalTo OK
            response.entity should not be equalTo(None)
          }
      }.find(!_.isSuccess) === None
    }

    "return 404 and error message when we try to delete a non-existent customer" in {
      customersIds.map {
        id =>
          Delete(s"$customerLink/$id") ~> customerService ~> check {
            response.status should be equalTo NotFound
            response.entity should not be equalTo(None)
            responseAs[Map[String, String]].get("error") ===
              Some(s"Customer with id=$id does not exist")
          }
      }.find(!_.isSuccess) === None
    }
  }
}

See also:

Hope you found this helpful.

Looking to hire a software developer?
Don't hesitate to contact us.

Comments