1. Introduction

In this tutorial, we’ll demonstrate how to test Play application tests with Scalatest.

Application testing can generally be complex and slow, especially when an application comprises many modules. With this in mind, we’ll focus on unit tests, integration test setup, and encapsulation.

In brief, while unit tests focus on a class or even a method, integration tests focus on an entire module or a subsystem.

2. Setup

Before diving into the test suites, we should look at the additional dependencies in the build.sbt file:

libraryDependencies += guice
libraryDependencies += "com.h2database" % "h2" % "1.4.200"
libraryDependencies += "org.postgresql" % "postgresql" % "42.2.27"
libraryDependencies += "com.typesafe.play" %% "play-slick" % "6.0.0"
libraryDependencies += "com.typesafe.play" %% "play-slick-evolutions" % "6.0.0"
libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.0" % Test
libraryDependencies += "org.scalatestplus" %% "mockito-3-4" % "3.2.10.0" % Test

We’ll use Guice, Scalatest, Mockito, Slick, Postgres, and H2 in our test. For Guice, we use the dependency provided by the play sbt module. In addition, we’ll use the slick-evolutions library to populate the databases with data for the tests to use.

3. Application Context / Modules

Since applications are composed of many modules, it’s rare for a test to cover all of the modules. Instead, a test usually covers one or two modules, especially if the modules are split in a business-unit manner. In other words, it’s very convenient to omit the initialization of a third-party integration or a database connection pool if we only need to test an internal component.

With a well-thought module split, we can create lightweight application snapshots that bootstrap only what’s required and not everything, even if it’s pointless.

3.1. Overview of the Application Under Test

We wrote the Arrival application for this tutorial and kept it as simple as possible. Indeed, the Arrival application exposes one rest endpoint, and the service layer has only two services with two functions. Specifically, we implemented a controller function that fetches data from the repository and another function that decorates the fetched data. This three-tier application has layers for controllers, services, and repositories.

For a better understanding of the system under test, we also included a simple chart with the application modules:

UML diagram that describes the Arrival application module split.

The next sections show that those three tiers are used as boundaries during tests. For example, we can write tests where we test the controller layer and mock the service layer or write tests for the service layer while the repository layer is mocked.

3.2. Module and Configuration Split

Before we write our first test suite, let’s look at the configuration and the module split.

Starting with ControllerModule, we define the ArrivalController:

class ControllerModule extends AbstractModule {
  override def configure(): Unit = {
    bind(classOf[ArrivalController]).in(classOf[Singleton])
  }
}

Then, in the ServiceModule, we’ve included the service component definitions:

class ServiceModule extends AbstractModule {
  override def configure(): Unit = {
    bind(classOf[ArrivalService]).in(classOf[Singleton])
    bind(classOf[ArrivalDecoratorService]).in(classOf[Singleton])
  }
}

Finally, SlickModule contains the slick repository definitions:

class SlickModule extends AbstractModule {
  override def configure(): Unit = {
    bind(classOf[ArrivalRepository]).to(classOf[SlickArrivalRepository])
  }
}

Additionally, the configuration is split into three files.

In the base.conf, we defined the common configuration:

play.modules.enabled += "com.baeldung.arrival.modules.SlickModule"
play.modules.enabled += "com.baeldung.arrival.modules.ServiceModule"
play.modules.enabled += "com.baeldung.arrival.modules.ControllerModule"
play.evolutions.autoApply = true
short-name-max = 5
medium-name-max = 8

The H2-related configuration is included in the h2.conf:

play.modules.enabled += "com.baeldung.arrival.modules.H2Module"
slick.dbs.h2 {
  profile = "slick.jdbc.H2Profile$"
  db {
    profile = "org.h2.Driver"
    url = "jdbc:h2:mem:play;MODE=MYSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE"
  }
}

For Postgres, we defined the configuration in the postgres.conf file:

play.modules.enabled += "com.baeldung.arrival.modules.PostgresModule"
slick.dbs.postgres {
  profile = "slick.jdbc.PostgresProfile$"
  db {
    dataSourceClass = "org.postgresql.ds.PGSimpleDataSource"
    properties = {
      serverName = "localhost"
      portNumber = "5432"
      databaseName = "arrival_db"
      user = "postgres"
      password = "postgres"
    }
    numThreads = 10
  }
}

3.3. Context Test Suite

The component that we’ll isolate is the ArrivalService:

