avatar

Andres Jaimes

Testing scala classes and controllers

By Andres Jaimes

- 7 minutes read - 1489 words

This article discusses different approaches for testing classes, services, and PlayFramework controllers using scalatest.

Setting up the project dependencies

First step is to add to build.sbt the next dependency:

1"org.scalatest" %% "scalatest" % "3.2.2"

The following template unit test uses the WordSpec style which offers a natural way for writing tests. scalatest offers more styles, but I find this one more expressive.

PlaySpec (found in the PaylFramework) extends WordSpec and has a similar behavior.

 1package testing
 2
 3import org.scalatest.BeforeAndAfterAll
 4import org.scalatest.wordspec.AnyWordSpec
 5
 6class MyTemplateSpec extends AnyWordSpec with BeforeAndAfterAll {
 7
 8  import MyTemplateSpec._
 9
10  override def beforeAll(): Unit = ???
11
12  override def afterAll(): Unit = ???
13
14  "An item to test" when {
15    "a function is called or an event is triggered" should {
16      "do something if a condition is met" in {
17         assert(...)
18      }
19
20      "throw an exception if a condition is not met" in {
21        intercept[Exception] {
22          f()
23        }
24      }
25    }
26  }
27
28}
29
30object MyTemplateSpec {
31
32}

Mockito

Add the MockitoSugar trait to the class:

1import org.mockito.Mockito
2import org.scalatest.mock.MockitoSugar
3
4class MyClassSpec extends AnyWordSpec with BeforeAndAfterAll with MockitoSugar
 1// First, create the mock object
 2val mockCollaborator = mock[Collaborator]
 3
 4// Create the class under test and pass the mock to it
 5classUnderTest = new ClassUnderTest
 6classUnderTest.addListener(mock)
 7
 8// Use the class under test
 9classUnderTest.addDocument("Document", new Array[Byte](0))
10classUnderTest.addDocument("Document", new Array[Byte](0))
11classUnderTest.addDocument("Document", new Array[Byte](0))
12classUnderTest.addDocument("Document", new Array[Byte](0))
13
14// Then verify the class under test used the mock object as expected
15verify(mockCollaborator).documentAdded("Document")
16verify(mockCollaborator, times(3)).documentChanged("Document")

Here is another example that uses mockito to send a body request using the play framework.

 1import org.mockito.Mockito
 2import org.scalatest.mock.MockitoSugar
 3import play.api.test.Helpers._
 4
 5...
 6
 7"some test" in {
 8  val app: Application =
 9          new GuiceApplicationBuilder()
10            .build()
11
12  val controller = app.injector.instanceOf[MyController]
13  val person = Person("Name", 30)
14  val request = mock[Request[JsValue]]
15  Mockito.when(request.body) thenReturn Json.toJson(person)
16
17  val response = await(controller.create()(request))
18  response.header.status mustBe INTERNAL_SERVER_ERROR
19  response.body.contentType mustBe Some("application/json")
20  (contentAsJson(Future.successful(response)) \ "message").get.as[String] must include("Some description")
21}
22
23...
24
25case class Person(name: String, age: Int)

We are expecting the previous controller call to return the following response:

1{
2  "message": "Some description and probably something else"
3}

Mockito.when can be to receive and return generic or specific parameters as well. However, it’s important not to mix both types of parameters or an exception will be thrown. Here’s an example using generic parameters, which takes an additional implicit generic parameter and returns a boolean:

1Mockito.when(someService.function(any[String], any[Int], any[Option[String]])(any[String])) thenReturn Future.successful(true)
2
3...
4
5verify(someService, times(1)).function(any[String], any[Int], any[Option[String]])(any[String])

We don’t have to build a full injector to test a controller. We can instantiate a class and mock any required parameters. For example:

 1val appConfig = AppConfig(ConfigFactory.load)
 2val service1 = mock[SomeService1]
 3val service2 = mock[SomeService2]
 4
 5...
 6
 7"some test" in {
 8  val controller = new SomeController(appConfig, someService1, someService2)
 9  // we can now use it
10}

