How to do integration testing on a Vapor server
As I’m getting further into my Vapor side project, I’m learning how writing Swift iOS/macOS apps is different from writing Swift server apps. One of the ways they are different is integration tests. For iOS apps, it’s usually done via automated UI testing. For Vapor, it’s API testing, or sometimes I see it called controller testing. Regardless of its name, I find myself writing more integration tests than unit tests for my server. I think there are two reasons for that:
- APIs tend to be well defined and easy to interact with, at least compared to UIs
- The integration tests give me a lot more confidence than unit tests that my server is behaving that way I intended it to
Of course, the downside is integration tests are much slower to run. So far, that’s a trade off I’ve been willing to make.
In any case, I want to talk about how I set up integration testing for my Vapor 3 app. My goal was to write test cases where calling the API under test was simple, and looked as much like real client code as possible. To accomplish that, I wrote some helper types called TestApplication
and TestResponse
to hide boilerplate code. I also created some fixtures to easily seed the database with data, along with some utility methods to reset the database after each test. Finally, I had to figure out how use Docker to stand up a PostgreSQL database I could run the tests against.
Example testcase
I’m going to start with a test case showing how I wrote my integration tests, then work backwards explaining the infrastructure I set up to support that.
As an example, I’m going to test an API that allows an authenticated user to invalidate all auth tokens that have been issued for their account, thereby forcing all devices to re-authenticate. Here’s the test:
class UserControllerTests: XCTestCase {
...
func testResetTokens_isTrue_success() throws {
let request = UserController.UserEnvelope(user: UserController.UpdateRequest(resetTokens: true))
let response = try app.put("/api/users/\(user.id!)", headers: .withAuthorization(app.apiKey, jwt), body: request)
XCTAssertEqual(response.status, .ok)
let responseBody = try response.content(decodeTo: UserController.UserEnvelope<UserController.UserResponse>.self).user
XCTAssertEqual(responseBody.id, user.id!)
XCTAssertEqual(responseBody.email, user.email)
let showResponse = try app.get("/api/users/\(user.id!)", headers: .withAuthorization(app.apiKey, jwt))
XCTAssertEqual(showResponse.status, .unauthorized)
}
...
}
Right now I’m using XCTestCase
because it doesn’t rely on any external dependencies. I’ve been entertaining the idea of using Quick & Nimble so I can share more setup between tests, use custom matchers, and make the assertions a touch more readable. But for now, I just name my test functions with a specific pattern: API name, plus parameter values, plus expected results. I also toss in the app state if it’s relevant for the test.
The first line of the test is creating the request that I want to validate. The app specific request types are currently defined in the controller code, which is why there’s namespacing. The next line makes the request via TestApplication
and then waits for a response. The request is performing a PUT on /api/users/:user_id
with the request parameters encoded in the body. The .withAuthorization(app.apiKey, jwt)
is a fixture on HTTPHeaders
that creates headers with a valid authorization for this user.
Once the API performs the token reset, it returns the user object. The test validates this by decoding the response body and asserting the id and email match what’s expected. Then, to fully verify that all tokens were reset, the test makes another request (this time a GET request) with the existing token. It asserts that the request is rejected with an unauthorized status.
At this point there are a lot of undefined things that make this test function. I will eventually get to them all. But for now, I’ll start with the setup code that creates the user and JWT that are used by this test. Here’s what I got for that:
import Foundation
import XCTest
import Vapor
import FluentPostgreSQL
import JWT
@testable import App
class UserControllerTests: XCTestCase {
var app: TestApplication!
var connection: PostgreSQLConnection!
var user: User!
var jwt: String!
override func setUp() {
super.setUp()
try! TestApplication.reset()
app = try! TestApplication()
connection = app.connection
user = User.user(on: connection)
let payload = AuthJWTPayload(uid: UserIDClaim(value: user.id!),
iat: IssuedAtClaim(value: Date()))
jwt = try! JWTSerialization().encode(payload, on: app.container)
}
override func tearDown() {
super.tearDown()
_ = user.delete(on: connection)
connection.close()
}
...
}
Since I’m using XCTest, I’m using the standard setUp()
and tearDown()
methods. In the setup I start by resetting the app via TestApplication.reset()
; this resets the database. Then I create a TestApplication
instance, which is the test’s interface into the app. I’ll need to add data to the database as well verify data was created, so I go ahead and create a database connection. Then I immediately use it create a user using the User.user(on:)
fixture. The final bit of setup is creating a valid JWT for the user that can be used for making API calls. I wrote about using JWTs in Vapor API servers earlier.
My tearDown()
is simple. I delete the user I created in the set up, and then close the database connection. This is a bit redundant in that I reset the app on each test, but I like to do it because it feels like good hygiene.
Now I have a complete test case, but there’s still a lot of infrastructure to build. The example test above used a couple of fixtures, so I’ll talk about those next.
Fixtures
My goal with fixtures is to provide a quick and easy way to create model data to test with. My fixtures tend to change a lot from app to app because they’re tied to the model types, and those change from app to app. That said, I do have a couple of patterns that I follow for fixtures. First, I prefer to make the fixtures static methods on the data type they’re creating instances of. In my experience, this scales a bit better than having one fixtures namespace or type that all fixtures are created from. Second, while I have the fixture methods take the model’s properties as parameters, I do prefer to default those parameters to sane values. I have found that reduces test maintenance when I need to add a property to a model type.
To provide a concrete example, here’s the user fixture used in example test above:
import Foundation
import Vapor
@testable import App
extension User {
static func user(email: String = "bob.jimbob@example.com", on connection: DatabaseConnectable) -> User {
let user = try! User(email: email)
return try! user.create(on: connection).wait()
}
}
There’s not much here, but I find even simple fixtures like this save time and boilerplate. The fixture creates a User
instance, then saves it to the database. The most notable thing here is that the fixture waits on the database save to complete before returning. It does this by calling wait()
on the promise.
The other thing that’s probably worth commenting on is my liberal use of force unwraps in the tests. Normally, I would not suffer a force unwrap to live (with a couple of exceptions). But I view tests as controlled environments that real users will never have to experience, and if something crashes it was probably a programmer error anyway.
TestApplication
Everything that I’ve written about so far has been pretty specific to my particular app. But there’s some testing infrastructure that I built that should be fairly portable. The next piece I want to talk about is the TestApplication
.
The TestApplication
represents the system under test. Its main function is to receive requests and send back responses. There’s some boilerplate code needed to make that ergonomic for tests. I chose to make TestApplication
its own type (as opposed to an extension
on Application
) for a few reasons. First, I feel a bespoke type makes it clear which methods and properties are useful for tests, as opposed to production code. Second, having a separate type allows other testing properties and methods to have a convenient place to live.
With all that said, here’s the init
and deinit
of my TestApplication
type:
import Foundation
import Vapor
import FluentPostgreSQL
@testable import App
class TestApplication {
private let application: Application
private let configuration: TestingConfiguration
lazy var connection: PostgreSQLConnection = {
return try! application.newConnection(to: .psql).wait()
}()
var container: Container {
return application
}
init(arguments: [String] = CommandLine.arguments) throws {
var env = Environment.testing
env.arguments = arguments
self.application = try App.app(env)
self.configuration = try application.make(TestingConfiguration.self)
}
deinit {
try! application.releaseCachedConnections()
try! application.syncShutdownGracefully()
}
}
The TestApplication
type mainly wraps Vapor’s Application
type. However, it also contains the TestingConfiguration
, which is helpful for tests wanting to grab the API key, or fake services, or any other piece of configuration data. The TestApplication
also exposes a database connection, lazily created, and a dependency injection container. These objects are needed to do anything interesting in a Vapor app.
The init
creates a testing environment and sets the command line arguments on it. The command line argument functionality will be used later, when resetting the database between tests. But for now, init
ensures the application will be stood up using the TestingConfiguration
. After the application is created, a TestingConfiguration
instance is pulled out and stored in a property.
I didn’t write a deinit
method to start with. After I had written severals tests I started getting test failures because all the Postgres connections were being used, and after that no test could acquire a new one. So the deinit
manually clears any cached connections (Vapor seems to keep a pool around), then waits synchronously until the Vapor app fully shuts down.
Making test requests
I’ve created my TestApplication
now, and while it does some helpful things, I haven’t covered the main thing it does: send requests and receive responses. There were couple of pieces to implementing this. First, I needed to put together a method that can do basic send and receive. Then I added several methods to make it ergonomic to call from tests.
To start, I built the basic workhorse method:
class TestApplication {
...
private func request<T: Content>(_ path: String, method: HTTPMethod, headers: HTTPHeaders, body: T?) throws -> TestResponse {
let responder = try application.make(Responder.self)
let httpRequest = HTTPRequest(method: method, url: URL(string: path)!, headers: headers)
let request = Request(http: httpRequest, using: application)
if let body = body {
try request.content.encode(body)
}
return try TestResponse(response: responder.respond(to: request).wait())
}
...
}
The first thing I’ll point out here is the method signature. It takes a path to the API to be called, the HTTP method to use, headers, and an optional body. The body can be any type as long as it conforms to Vapor’s Content
protocol. The method can throw, and returns a TestResponse
if it is successful. These parameters cover all the options that my tests care about.
The first thing request()
does is create an object that conforms to the Vapor Responder
protocol. This is the object that allows TestApplication
to make requests and receive responses. Then request()
creates the raw HTTPRequest
using the parameters passed in, then the Request
object based on that. The using
parameter wants a dependency injection container, so I give it the application
. If there is a non-nil
body provided in the parameters, it is encoded into the request. Now that the request is fully created, it is given to the Responder
which returns a promise to the response. Since this is for tests, I wait()
on the response promise to be fulfilled before wrapping it in a TestResponse
.
Although this method removes a lot of the boilerplate needed to make a request, I felt like it could be a bit cleaner. I figured that the HTTP method could be the method name instead of a parameter. So I added some methods that do that:
class TestApplication {
...
func get<T: Content>(_ path: String, headers: HTTPHeaders = HTTPHeaders(), body: T?) throws -> TestResponse {
return try request(path, method: .GET, headers: headers, body: body)
}
func put<T: Content>(_ path: String, headers: HTTPHeaders = HTTPHeaders(), body: T?) throws -> TestResponse {
return try request(path, method: .PUT, headers: headers, body: body)
}
func post<T: Content>(_ path: String, headers: HTTPHeaders = HTTPHeaders(), body: T?) throws -> TestResponse {
return try request(path, method: .POST, headers: headers, body: body)
}
func delete<T: Content>(_ path: String, headers: HTTPHeaders = HTTPHeaders(), body: T?) throws -> TestResponse {
return try request(path, method: .DELETE, headers: headers, body: body)
}
...
}
These methods also default the headers
to an empty set of headers, in the cases where tests don’t care about them. These methods are pretty ergonomic, but there’s one more case I needed to handle.
Some of my APIs don’t send a body in the request. The workhorse request()
method can handle that; the caller just passes in nil
in that case. However, because body
is a generic type, the compiler needs a type — any type — to compile the call. nil
by itself won’t give the compiler enough information to infer the type. I needed an instance of an optional type set to nil
to satisfy the type inference. I also wanted to avoid forcing the test from having to pass that instance in. If a body
parameter wasn’t provided, the TestApplication
methods should assume there isn’t one.
Fortunately this can be done easily, albeit with a bit more boilerplate:
class TestApplication {
...
struct Empty: Content {
static let instance: Empty? = nil
}
func get(_ path: String, headers: HTTPHeaders = HTTPHeaders()) throws -> TestResponse {
return try get(path, headers: headers, body: Empty.instance)
}
func delete(_ path: String, headers: HTTPHeaders = HTTPHeaders()) throws -> TestResponse {
return try delete(path, headers: headers, body: Empty.instance)
}
...
}
I defined a placeholder type called Empty
and conformed it to Vapor’s Content
protocol. The protocol conformance was to satisfy request()
‘s constraints on the body
parameter. Then I declared an optional static instance of the type, and set it to nil
. I could then use this static instance to represent an empty request body.
Now that I had the Empty.instance
instance created, I used it to remove the body
parameter. Here, I’ve only provided versions of the get
and delete
methods without a body, but the same technique works for put
and post
.
At this point I could now make requests like I showed in my example test. But how about validating the response I got back?
TestResponse
The main goal of TestResponse
was to make it easy for tests to validate the response. I chose to wrap Vapor’s Response
type instead of providing an extension
on it for the same reason TestApplication
wraps Application
. The main things my tests want to do with a response is: verify the HTTP status, verify the body, and maybe verify a header or two.
import Foundation
import Vapor
struct TestResponse {
private let response: Response
init(response: Response) {
self.response = response
}
var status: HTTPResponseStatus { return response.http.status }
func content<T: Decodable>(decodeTo type: T.Type) throws -> T {
return try response.content.decode(type).wait()
}
func header(_ name: HTTPHeaderName) -> String? {
return response.http.headers.firstValue(name: name)
}
}
TestResponse
is simple. It exposes the HTTP status in status
, provides a synchronous way to decode the body via the content()
method, and looks up a given header with the header()
method. For more complicated tests it might need to expose more information, but this works for all my tests.
Resetting the database
So far, I’ve been mainly concerned with sending requests, receiving responses, and validating them. But for tests to work, I needed to be able to reset the database between test runs. Otherwise tests would interfere with one another.
First, I needed to set up some Fluent commands in my configuration. These added command line arguments that can reset the database for me.
public func configure(_ config: inout Config, _ env: inout Environment, _ services: inout Services, _ configuration: ConfigurationType) throws {
...
var commandConfig = CommandConfig.default()
commandConfig.useFluentCommands()
services.register(commandConfig)
...
}
There’s not much to this. Toward the end of my configure()
method, I created a default Vapor CommandConfig
, told it to use Fluent commands, then registered it as a service.
Now that the Fluent commands were set up, I needed to add a couple of methods to TestApplication
to have it reset the database.
class TestApplication {
...
func run() throws {
try application.asyncRun().wait()
}
static func reset() throws {
try TestApplication(arguments: ["vapor", "revert", "--all", "-y"]).run()
try TestApplication(arguments: ["vapor", "migrate", "-y"]).run()
}
...
}
The run()
method looks a bit odd by itself. It synchronously runs the application. Which turned out to be handy, if it has some commands to run from the command line.
The reset()
static method provided those command line arguments. First, it creates an instance of the app that reverts the database, and runs it. Then, it it creates an instance of the app that runs all migrations.
That testing database
I’ve blissfully ignored that all this needs a real test database to talk to. I could have set up a local Postgres instance on my laptop (which I’ve done before), but that doesn’t scale well if I have multiple apps. So I’ve been using Docker to handle standing up a test database.
I already had Homebrew installed, so I used it to install Docker:
brew cask install docker
Then I started a Postgres instance running in Docker for the test suite to talk to with this command:
docker run --name myapp-postgres-test -e POSTGRES_DB=myapp-test -e POSTGRES_USER=myapp -e POSTGRES_PASSWORD=password -p 5433:5432 -d postgres
This names the instance myapp-postgres-test
so I can refer to it later. The -e
flag sets environment variables, and POSTGRES_DB
, POSTGRES_USER
, POSTGRES_PASSWORD
values all match the values I set in my TestingConfiguration
. The -p
flag tells Docker to publish Postgres’s default port (5432) on port 5433, which is where my TestingConfiguration
was pointing. The -d
flag tells Docker to run the container in the background, and postgres
argument is the Docker image to use.
At this point, I can run all tests in Xcode (menu: Product > Test) or run vapor test
from the command line.
Sometimes during development I changed the database schema in way that my migrations didn’t catch. In these cases, I was lazy, and I just reset the Docker image:
docker stop myapp-postgres-test
docker rm myapp-postgres-test
This first stops the container, then removes it, leaving me free to re-execute the docker run
command above to stand up a fresh Postgres database.
As a final testing infrastructure tip, I’ll start by observing that Swift on Linux doesn’t currently have reflection capabilities. That means test cases have to be manually given to the testing framework as a parameter. I find this is tedious and error prone, especially given how forgetful I am. Therefore, I let the Swift Package Manager autogenerate the necessary information for me:
swift test --generate-linuxmain
Unfortunately the generated code is static, so I have re-run the command anytime I add or remove a test case.
Also, I’m terrible at remembering all these Docker and Swift Package Manager commands, so I added them to my app’s README.md file.
Conclusion
With API servers, I find writing integration tests more helpful than unit tests. However, in my experience there’s some boilerplate code needed to make creating integration tests in Vapor comfortable. My approach is create a TestApplication
class to model the system under test, and make to it ergonomic to send requests and synchronously receive responses. Likewise, I use a TestResponse
type to make validating responses easier. I also leverage fixtures for seeding the test database, and a utility function for resetting the database between test runs. Finally, I make use of Docker to make it convenient to stand up and reset the Postgres testing database.