Home iOS & Swift Books Server-Side Swift with Vapor

11
Testing Written by Tim Condon

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 chapter, you’ll learn how to write tests for your Vapor applications. You’ll learn why testing is important and how it works with Swift Package Manager. Next, you’ll learn how to write tests for the TIL application from the previous chapters. Finally, you’ll see why testing matters on Linux and how to test your code on Linux using Docker.

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. Over the last several chapters, you’ve evolved and changed the TIL application. Testing every part of the application manually is slow and laborious, and this 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.

Writing tests with SwiftPM

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/ 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:

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, using the application’s database object.
  6. 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.
  7. Ensure the response received contains the expected status code.
  8. Decode the response body into an array of Users.
  9. Ensure there are the correct number of users in the response and the first user matches the one created at the start of the test.

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. Since you’re using Docker to host your database, setting up another database on the same machine is simple. 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 is similar to the command you used in Chapter 6, “Configuring a Database”, but it 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.

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!

Test extensions

The first test contains a lot of code that all tests need. Extract the common parts to make the tests easier to read and to simplify future tests. In Tests/AppTests create a new file for one of these extensions, called Application+Testable.swift. Open the new file and add the following:

import XCTVapor
import App

extension Application {
  static func testable() throws -> Application {
    let app = Application(.testing)
    try configure(app)
    
    try app.autoRevert().wait()
    try app.autoMigrate().wait()

    return app
  }
}

This function allows you to create a testable Application object, configure it and set up the database. Next, create a new file in Tests/AppTests called Models+Testable.swift. Open the new file and create an extension to create a User:

@testable import App
import Fluent

extension User {
  static func create(
    name: String = "Luke",
    username: String = "lukes",
    on database: Database
  ) throws -> User {
    let user = User(name: name, username: username)
    try user.save(on: database).wait()
    return user
  }
}

This function saves a user, created with the supplied details, in the database. It has default values so you don’t have to provide any if you don’t care about them.

With all this created, you can now rewrite your user test. Open UserTests.swift and delete testUsersCanBeRetrievedFromAPI().

Next, in UserTests create the common properties for all the tests:

let usersName = "Alice"
let usersUsername = "alicea"
let usersURI = "/api/users/"
var app: Application!

Next implement setUpWithError() to run the code that must execute before each test:

override func setUpWithError() throws {
  app = try Application.testable()
}

This creates an Application for the test, which also resets the database.

Next, implement tearDownWithError() to shut the application down:

override func tearDownWithError() throws {
  app.shutdown()
}

Finally, rewrite testUsersCanBeRetrievedFromAPI() to use all the new helper methods:

func testUsersCanBeRetrievedFromAPI() throws {
  let user = try User.create(
    name: usersName, 
    username: usersUsername, 
    on: app.db)
  _ = try User.create(on: app.db)

  try app.test(.GET, usersURI, afterResponse: { response in
    XCTAssertEqual(response.status, .ok)
    let users = try response.content.decode([User].self)
    
    XCTAssertEqual(users.count, 2)
    XCTAssertEqual(users[0].name, usersName)
    XCTAssertEqual(users[0].username, usersUsername)
    XCTAssertEqual(users[0].id, user.id)
  })
}

This test does exactly the same as before but is far more readable. It also makes the next tests much easier to write. Run the tests again to ensure they still work.

Testing the User API

Open UserTests.swift and using the test helper methods add the following to test saving a user via the API:

func testUserCanBeSavedWithAPI() throws {
  // 1
  let user = User(name: usersName, username: usersUsername)
  
  // 2
  try app.test(.POST, usersURI, beforeRequest: { req in
    // 3
    try req.content.encode(user)
  }, afterResponse: { response in
    // 4
    let receivedUser = try response.content.decode(User.self)
    // 5
    XCTAssertEqual(receivedUser.name, usersName)
    XCTAssertEqual(receivedUser.username, usersUsername)
    XCTAssertNotNil(receivedUser.id)
    
    // 6
    try app.test(.GET, usersURI, 
      afterResponse: { secondResponse in
        // 7
        let users = 
          try secondResponse.content.decode([User].self)
        XCTAssertEqual(users.count, 1)
        XCTAssertEqual(users[0].name, usersName)
        XCTAssertEqual(users[0].username, usersUsername)
        XCTAssertEqual(users[0].id, receivedUser.id)
      })
  })
}

Here’s what the test does:

  1. Create a User object with known values.
  2. Use test(_:_:beforeRequest:afterResponse:) to send a POST request to the API
  3. Encode the request with the created user before you send the request.
  4. Decode the response body into a User object.
  5. Assert the response from the API matches the expected values.
  6. Make another request to get all the users from the API.
  7. Ensure the response only contains the user you created in the first request.