class ArrivalService @Inject() (db: DbManager, arrivalRepository: ArrivalRepository) {
  def getArrivals(): Future[Seq[Arrival]] = db.execute(arrivalRepository.getArrivals)
}

The InMemoryArrivalRepository will be our no-database repository implementation:

class InMemoryArrivalRepository extends ArrivalRepository {
  override def getArrivals: DBIO[Seq[Arrival]] = {
    SuccessAction(Seq(
      Arrival(12L, "Istanbul", "Athens", "A380"),
      Arrival(17L, "Dublin", "Oslo", "A380")
    ))
  }
}

The InMemoryDbManager is the in-memory DbManager that will materialize Slick’s DBIO:

class InMemoryDbManager extends DbManager {
  override def dbConfig: DatabaseConfig[_] = ???
  override def execute[T](dbio:  DBIO[T]): Future[T] = Future.successful(dbio.asInstanceOf[SuccessAction[T]].value)
}

With the building blocks in place, let’s write the ArrivalServiceIsolatedTest:

override def fakeApplication(): Application = {
  GuiceApplicationBuilder(
    modules = Seq(new ServiceModule),
    configuration = Configuration(
      "play.http.router" -> "play.api.routing.Router",
      "short-name-max" -> 5,
      "medium-name-max" -> 8
    )
  )
    .bindings(inject.bind[DbManager].toInstance(new InMemoryDbManager))
    .bindings(inject.bind[ArrivalRepository].toInstance(new InMemoryArrivalRepository))
    .build()
}

"ArrivalService#getArrivals" should {
  "use the InMemoryArrivalRepository without a connection to a database" in {
    val arrivalsF = app.injector.instanceOf[ArrivalService].getArrivals()
    whenReady(arrivalsF)(arrivals => {
      assert(arrivals.length === 2)
    })
  }
}

4. Mocking

When we need to override the behavior of a few components within a module or submodule, mocking can become quite useful. Ultimately, we can define entire modules with mocks and use them instead of the actual ones.

This concept is very similar to the one we discussed in the previous section, so we’ll write a very similar test suite, but instead of new implementations, we’ll use Mockito mocks.

First, it’s time to write some mocks, so let’s start with the DbManager mock:

private val mockDbManager: DbManager = {
  val mocked = mock[DbManager]
  when(mocked.execute(ArgumentMatchers.any[DBIO[Seq[Arrival]]]))
    .thenReturn(Future.successful(Seq(Arrival(33L, "Athens", "Gatwick", "DC10"))))
  mocked
}

We also need to declare the ArrivalRepository mock:

private val mockArrivalRepository: ArrivalRepository = {
  val mocked = mock[ArrivalRepository]
  when(mocked.getArrivals)
    .thenReturn(SuccessAction(Seq.empty[Arrival]))
  mocked
}

At last, let’s write the test case using the mocks we just created:

override def fakeApplication(): Application = {
  GuiceApplicationBuilder(
    modules = Seq(new ServiceModule)
  )
    .loadConfig(env => Configuration.load(env, Map("config.resource" -> "application.test.conf")))
    .configure("play.http.router" -> "play.api.routing.Router")
    .bindings(inject.bind[DbManager].toInstance(mockDbManager))
    .bindings(inject.bind[ArrivalRepository].toInstance(mockArrivalRepository))
    .build()
  }

"ArrivalService#getArrivals" should {
  "use the mocked DbManager and ArrivalRepository without a connection to a database" in {
    val arrivalsF = app.injector.instanceOf[ArrivalService].getArrivals()
    whenReady(arrivalsF)(arrivals => {
      assert(arrivals.length === 1)
    })
  }
}

5. Controllers and Actions

Since an Action handles most HTTP requests, we split this section into two sub-sections. The first section will cover controller tests, where we call the actual server endpoint with an HTTP client. The second section will cover the Action tests, where we test a Play Action function behavior.

5.1. Controller Tests

Given that we’ll send HTTP requests to our controller, we need a server to serve those requests. For this reason, we need GuiceOneServerPerTest or GuiceOneServerPerSuite traits mixed in our test suite:

class ArrivalControllerH2Test extends AnyWordSpec with WsScalaTestClient with GuiceOneServerPerTest with ScalaFutures with H2ApplicationFactory {

  private implicit def wsClient = app.injector.instanceOf[WSClient]