Testing a service and database access

I like this method because it does not use mocks, but a real database. This class extends from PlaySpec.

 1import play.api.test.Helpers._
 2import play.api.Application
 3import play.api.inject.guice.GuiceApplicationBuilder
 4
 5val app: Application =
 6    new GuiceApplicationBuilder()
 7      .build()
 8
 9val appConfig = AppConfig(ConfigFactory.load)
10// or val appConfig = app.injector.instanceOf[AppConfig]
11val someService = app.injector.instanceOf[SomeService]
12
13"insert a record data into the database" in {
14  val person = Person(
15    name = "Name",
16    age = 30
17  )
18  try {
19    val insert: Person = await(someService.insert(person))
20    val result: Option[Person] = await(someService.findById(person.id))
21    assert(result.isDefined && (result.get.name == person.name))
22  } finally {
23    await(someService.delete(person.id)) // clean up the db
24  }
25}

Application injection

We can use injection for creating and overriding/mocking classes. The following example uses overrides for mocking a service class using a dummy one we have created that returns expected values, and configure for overriding values in our application.conf file. We can have as many overrides and configure calls as needed.

 1import play.api.Application
 2import play.api.inject.bind
 3import play.api.inject.guice.GuiceApplicationBuilder
 4
 5...
 6
 7val app: Application = new GuiceApplicationBuilder()
 8  .overrides(bind[RealService].to[DummyService])
 9  .configure("config.entry" -> s"dummy-value")
10  .build()

This way of overriding values is very useful for testing controllers.

Testing controllers

A simple example:

 1import play.api.test._
 2
 3...
 4
 5val app: Application = new GuiceApplicationBuilder().build()
 6
 7"get a response that contains our code" in  {
 8  val code = "ABC-123"
 9  val result = route(app, FakeRequest(GET, s"/path/page?code=$code")).map(contentAsString(_))
10  result.map(s => s must (include(code)))
11}

We can replace contentAsString with contentAsJson for json testing.

Adding Server.withRouter to our tests

A more complex example that creates a mock server that responds to internal requests from our endpoint.

 1import play.api.Application
 2import play.api.inject.guice.GuiceApplicationBuilder
 3import play.api.mvc.{Action, Results}
 4import play.api.test._
 5import play.core.server.Server
 6
 7...
 8
 9"verify we are calling three endpoints service" in  {
10  Server.withRouter() {
11    // define our fake remote endpoints
12    case play.api.routing.sird.POST(accessTokenEndpoint) => Action {
13      Results.Ok.sendResource("auth-token.json")
14    }
15    case play.api.routing.sird.GET(userEndpoint) => Action {
16      Results.Ok.sendResource("user-info.json")
17    }
18    case play.api.routing.sird.DELETE(ordersEndpoint) => Action {
19      Results.Ok("")
20    }
21  } { implicit port =>
22    WsTestClient.withClient { client =>
23      // configure our app to use our fake remote endpoints
24      val app: Application = new GuiceApplicationBuilder()
25        .configure("some.tokensEndpoint" -> s"http://localhost:${port.value.toString()}/path/tokens")
26        .configure("some.usersEndpoint" -> s"http://localhost:${port.value.toString()}/path/users")
27        .configure("some.ordersEndpoint" -> s"http://localhost:${port.value.toString()}/path/orders")
28        .build()
29      val controller = app.injector.instanceOf[MyController]
30
31      // the following function internally calls the three remote endpoints
32      val response = controller.someEndpoint()(FakeRequest(POST, s"/start")
33                             .withFormUrlEncodedBody("code" -> "abc-123", "user" -> "user-123"))
34      status(response) mustBe OK
35      contentAsString(response) must (include("The process is complete."))
36    }
37  }
38}

Reusing an application object with tests that use Server.withRouter

