Testing Play Framework Applications with Specs2
Play Framework has first-class support for Specs2. The framework provides helpers for testing controllers, routes, templates, and WebSocket connections without starting a real server. This guide covers the full Play + Specs2 testing stack.
Setup
Add to build.sbt:
libraryDependencies ++= Seq(
"org.scalatestplus.play" %% "scalatestplus-play" % "7.0.0" % Test,
"org.specs2" %% "specs2-core" % "5.x.x" % Test
)Play's test helpers work with both ScalaTest and Specs2 out of the box.
Unit Testing Controllers
Test controllers without HTTP overhead:
import org.specs2.mutable.Specification
import play.api.test._
import play.api.test.Helpers._
import play.api.mvc._
class UsersControllerSpec extends Specification with Injecting {
"UsersController" should {
"return 200 for GET /users" in new WithApplication {
val controller = inject[UsersController]
val request = FakeRequest(GET, "/users")
val result = controller.index()(request)
status(result) must_== OK
contentType(result) must beSome("text/html")
contentAsString(result) must contain("Users")
}
"return 404 for unknown user" in new WithApplication {
val controller = inject[UsersController]
val request = FakeRequest(GET, "/users/999999")
val result = controller.show(999999)(request)
status(result) must_== NOT_FOUND
}
"create user with valid JSON" in new WithApplication {
val controller = inject[UsersController]
val body = Json.obj("email" -> "new@example.com", "name" -> "New User")
val request = FakeRequest(POST, "/users")
.withJsonBody(body)
.withCSRFToken
val result = controller.create()(request)
status(result) must_== CREATED
val json = contentAsJson(result)
(json \ "email").as[String] must_== "new@example.com"
}
}
}FakeRequest Builder
// GET with headers
FakeRequest(GET, "/api/users")
.withHeaders("Authorization" -> s"Bearer $token")
.withHeaders("Accept" -> "application/json")
// POST with JSON body
FakeRequest(POST, "/api/users")
.withJsonBody(Json.obj("email" -> "test@example.com"))
.withHeaders("Content-Type" -> "application/json")
// POST with form data
FakeRequest(POST, "/users")
.withFormUrlEncodedBody("email" -> "test@example.com", "name" -> "Test")
.withCSRFToken
// With session
FakeRequest(GET, "/dashboard")
.withSession("userId" -> "123")Route Testing
Test that routes resolve to the correct controller actions:
import play.api.test._
import play.api.test.Helpers._
class RoutesSpec extends Specification {
"Routes" should {
"route GET /users to UsersController.index" in new WithApplication {
val Some(result) = route(app, FakeRequest(GET, "/users"))
status(result) must_== OK
}
"route POST /users to UsersController.create" in new WithApplication {
val Some(result) = route(app,
FakeRequest(POST, "/users")
.withJsonBody(Json.obj("email" -> "x@y.com", "name" -> "X"))
)
status(result) must beOneOf(CREATED, UNPROCESSABLE_ENTITY)
}
"return 404 for unknown routes" in new WithApplication {
val result = route(app, FakeRequest(GET, "/nonexistent"))
result must beNone
}
}
}Functional Tests with WithServer
For full integration tests that require a real HTTP server:
import play.api.test._
import play.api.libs.ws._
class UserAPIFunctionalSpec extends Specification {
"User API" should {
"create and retrieve a user" in new WithServer {
val ws = app.injector.instanceOf[WSClient]
val baseUrl = s"http://localhost:$port"
// Create user
val createResponse = await(
ws.url(s"$baseUrl/api/users")
.post(Json.obj("email" -> "test@example.com", "name" -> "Test"))
)
createResponse.status must_== 201
val userId = (createResponse.json \ "id").as[Long]
// Retrieve user
val getResponse = await(ws.url(s"$baseUrl/api/users/$userId").get())
getResponse.status must_== 200
(getResponse.json \ "email").as[String] must_== "test@example.com"
}
"return 401 for unauthenticated requests" in new WithServer {
val ws = app.injector.instanceOf[WSClient]
val response = await(
ws.url(s"http://localhost:$port/api/admin/users").get()
)
response.status must_== 401
}
}
}Mocking WS Calls
Test code that makes outbound HTTP calls:
import play.api.libs.ws._
import org.mockito.Mockito._
class GitHubServiceSpec extends Specification with Mockito {
"GitHubService" should {
"fetch user repos" in new WithApplication {
val mockWS = mock[WSClient]
val mockRequest = mock[WSRequest]
val mockResponse = mock[WSResponse]
when(mockWS.url(anyString)) thenReturn mockRequest
when(mockRequest.get()) thenReturn Future.successful(mockResponse)
when(mockResponse.status) thenReturn 200
when(mockResponse.json) thenReturn Json.arr(
Json.obj("name" -> "rails", "stars" -> 50000)
)
val service = new GitHubService(mockWS)
val repos = await(service.fetchRepos("dhh"))
repos must have size 1
repos.head.name must_== "rails"
}
}
}Or use Play's StandaloneAhcWSClient with a test server:
class ExternalAPISpec extends Specification {
"ExternalAPIClient" should {
"handle rate limiting" in {
Server.withRouterFromComponents() { components => {
case GET(p"/api/data") => components.defaultActionBuilder {
TooManyRequests("Rate limit exceeded")
}
}} { implicit port =>
WsTestClient.withClient { client =>
val apiClient = new ExternalAPIClient(client, s"http://localhost:$port")
await(apiClient.fetchData()) must beLeft(contain("Rate limit"))
}
}
}
}
}Database Testing with Slick
import slick.jdbc.PostgresProfile.api._
import play.api.test._
class UserRepositorySpec extends Specification {
"UserRepository" should {
"create and find users" in new WithApplication {
val db = app.injector.instanceOf[Database]
val repo = new UserRepository(db)
val user = await(repo.create(UserData(
email = "test@example.com",
name = "Test User"
)))
user.id must beGreaterThan(0L)
val found = await(repo.findById(user.id))
found must beSome
found.get.email must_== "test@example.com"
}
"return None for missing user" in new WithApplication {
val repo = app.injector.instanceOf[UserRepository]
await(repo.findById(-1L)) must beNone
}
}
}Test Fixtures and Helpers
WithLoggedInUser
Create a reusable trait for authenticated tests:
trait WithLoggedInUser extends WithApplication {
lazy val user: User = {
val repo = app.injector.instanceOf[UserRepository]
await(repo.create(UserData(email = "test@example.com", name = "Test")))
}
def authenticatedRequest(method: String, path: String): FakeRequest[AnyContentAsEmpty.type] =
FakeRequest(method, path)
.withSession("userId" -> user.id.toString)
}
class DashboardControllerSpec extends Specification {
"DashboardController" should {
"show dashboard to logged-in user" in new WithLoggedInUser {
val controller = app.injector.instanceOf[DashboardController]
val result = controller.index()(authenticatedRequest(GET, "/dashboard"))
status(result) must_== OK
}
}
}Overriding Bindings in Tests
Swap out real dependencies with test doubles:
class MockPaymentGateway extends PaymentGateway {
var charged: List[(BigDecimal, String)] = Nil
def charge(amount: BigDecimal, currency: String): Future[Either[Error, Transaction]] = {
charged = (amount, currency) :: charged
Future.successful(Right(Transaction(s"test_txn_${charged.size}")))
}
}
class PaymentSpec extends Specification {
val mockGateway = new MockPaymentGateway
val app = new GuiceApplicationBuilder()
.overrides(bind[PaymentGateway].toInstance(mockGateway))
.build()
"OrderService" should {
"charge correct amount" in new WithApplication(app) {
val service = app.injector.instanceOf[OrderService]
await(service.processOrder(Order(total = BigDecimal("99.99"))))
mockGateway.charged must_== List((BigDecimal("99.99"), "USD"))
}
}
}Configuration Overrides for Testing
// Custom application for tests
val testApp = new GuiceApplicationBuilder()
.configure(
"db.default.driver" -> "org.h2.Driver",
"db.default.url" -> "jdbc:h2:mem:test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
"stripe.apiKey" -> "sk_test_fake",
"play.evolutions.db.default.autoApply" -> true
)
.build()Or use application.test.conf:
# conf/application.test.conf
include "application.conf"
db.default {
driver = "org.h2.Driver"
url = "jdbc:h2:mem:test;MODE=PostgreSQL"
}
# Disable real external APIs
github.apiEnabled = falseval app = new GuiceApplicationBuilder()
.loadConfig(Environment.simple())
.configure("config.resource" -> "application.test.conf")
.build()WebSocket Testing
import akka.actor._
import play.api.test._
class ChatControllerSpec extends Specification {
"ChatController" should {
"accept WebSocket connections" in new WithApplication {
val controller = app.injector.instanceOf[ChatController]
val result = controller.chat()(FakeRequest())
// WebSocket should upgrade
result.header.status must_== SWITCHING_PROTOCOLS
}
}
}For more complete WebSocket testing, use Akka TestKit:
import akka.testkit._
import akka.actor._
class ChatActorSpec extends TestKit(ActorSystem("test"))
with Specification with ImplicitSender {
"ChatActor" should {
"broadcast messages to subscribers" in {
val actor = system.actorOf(ChatActor.props)
actor ! Subscribe("room1", testActor)
actor ! Publish("room1", "Hello everyone!")
expectMsg("Hello everyone!")
}
}
override def afterAll(): Unit = TestKit.shutdownActorSystem(system)
}Specs2 Matchers for Play
// Status matchers
status(result) must_== OK
status(result) must_== CREATED
status(result) must_== NOT_FOUND
status(result) must beOneOf(OK, NOT_FOUND)
// Content matchers
contentType(result) must beSome("application/json")
contentAsString(result) must contain("expected text")
contentAsJson(result) must_== Json.obj("status" -> "ok")
// Header matchers
header("Location", result) must beSome("/users/1")
header("X-Request-Id", result) must beSome
// Session matchers
session(result).get("userId") must beSome("123")
flash(result).get("success") must beSome("User created")Running Play Tests
# All tests
sbt test
# Specific spec
sbt "testOnly *UsersControllerSpec*"
# With specific configuration
sbt -Dconfig.resource=application.test.conf test
# Continuous testing
sbt ~testSummary
Play Framework + Specs2 testing covers the full application:
FakeRequestfor controller unit tests without starting HTTProute()helper for testing routingWithServerfor full integration tests over real HTTP- WS client mocking for outbound API calls
GuiceApplicationBuilderoverrides for swapping dependencies- First-class async support throughout
The combination gives you fast unit tests for business logic and controller behavior, plus the option of real integration tests when you need confidence at the HTTP boundary.