  "ArrivalController#index" should {
    "return arrivals using h2" in {
      val controllerResponseF: Future[WSResponse] = wsCall(com.baeldung.arrival.controller.routes.ArrivalController.index()).get()
      whenReady(controllerResponseF)(controllerResponse => {
        val arrivals = controllerResponse.json.as[JsArray].value
        assert(arrivals.length === 6)
      })
    }
  }

  override implicit def patienceConfig: PatienceConfig = PatienceConfig(timeout = scaled(Span(5, Seconds)), interval = scaled(Span(200, Millis)))
}

Of course, we could use Postgres and not H2 by mixing in the PostgresApplicationFactory instead of the H2ApplicationFactory.

5.2. Action Tests

To showcase Action tests, let’s implement a very simple Action, namely the SourceAction:

trait SourceActions {

  def SourceActionFilter(implicit ec: ExecutionContext): ActionFilter[Request] = new ActionFilter[Request] {
    override protected def filter[A](request: Request[A]): Future[Option[Result]] = {
      Future.successful {
        request.headers.get("source") match {
          case Some(_) => None
          case None => Some(Results.BadRequest("Source header is absent"))
        }
      }
    }

    override protected def executionContext: ExecutionContext = ec
  }

  def SourceAction(anyContentParser: BodyParser[AnyContent])(implicit ec: ExecutionContext): ActionBuilder[Request, AnyContent] = 
    new ActionBuilderImpl[AnyContent](anyContentParser) andThen SourceActionFilter

}

The SourceAction returns BAD_REQUEST if a request lacks the HTTP header “source“.

Let’s now write test cases for the SourceAction:

class SourceActionsTest extends AnyWordSpec with SourceActions with ScalaFutures {
  private def anyContentParser = Helpers.stubControllerComponents().parsers.anyContent
  private def globalEc = scala.concurrent.ExecutionContext.Implicits.global

  "SourceAction" should {
    "return BAD_REQUEST status for missing source header" in {
      val testee = SourceAction(anyContentParser)(globalEc) { _ => NoContent }

      whenReady(testee.apply(FakeRequest())) { result =>
        assert(result.header.status === BAD_REQUEST)
      }
    }

    "return NO_CONTENT status for when source header is present" in {
      val testee = SourceAction(anyContentParser)(globalEc) { _ => NoContent }
      whenReady(testee.apply(FakeRequest().withHeaders(Headers("source" -> "foo")))) { result =>
        assert(result.header.status === NO_CONTENT)
      }
    }
  }
}

6. Testing With Databases

Even though unit tests shouldn’t need a database, integration tests often use one. In this section, we’ll try to provide some useful examples for test cases that use a database.

6.1. Initial Setup

Before we begin to write tests that use a database, we need to create a database. Therefore, we decided not to use test containers and a local database, the most usual scenario for a developer’s day-to-day workflow. The following docker-compose file contains our database definition:

version: '3.8'
services:
  db:
    image: postgres:14.1-alpine
    restart: always
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    ports:
      - '5432:5432'
    volumes:
      - db:/var/lib/postgresql/data
      - ./postgres/init-database.sql:/docker-entrypoint-initdb.d/init-database.sql
volumes:
  db:
    driver: local

In this article, we also decided to use Slick, a very popular ORM for Scala.

Usually, projects use the H2 in-memory database, so the tests are faster. Despite that H2 supports many features, there are cases where we can’t use H2, such as native queries or database plugins. With this in mind, we included two separate configuration files for our databases, one for H2 and one for Postgres.

This is the application.h2.conf configuration file:

include "base.conf"
include "h2.conf"

And this is the application.postgres.conf configuration file:

include "base.conf"
include "postgres.conf"

6.2. Database Test Suites

Notably, we’ve defined our components and the ORM to run with any SQL database we want without any changes rather than the application configuration. However, to assemble our application with a different configuration file for a test, we call the GuiceApplicationBuilder with the configuration file as an additional argument. For this reason, we made two traits that provide the needed configuration for each database.

Our Postgres-backed test cases use the PostgresApplicationFactory:

trait PostgresApplicationFactory {
  self: FakeApplicationFactory =>

  override def fakeApplication(): Application = {
    GuiceApplicationBuilder(
      loadConfiguration = env => Configuration.load(env, Map("config.resource" -> "application.postgres.conf"))
    ).build()
  }
}

The H2ApplicationFactory is used to initialize our H2 test suites:

trait H2ApplicationFactory {
  self: FakeApplicationFactory =>