Creating an application instance is expensive and if not reused, it can significatively slow down the testing process. If we want to reuse the application object across multiple tests, we have to:

  • specify a port, and don’t let Server.withRouter assign a random one.
  • pass the port number during our application instance creation.
  • move Server.withRouter to a function that receives the port number and our test as parameters.

See the folowing example:

 1@@ -0,0 +1,92 @@
 2import org.scalatestplus.play.PlaySpec
 3import play.api.{Application, Mode}
 4import play.api.inject.guice.GuiceApplicationBuilder
 5import play.api.mvc.{Action, Results}
 6import play.api.test.Helpers._
 7import play.api.test.WsTestClient
 8import play.core.server.{Server, ServerConfig}
 9
10class MyClassSpec extends PlaySpec {
11
12  import MyClassSpec._
13  
14  "MyClass" when {
15
16    val port = 54321 // specify a port and use it in every place
17    val app: Application = new GuiceApplicationBuilder()
18      .configure("myConfig.someEndpoint" -> s"http://localhost:$port/v2/some-path")
19      .build()
20    val myClass: MyClass = app.injector.instanceOf[MyClass]
21      
22    "receives a response from someEndpoint" should {
23
24      "return a valid item if some condition is met" in 
25        withServer(port) { _ =>
26          val result = await(myClass.someFunction("someParam1"))
27          assert(someCondition === true)
28        }
29      
30      "return an empty item if some condition is not met" in
31        withServer(port) { _ =>
32          val result = await(myClass.someFunction("someParam2"))
33          assert(someCondition === true)
34        }
35      
36    }
37    
38  }
39  
40}
41
42object MyClassSpec {
43  
44  def withServer(port: Int)(block: Unit => Unit): Unit = {
45    import play.api.routing.sird._
46    Server.withRouter(ServerConfig(port = Some(port), mode = Mode.Test)) {
47      case GET(p"/v2/some-path/$someParam") => Action {
48        someParam match {
49          case "someParam1" => 
50            Results.Ok.sendResource("valid-response.json")
51          case "someParam2" =>
52            Results.NotFound.sendResource("empty-response.json")
53        }
54      }
55    } { implicit port => 
56      WsTestClient.withClient { client =>
57        block(())
58      }
59    }
60  }
61
62}

This will call multiple times Server.withRouter, resulting in multiple instances, but the process is still very fast. What we really wanted to avoid was creating multiple application instances which slows down the process considerably.

Common errors

When testing Action.async endpoints there are times when we have seen an error similar to the following:

Type mismatch. Required: Future[Result] Found: Iteratee[Array[Byte], Result]

As explained by tjdett, the problem happens because play.api.mvc.Action[A] has two apply methods:

1// What you're hoping for
2def apply(request: Request[A]): Future[Result]
3
4// What actually gets called
5def apply(rh: RequestHeader): Iteratee[Array[Byte], Result]

Request[A] extends from RequestHeader, and the A in this case makes all the difference.

When you use ActionBuilder with a BodyParser[A], you create an Action[A]. As a result, you’ll need a Request[A] to test. parse.json returns a BodyParser[JsValue], so you need a Request[JsValue].

 1"should be valid" in {
 2  val controller = new TestController()
 3  val body: JsValue = ??? // Change this once your test compiles
 4
 5  // Could do these lines together, but this shows type signatures
 6  val request: Request[JsValue] = FakeRequest().withBody(body)
 7  val result: Future[Result] = controller.index().apply(request)
 8
 9  /* assertions */
10}

A longer example can look like this:

 1"my test" in {
 2  service.someFunction(any) returns Future.successful(())
 3
 4  val request: Request[JsValue] = FakeRequest()
 5    .withSession(session:_*)
 6    .withBody(Json.toJson(SomeCaseClass(prop1, prop2)))
 7  val result = controller.someEndpoint()(request)
 8
 9  status(result) shouldBe OK
10  contentAsString(result) should include ("Some text")
11
12  verify(service, times(1)).someFunction(any)
13}

Another source of error can be using withJsonBody instead of withBody.

References