Run the tests to ensure that the new test works!

Next, add the following test to retrieve a single user from the API:

func testGettingASingleUserFromTheAPI() throws {
  // 1
  let user = try User.create(
    name: usersName, 
    username: usersUsername, 
    on: app.db)
  
  // 2
  try app.test(.GET, "\(usersURI)\(user.id!)", 
    afterResponse: { response in
      let receivedUser = try response.content.decode(User.self)
      // 3
      XCTAssertEqual(receivedUser.name, usersName)
      XCTAssertEqual(receivedUser.username, usersUsername)
      XCTAssertEqual(receivedUser.id, user.id)
    })
}

Here’s what the test does:

  1. Save a user in the database with known values.
  2. Get the user at /api/users/<USER ID>.
  3. Assert the values are the same as provided when creating the user.

The final part of the user’s API to test retrieves a user’s acronyms. Open Models+Testable.swift and, at the end of the file, create a new extension to create acronyms:

extension Acronym {
  static func create(
    short: String = "TIL",
    long: String = "Today I Learned",
    user: User? = nil,
    on database: Database
  ) throws -> Acronym {
    var acronymsUser = user

    if acronymsUser == nil {
      acronymsUser = try User.create(on: database)
    }

    let acronym = Acronym(
      short: short,
      long: long,
      userID: acronymsUser!.id!)
    try acronym.save(on: database).wait()
    return acronym
  }
}

This creates an acronym and saves it in the database with the provided values. If you don’t provide any values, it uses defaults. If you don’t provide a user for the acronym, it creates a user to use first.

Next, open UserTests.swift and create a method to test getting a user’s acronyms:

func testGettingAUsersAcronymsFromTheAPI() throws {
  // 1
  let user = try User.create(on: app.db)
  // 2
  let acronymShort = "OMG"
  let acronymLong = "Oh My God"
  
  // 3
  let acronym1 = try Acronym.create(
    short: acronymShort, 
    long: acronymLong, 
    user: user, 
    on: app.db)
  _ = try Acronym.create(
    short: "LOL", 
    long: "Laugh Out Loud", 
    user: user, 
    on: app.db)

  // 4
  try app.test(.GET, "\(usersURI)\(user.id!)/acronyms", 
    afterResponse: { response in
      let acronyms = try response.content.decode([Acronym].self)
      // 5
      XCTAssertEqual(acronyms.count, 2)
      XCTAssertEqual(acronyms[0].id, acronym1.id)
      XCTAssertEqual(acronyms[0].short, acronymShort)
      XCTAssertEqual(acronyms[0].long, acronymLong)
    })
}

Here’s what the test does:

  1. Create a user for the acronyms.

  2. Define some expected values for an acronym.

  3. Create two acronyms in the database using the created user. Use the expected values for the first acronym.

  4. Get the user’s acronyms from the API by sending a request to /api/users/<USER ID>/acronyms.

  5. Assert the response returns the correct number of acronyms and the first one matches the expected values.

Run the tests to ensure the changes work!

Testing acronyms and categories

Open Models+Testable.swift and, at the bottom of the file, add a new extension to simplify creating categories:

extension App.Category {
  static func create(
    name: String = "Random",
    on database: Database
  ) throws -> App.Category {
    let category = Category(name: name)
    try category.save(on: database).wait()
    return category
  }
}

Like the other model helper functions, create(name:on:) takes the name as a parameter and creates a category in the database. The tests for the acronyms API and categories API are part of the starter project for this chapter. Open CategoryTests.swift and uncomment all the code. The tests follow the same pattern as the user tests.

Open AcronymTests.swift and uncomment all the code. These tests also follow a similar pattern to before but there are some extra tests for the extra routes in the acronyms API. These include updating an acronym, deleting an acronym and the different Fluent query routes.

Run all the tests to make sure they all work. You should have a sea of green tests with every route tested!

Testing on Linux

Earlier in the chapter you learned why testing your application is important. For server-side Swift, testing on Linux is especially important. When you deploy your application to Heroku, for instance, you’re deploying to an operating system different from the one you used for development. It’s vital that you test your application on the same environment that you deploy it on.

Why is this so? Foundation on Linux isn’t the same as Foundation on macOS. Foundation on macOS still uses the Objective-C framework, which has been thoroughly tested over the years. Linux uses the pure-Swift Foundation framework, which isn’t as battle-tested. The implementation status list, github.com/apple/swift-corelibs-foundation/blob/master/Docs/Status.md, shows that many features remain unimplemented on Linux. If you use these features, your application may crash. While the situation improves constantly, you must still ensure everything works as expected on Linux.

Running tests in Linux