  override def fakeApplication(): Application = {
    GuiceApplicationBuilder(
      loadConfiguration = env => Configuration.load(env, Map("config.resource" -> "application.h2.conf"))
    ).build()
  }
}

The traits above allow us to choose which database a test suite uses simply by mixing in the desired trait.

Now that our building blocks are ready, we can finally write some database tests. Let’s start with the ArrivalServiceH2Test test:

class ArrivalServiceH2Test extends AnyWordSpec with GuiceOneAppPerTest with ScalaFutures with H2ApplicationFactory {

  "ArrivalService" should {
    "fetch data from H2" in {
      val arrivalsF = app.injector.instanceOf[ArrivalService].getArrivals()
      whenReady(arrivalsF)(arrivals => {
        assert(arrivals.length === 6)
      })
    }
  }

}

The ArrivalServicePostgresTest demonstrates that if we mix in the PostgresApplicationFactory trait, the above test will use Postgres instead of H2:

class ArrivalServicePostgresTest extends AnyWordSpec with GuiceOneAppPerTest with ScalaFutures with PostgresApplicationFactory {

  "ArrivalService" should {
    "fetch data from Postgres" in {
      val arrivalsF = app.injector.instanceOf[ArrivalService].getArrivals()
      whenReady(arrivalsF)(arrivals => {
        assert(arrivals.length === 4)
      })
    }
  }
}

7. Configuration

We can introduce or override application configuration during tests if that suits us. With this intention, we included in our application the ArrivalDecoratorService, which marks an arrival based on the plane name size:

trait Size {
  def short: Boolean
  def medium: Boolean
  def long: Boolean
}

class ArrivalDecoratorService @Inject()(configuration: Configuration) {

  private val maximumShortNameLength = configuration.get[Int]("short-name-max")
  private val maximumMediumNameLength = configuration.get[Int]("medium-name-max")

  def decorate(undecorated: Arrival): Arrival with Size = new Arrival(undecorated.planeId, undecorated.origin, undecorated.destination, undecorated.plane) with Size {
    override def short: Boolean = undecorated.plane.length <= maximumShortNameLength
    override def medium: Boolean = undecorated.plane.length > maximumShortNameLength && undecorated.plane.length <= maximumMediumNameLength
    override def long: Boolean = undecorated.plane.length > maximumMediumNameLength
  }
}

Let’s have a look at the application.test.conf file:

short-name-max = 5
medium-name-max = 8

Finally, it’s time to test the ArrivalDecoratorService with different configuration values. The first case uses the configuration file application.test.conf without any overrides:

"mark as short an arrival with plane name length = 5" in new App(
  GuiceApplicationBuilder()
    .configure("play.http.router" -> "play.api.routing.Router")
    .loadConfig(env => Configuration.load(env, Map("config.resource" -> "application.test.conf")))
    .build()
  ) {
  private val testee = app.injector.instanceOf[ArrivalDecoratorService]
  private val arrival = Arrival(1L, "Athens", "Heathrow", "12345")
  assert(testee.decorate(arrival).short)
  assert(!testee.decorate(arrival).medium)
  assert(!testee.decorate(arrival).long)
}

The second case uses the same configuration file but with overrides so the ArrivalDecoratorService returns a medium decorated Arrival instead of a short one:

"mark as medium an arrival with plane name length = 5 with overridden configuration" in new App(
  GuiceApplicationBuilder()
    .configure("play.http.router" -> "play.api.routing.Router")
    .loadConfig(env => Configuration.load(env, Map("config.resource" -> "application.test.conf")))
    .configure(
      "short-name-max" -> "3",
      "medium-name-max" -> "6"
    )
    .build()
  ) {
  private val testee = app.injector.instanceOf[ArrivalDecoratorService]
  private val arrival = Arrival(1L, "Athens", "Heathrow", "12345")
  assert(!testee.decorate(arrival).short)
  assert(testee.decorate(arrival).medium)
  assert(!testee.decorate(arrival).long)
}

To clarify, we used MixedPlaySpec, a convenience class for Play test suites in this test suite, which enabled us to bootstrap a completely different application for each test case.

8. Conclusion

Testing an application is rarely easy, and often application tests can become quite complex and slow. In most projects, the usual approach is to start up everything for the test suites, and then the test suites become slow and require frequent maintenance. In this article, we used some known best practices to demonstrate ways to write lean and fast application tests.

As always, the code of the above examples is available over on GitHub.

Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.