Testing in Vapor 4

Use a pre-built Vapor application to learn both how to test your server-side Swift Vapor apps on macOS and also best practices to simplify your test code. By Tim Condon.

Leave a rating/review
Download materials
Save for later
Share

Testing is an important part of the software development process. Writing unit tests and automating them as much as possible allows you to develop and evolve your applications quickly.

In this server-side Swift tutorial, you’ll learn how to write tests for your Vapor applications. You’ll learn why testing is important, how it works with Swift Package Manager (SwiftPM), and how to write tests for your application.

More information on testing and using the XCTVapor module in Vapor 4 may be found in the Vapor documentation.

Why Should You Write Tests?

Software testing is as old as software development itself. Modern server applications are deployed many times a day, so it’s important that you’re sure everything works as expected. Writing tests for your application gives you confidence the code is sound.

Testing also gives you confidence when you refactor your code. Testing every part of your application manually is slow and laborious, even when your application is small! To develop new features quickly, you want to ensure the existing features don’t break. Having an expansive set of tests allows you to verify everything still works as you change your code.

Testing can also help you design your code. Test-driven development is a popular development process in which you write tests before writing code. This helps ensure you have full test coverage of your code. Test-driven development also helps you design your code and APIs.

Note: This tutorial assumes you have some experience with using Vapor to build web apps. See Getting Started with Server-side Swift with Vapor 4 if you’re new to Vapor. This tutorial also assumes you have some experience working with the command line, Fluent, and Docker.

For information on using Fluent in Vapor, see Using Fluent and Persisting Models in Vapor.

If you’re new to Docker, check out Docker on macOS: Getting Started.

Getting Started

Download the starter project for this tutorial using the Download Materials button at the top or bottom of this tutorial.

The starter project contains a pre-built Vapor app named TIL (Today I Learned) that hosts user-supplied acronyms.

On iOS, Xcode links tests to a specific test target. Xcode configures a scheme to use that target and you run your tests from within Xcode. The Objective-C runtime scans your XCTestCases and picks out the methods whose names begin with test. On Linux, and with SwiftPM, there’s no Objective-C runtime. There’s also no Xcode project to remember schemes and which tests belong where.

In Xcode, open Package.swift. There’s a test target defined in the targets array:

.testTarget(name: "AppTests", dependencies: [
  .target(name: "App"),
  .product(name: "XCTVapor", package: "vapor"),
])

This defines a testTarget type with a dependency on App and Vapor’s XCTVapor. Tests must live in the Tests/<target directory> directory. In this case, that’s Tests/AppTests.

Xcode creates the TILApp scheme and adds AppTests as a test target to that scheme. You can run these tests as normal with Command-U, or Product ▸ Test:

TIL App Scheme

Testing Users

Writing your First Test

Create a new file in Tests/AppTests called UserTests.swift. This file will contain all the user-related tests. Open the new file and insert the following:

@testable import App
import XCTVapor

final class UserTests: XCTestCase {
}

This creates the XCTestCase you’ll use to test your users and imports the necessary modules to make everything work.

Next, add the following inside UserTests to test getting the users from the API:

func testUsersCanBeRetrievedFromAPI() throws {
  // 1
  let expectedName = "Alice"
  let expectedUsername = "alice"

  // 2
  let app = Application(.testing)
  // 3
  defer { app.shutdown() }
  // 4
  try configure(app)

  // 5
  let user = User(
    name: expectedName,
    username: expectedUsername)
  try user.save(on: app.db).wait()
  try User(name: "Luke", username: "lukes")
    .save(on: app.db)
    .wait()

  // 6
  try app.test(.GET, "/api/users", afterResponse: { response in
    // 7
    XCTAssertEqual(response.status, .ok)

    // 8
    let users = try response.content.decode([User].self)
    
    // 9
    XCTAssertEqual(users.count, 2)
    XCTAssertEqual(users[0].name, expectedName)
    XCTAssertEqual(users[0].username, expectedUsername)
    XCTAssertEqual(users[0].id, user.id)
  })
}

There’s a lot going on in this test; here’s the breakdown:

  1. Define some expected values for the test: a user’s name and username.
  2. Create an Application, similar to main.swift. This creates an entire Application object but doesn’t start running the application. Note, you’re using the .testing environment here.
  3. Shutdown the application at the end of the test. This ensures that you close database connections correctly and clean up event loops.
  4. Configure your application for testing. This helps ensure you configure your real application correctly as your test calls the same configure(_:).
  5. Create a couple of users and save them in the database.
  6. Create a Responder type; this is what responds to your requests.
  7. Use XCTVapor — Vapor’s testing module — to send a GET request to /api/users. With XCTVapor you specify a path and HTTP method. XCTVapor also allows you to provide closures to run before you send the request and after you receive the response.
  8. Ensure the response received contains the expected status code.
  9. Decode the response data into an array of Users.
  10. Ensure there are the correct number of users in the response and the users match those created at the start of the test.

Support Testing in Your Configuration

Next, you must update your app’s configuration to support testing. Open configure.swift and before app.databases.use add the following:

let databaseName: String
let databasePort: Int
// 1
if (app.environment == .testing) {
  databaseName = "vapor-test"
  databasePort = 5433
} else {
  databaseName = "vapor_database"
  databasePort = 5432
}

This sets properties for the database name and port depending on the environment. You’ll use different names and ports for testing and running the application. Next, replace the call to app.databases.use with the following:

app.databases.use(.postgres(
  hostname: Environment.get("DATABASE_HOST")
    ?? "localhost",
  port: databasePort,
  username: Environment.get("DATABASE_USERNAME")
    ?? "vapor_username",
  password: Environment.get("DATABASE_PASSWORD")
    ?? "vapor_password",
  database: Environment.get("DATABASE_NAME")
    ?? databaseName
), as: .psql)

This sets the database port and name from the properties set above if you don’t provide environment variables. These changes allow you to run your tests on a database other than your production database. This ensures you start each test in a known state and don’t destroy live data.

The VaporTIL app was developed using Docker to host the app database. Setting up another database on the same machine for testing is straightforward. In Terminal, type the following:

docker run --name postgres-test -e POSTGRES_DB=vapor-test \
  -e POSTGRES_USER=vapor_username -e POSTGRES_PASSWORD=vapor_password \
  -p 5433:5432 -d postgres

This changes the container name and database name. The Docker container is also mapped to host port 5433 to avoid conflicting with the existing database.

Run the tests and they should pass. However, if you run the tests again, they’ll fail. The first test run added two users to the database and the second test run now has four users since the database wasn’t reset.

User test passed first time only.

preformed 2 test

Open UserTests.swift and add the following after try configure(app):

try app.autoRevert().wait()
try app.autoMigrate().wait()

This adds commands to revert any migrations in the database and then run the migrations again. This provides you with a clean database for every test.

Build and run the tests again and this time they’ll pass!

Now, test will pass each time they are run because you have added commands to revert any migrations.

Executed two successful test