Running tests on Linux requires you to do things differently from running them on macOS. As mentioned earlier, the Objective-C runtime determines the test methods your XCTestCases provide. On Linux there’s no runtime to do this, so you must point Swift in the right direction. Swift 5.1 introduced test discovery, which parses your test classes to find tests to run.

When you call swift test on Linux, you must pass the --enable-test-discovery flag.

Early feedback is always valuable in software development and running tests on Linux is no exception. Using a Continuous Integration system to automatically test on Linux is vital, but what happens if you want to test on Linux on your Mac?

Well, you’re already running Linux for the PostgreSQL database using Docker! So, you can also use Docker to run your tests in a Linux environment. In the project directory, create a new file called testing.Dockerfile.

Open the file in a text editor and add the following:

# 1
FROM swift:5.2

# 2
WORKDIR /package
# 3
COPY . ./
# 4
CMD ["swift", "test", "--enable-test-discovery"]

Here’s what the Dockerfile does:

  1. Use the Swift 5.2 image.
  2. Set the working directory to /package.
  3. Copy the contents of the current directory into /package in the container.
  4. Set the default command to swift test --enable-test-discovery. This is the command Docker executes when you run the Dockerfile.

The tests need a PostgreSQL database in order to run. By default, Docker containers can’t see each other. However, Docker has a tool, Docker Compose, designed to link together different containers for testing and running applications. Vapor already provides a compose file for running your applications, but you’ll use a different one for testing. Create a new file called docker-compose-testing.yml in the project directory.

Open the file in an editor and add the following:

# 1
version: '3'
# 2
services:
  # 3
  til-app:
    # 4
    depends_on:
      - postgres
    # 5
    build:
      context: .
      dockerfile: testing.Dockerfile
    # 6
    environment:
      - DATABASE_HOST=postgres
      - DATABASE_PORT=5432
  # 7
  postgres:
    # 8
    image: "postgres"
    # 9
    environment:
      - POSTGRES_DB=vapor-test
      - POSTGRES_USER=vapor_username
      - POSTGRES_PASSWORD=vapor_password

Here’s what this does:

  1. Specify the Docker Compose version.

  2. Define the services for this application.

  3. Define a service for the TIL application.

  4. Set a dependency on the Postgres container, so Docker Compose starts the Postgres container first.

  5. Build testing.Dockerfile in the current directory — the Dockerfile you created earlier.

  6. Inject the DATABASE_HOST environment variable. Docker Compose has an internal DNS resolver. This allows the til-app container to connect to the postgres container with the hostname postgres. Also set the port for the database.

  7. Define a service for the Postgres container.

  8. Use the standard Postgres image.

  9. Set the same environment variables as used at the start of the chapter for the test database.

Finally open configure.swift in Xcode and allow the database port to be set as an environment variable for testing. Replace:

if (app.environment == .testing) {
  databaseName = "vapor-test"
  databasePort = 5433
} else {

with the following:

if (app.environment == .testing) {
  databaseName = "vapor-test"
  if let testPort = Environment.get("DATABASE_PORT") {
    databasePort = Int(testPort) ?? 5433
  } else {
    databasePort = 5433
  }
} else {

This uses the DATABASE_PORT environment variable if set, otherwise defaults the port to 5433. This allows you to use the port set in docker-compose-testing.yml. To test your application in Linux, open Terminal and type the following:

# 1
docker-compose -f docker-compose-testing.yml build
# 2
docker-compose -f docker-compose-testing.yml up \
  --abort-on-container-exit

Here’s what this does:

  1. Build the different docker containers using the compose file created earlier.
  2. Spin up the different containers from the compose file created earlier and run the tests. --abort-on-container-exit tells Docker Compose to stop the postgres container when the til-app container stops. The postgres container used for this test is different from, and doesn’t conflict with, the one you’ve been using during development.

When the tests finish running, you’ll see the output in Terminal with all tests passing:

Where to go from here?

In this chapter, you learned how to test your Vapor applications to ensure they work correctly. Writing tests for your application also means you can run these tests on Linux. This gives you confidence your application will work when you deploy it. Having a good test suite allows you to evolve and adapt your applications quickly.

Vapor’s architecture has a heavy reliance on protocols. This, combined with Vapor’s use of Swift extensions and switchable services, makes testing simple and scalable. For large applications, you may even want to introduce a data abstraction layer so you aren’t testing with a real database.

This means you don’t have to connect to a database to test your main logic and will speed up the tests.

It’s important you run your tests regularly. Using a continuous integration (CI) system such as Jenkins or GitHub Actions allows you to test every commit.

You must also keep your tests up to date. In future chapters where the behavior changes, such as when authentication is introduced, you’ll change the tests to work with these new features.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC