Testing Against a Real Server in Vapor Tests

Throughout this series, we've built a clean iOS architecture, a Vapor backend, wired them together, and deployed to AWS. But we're shipping code without tests. That's like building a bridge and skipping the load test.

Vapor has solid testing support out of the box with XCTVapor, which lets you make simulated requests against your app without starting a real HTTP server. That's great for unit tests. But for integration tests, I want more confidence. I want to boot a real server, make real HTTP requests, and verify that the full stack works - routing, middleware, database queries, JSON serialization, error handling, everything.

You can find the complete test suite on GitHub.

Unit Tests vs Integration Tests

XCTVapor provides a testable mode where requests are dispatched directly to your route handlers without going through the HTTP stack:

// XCTVapor simulated test
func testGetLandmarks() async throws {
    let app = try await Application.make(.testing)
    try await configure(app)

    try await app.test(.GET, "landmarks") { res async in
        XCTAssertEqual(res.status, .ok)
    }

    try await app.asyncShutdown()
}

This is fast and useful for testing route logic in isolation. But it skips HTTP parsing, content negotiation, middleware ordering, and real network serialization. If your middleware modifies headers or your JSON encoder has custom date formatting, simulated tests won't catch issues there.

Integration tests boot a real Application, bind to a port, and make actual HTTP requests. They're slower but test the full path:

HTTP RequestVapor ServerMiddlewareRouteControllerDatabaseResponseHTTP Response

Both test types are valuable. Use simulated tests for fast feedback on logic. Use integration tests for confidence that the stack works end-to-end. We're focusing on integration tests here.

Setting Up the Test Server

The test helper boots a real Vapor application with an in-memory SQLite database. Each test gets a clean environment:

import XCTest
import Vapor
import Fluent
import FluentSQLiteDriver
@testable import App

final class IntegrationTestCase: XCTestCase {
    var app: Application!
    var port: Int!
    var baseURL: URL { URL(string: "http://localhost:\(port!)")! }

    override func setUp() async throws {
        app = try await Application.make(.testing)

        // Use in-memory SQLite for test isolation
        app.databases.use(.sqlite(.memory), as: .sqlite)

        // Run migrations
        app.migrations.add(CreateLandmark())
        try await app.autoMigrate()

        // Bind to a random available port
        app.http.server.configuration.port = 0
        try await app.startup()

        // Capture the actual port assigned by the OS
        port = app.http.server.shared.localAddress?.port

        try await super.setUp()
    }

    override func tearDown() async throws {
        try await app.asyncShutdown()
        try await super.tearDown()
    }
}

A few things to notice:

In-memory SQLite means every test starts with an empty database. No leftover data from previous runs, no flaky ordering issues, no cleanup scripts. The database vanishes when the app shuts down.

Port 0 tells the OS to assign a random available port. This avoids port conflicts when running tests in parallel or when another process is using 8080. We capture the actual port after startup.

Migrations run every time. Since the database is in-memory, we need to create tables for each test run. This also verifies that your migrations work correctly.

A Real HTTP Client for Tests

We need an HTTP client that mirrors what the iOS app's NetworkClient does. The test client makes real HTTP requests and decodes responses:

struct TestClient {
    let baseURL: URL
    private let session = URLSession.shared
    private let decoder = JSONDecoder()
    private let encoder = JSONEncoder()

    func get<T: Decodable>(_ path: String, as type: T.Type) async throws -> (T, HTTPURLResponse) {
        let url = baseURL.appendingPathComponent(path)
        let (data, response) = try await session.data(from: url)
        let httpResponse = response as! HTTPURLResponse
        let decoded = try decoder.decode(T.self, from: data)
        return (decoded, httpResponse)
    }

    func post<T: Decodable, B: Encodable>(_ path: String, body: B, as type: T.Type) async throws -> (T, HTTPURLResponse) {
        let url = baseURL.appendingPathComponent(path)
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try encoder.encode(body)
        let (data, response) = try await session.data(for: request)
        let httpResponse = response as! HTTPURLResponse
        let decoded = try decoder.decode(T.self, from: data)
        return (decoded, httpResponse)
    }

    func put<T: Decodable, B: Encodable>(_ path: String, body: B, as type: T.Type) async throws -> (T, HTTPURLResponse) {
        let url = baseURL.appendingPathComponent(path)
        var request = URLRequest(url: url)
        request.httpMethod = "PUT"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try encoder.encode(body)
        let (data, response) = try await session.data(for: request)
        let httpResponse = response as! HTTPURLResponse
        let decoded = try decoder.decode(T.self, from: data)
        return (decoded, httpResponse)
    }

    func delete(_ path: String) async throws -> HTTPURLResponse {
        let url = baseURL.appendingPathComponent(path)
        var request = URLRequest(url: url)
        request.httpMethod = "DELETE"
        let (_, response) = try await session.data(for: request)
        return response as! HTTPURLResponse
    }

    func getRaw(_ path: String) async throws -> (Data, HTTPURLResponse) {
        let url = baseURL.appendingPathComponent(path)
        let (data, response) = try await session.data(from: url)
        return (data, response as! HTTPURLResponse)
    }
}

This mirrors the NetworkClient from Post 6. The iOS app uses NetworkClient.live with the same GET/POST/PUT/DELETE pattern. The consistency means if a test passes here, the iOS client will work too.

The getRaw method is useful for error cases where the response isn't the expected type and you want to inspect the raw body.

Add a convenience property to the base test class:

extension IntegrationTestCase {
    var client: TestClient {
        TestClient(baseURL: baseURL)
    }
}

Testing CRUD Operations

With the test infrastructure in place, let's test every endpoint. Start with the basics: create, read, update, delete.

Create

final class LandmarkCRUDTests: IntegrationTestCase {

    func testCreateLandmark() async throws {
        let input = CreateLandmarkRequest(
            name: "Golden Gate Bridge",
            location: "San Francisco, CA",
            description: "An iconic suspension bridge.",
            imageName: "goldengate",
            isFeatured: true,
            category: "bridges"
        )

        let (landmark, response) = try await client.post(
            "landmarks",
            body: input,
            as: LandmarkResponse.self
        )

        XCTAssertEqual(response.statusCode, 200)
        XCTAssertNotNil(landmark.id)
        XCTAssertEqual(landmark.name, "Golden Gate Bridge")
        XCTAssertEqual(landmark.location, "San Francisco, CA")
        XCTAssertEqual(landmark.isFeatured, true)
        XCTAssertEqual(landmark.category, "bridges")

        // Verify it's actually in the database
        let count = try await LandmarkModel.query(on: app.db).count()
        XCTAssertEqual(count, 1)
    }

The test creates a landmark via HTTP, verifies the response, and then checks the database directly to confirm persistence. This dual verification catches bugs where the response looks correct but the database wasn't actually updated.

Read

     func testGetAllLandmarks() async throws {
        try await seedLandmarks(count: 3)

        let (landmarks, response) = try await client.get(
            "landmarks",
            as: [LandmarkResponse].self
        )

        XCTAssertEqual(response.statusCode, 200)
        XCTAssertEqual(landmarks.count, 3)
    }

    func testGetSingleLandmark() async throws {
        let seeded = try await seedLandmarks(count: 1)
        let id = seeded[0].id!

        let (landmark, response) = try await client.get(
            "landmarks/\(id)",
            as: LandmarkResponse.self
        )

        XCTAssertEqual(response.statusCode, 200)
        XCTAssertEqual(landmark.id, id)
        XCTAssertEqual(landmark.name, seeded[0].name)
    }

Update

     func testUpdateLandmark() async throws {
        let seeded = try await seedLandmarks(count: 1)
        let id = seeded[0].id!

        let update = CreateLandmarkRequest(
            name: "Updated Name",
            location: "Updated Location",
            description: "Updated description.",
            imageName: "updated",
            isFeatured: false,
            category: "lakes"
        )

        let (landmark, response) = try await client.put(
            "landmarks/\(id)",
            body: update,
            as: LandmarkResponse.self
        )

        XCTAssertEqual(response.statusCode, 200)
        XCTAssertEqual(landmark.name, "Updated Name")
        XCTAssertEqual(landmark.category, "lakes")

        // Verify the database was updated
        let model = try await LandmarkModel.find(id, on: app.db)
        XCTAssertEqual(model?.name, "Updated Name")
    }

Delete

     func testDeleteLandmark() async throws {
        let seeded = try await seedLandmarks(count: 1)
        let id = seeded[0].id!

        let response = try await client.delete("landmarks/\(id)")

        XCTAssertEqual(response.statusCode, 204)

        // Verify it's gone from the database
        let count = try await LandmarkModel.query(on: app.db).count()
        XCTAssertEqual(count, 0)
    }
}

Each test follows the same pattern: seed data if needed, make an HTTP request, assert the response, verify the database state. The database assertions are optional but catch subtle bugs.

Testing Category and Favorites Endpoints

The filtering endpoints need seed data with specific attributes:

final class LandmarkFilterTests: IntegrationTestCase {

    func testGetFeaturedLandmarks() async throws {
        // Seed a mix of featured and non-featured landmarks
        try await seedLandmark(name: "Featured One", isFeatured: true, category: .mountains)
        try await seedLandmark(name: "Not Featured", isFeatured: false, category: .lakes)
        try await seedLandmark(name: "Featured Two", isFeatured: true, category: .bridges)

        let (landmarks, response) = try await client.get(
            "landmarks/featured",
            as: [LandmarkResponse].self
        )

        XCTAssertEqual(response.statusCode, 200)
        XCTAssertEqual(landmarks.count, 2)
        XCTAssert(landmarks.allSatisfy { $0.isFeatured })
    }

    func testGetLandmarksByCategory() async throws {
        try await seedLandmark(name: "Mountain One", isFeatured: false, category: .mountains)
        try await seedLandmark(name: "Lake One", isFeatured: false, category: .lakes)
        try await seedLandmark(name: "Mountain Two", isFeatured: true, category: .mountains)

        let (landmarks, response) = try await client.get(
            "landmarks/category/mountains",
            as: [LandmarkResponse].self
        )

        XCTAssertEqual(response.statusCode, 200)
        XCTAssertEqual(landmarks.count, 2)
        XCTAssert(landmarks.allSatisfy { $0.category == "mountains" })
    }

    func testGetLandmarksByCategoryReturnsEmptyForNoMatches() async throws {
        try await seedLandmark(name: "Mountain", isFeatured: false, category: .mountains)

        let (landmarks, response) = try await client.get(
            "landmarks/category/bridges",
            as: [LandmarkResponse].self
        )

        XCTAssertEqual(response.statusCode, 200)
        XCTAssertEqual(landmarks.count, 0)
    }
}

These tests verify that the Fluent query filters work correctly through the full HTTP stack. The allSatisfy assertions ensure no incorrect records leak through.

Testing Error Cases

Error tests verify that the server returns appropriate status codes and error messages. This is particularly important because the iOS app expects a specific error format (Post 5's ErrorResponse):

final class LandmarkErrorTests: IntegrationTestCase {

    func testGetNonexistentLandmark() async throws {
        let nonexistentId = UUID()

        let (data, response) = try await client.getRaw("landmarks/\(nonexistentId)")

        XCTAssertEqual(response.statusCode, 404)

        // Verify the error response structure matches what the iOS app expects
        let error = try JSONDecoder().decode(ErrorResponse.self, from: data)
        XCTAssertTrue(error.error)
        XCTAssertEqual(error.code, 404)
        XCTAssertFalse(error.reason.isEmpty)
    }

    func testCreateLandmarkWithInvalidCategory() async throws {
        let input = CreateLandmarkRequest(
            name: "Test",
            location: "Test",
            description: "Test",
            imageName: "test",
            isFeatured: false,
            category: "invalid_category"
        )

        let (data, response) = try await client.getRaw("landmarks")
        // POST with invalid data
        let url = baseURL.appendingPathComponent("landmarks")
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode(input)
        let (errorData, errorResponse) = try await URLSession.shared.data(for: request)

        XCTAssertEqual((errorResponse as! HTTPURLResponse).statusCode, 400)
    }

    func testCreateLandmarkWithEmptyName() async throws {
        let input = CreateLandmarkRequest(
            name: "",
            location: "Test",
            description: "Test",
            imageName: "test",
            isFeatured: false,
            category: "mountains"
        )

        let url = baseURL.appendingPathComponent("landmarks")
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode(input)
        let (_, response) = try await URLSession.shared.data(for: request)

        XCTAssertEqual((response as! HTTPURLResponse).statusCode, 400)
    }

    func testDeleteNonexistentLandmark() async throws {
        let nonexistentId = UUID()

        let response = try await client.delete("landmarks/\(nonexistentId)")

        XCTAssertEqual(response.statusCode, 404)
    }

    func testGetInvalidCategory() async throws {
        let (data, response) = try await client.getRaw("landmarks/category/nonexistent")

        XCTAssertEqual(response.statusCode, 400)
    }
}

The 404 test verifies the error response structure matches ErrorResponse from Post 5. This is important because the iOS app's network layer expects this format. If someone changes the error middleware and breaks the structure, this test catches it.

Test Isolation and Seeding

Each test needs a clean database with specific data. The seeding helpers make this ergonomic:

extension IntegrationTestCase {

    @discardableResult
    func seedLandmarks(count: Int) async throws -> [LandmarkModel] {
        var models: [LandmarkModel] = []
        let categories: [LandmarkCategory] = [.mountains, .lakes, .bridges]

        for i in 0..<count {
            let model = LandmarkModel(
                name: "Landmark \(i)",
                location: "Location \(i)",
                description: "Description for landmark \(i).",
                imageName: "image\(i)",
                isFeatured: i % 2 == 0,
                category: categories[i % categories.count]
            )
            try await model.save(on: app.db)
            models.append(model)
        }

        return models
    }

    @discardableResult
    func seedLandmark(
        name: String,
        isFeatured: Bool,
        category: LandmarkCategory
    ) async throws -> LandmarkModel {
        let model = LandmarkModel(
            name: name,
            location: "\(name) Location",
            description: "Description for \(name).",
            imageName: name.lowercased().replacingOccurrences(of: " ", with: ""),
            isFeatured: isFeatured,
            category: category
        )
        try await model.save(on: app.db)
        return model
    }
}

The @discardableResult attribute lets tests ignore the return value when they don't need references to the seeded models. When they do need them (like for getting an ID to pass to a GET request), the returned models are available.

Because each test gets a fresh in-memory database (from setUp), there's no cleanup needed. Tests can't interfere with each other. This eliminates an entire class of flaky test failures.

Running Tests in CI

Add a test job to the GitHub Actions pipeline from Post 7. Tests should run before deployment, so a failing test blocks the deploy:

name: Deploy Backend

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install Swift
        uses: swift-actions/setup-swift@v2
        with:
          swift-version: "6.0"

      - name: Run tests
        run: swift test --parallel

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production

    steps:
      # ... same deployment steps from Post 7
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-west-2

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build, tag, and push image
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/landmarks-api:$IMAGE_TAG .
          docker push $ECR_REGISTRY/landmarks-api:$IMAGE_TAG

      - name: Deploy to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: task-definition.json
          service: landmarks-api
          cluster: landmarks-cluster
          wait-for-service-stability: true

The needs: test dependency ensures the deploy job only runs if tests pass. The --parallel flag runs test classes concurrently, which is safe because each test class gets its own in-memory database.

Wrapping Up

Integration tests that boot a real server catch an entire class of bugs that unit tests miss. HTTP parsing, content negotiation, middleware ordering, JSON serialization - all verified with actual requests against your actual code.

The pattern is straightforward: in-memory SQLite for isolation, port 0 for parallel safety, seed data for each test, assert both the HTTP response and the database state. The test infrastructure is minimal but covers the full stack.

Running these tests in CI before deployment means a failing endpoint blocks the deploy. That's the kind of safety net that lets you push to production with confidence.

Here's where we are in the series:

Post 1: Navigation established type-safe navigation with a Screen enum and centralized DestinationContent.

Post 2: API vs Domain Models separated network concerns from business logic with .domainModel mapping at the boundary.

Post 3: Dependency Injection replaced protocols and view models with closure-based services and observable stores.

Post 4: Caching added memory and disk caching with LRU eviction and flexible fetch policies.

Post 5: Vapor Backend built the server with Fluent models, response types, and RESTful routes.

Post 6: Full Integration wired every layer together into a complete client-server app.

Post 7: Deployment containerized the server with Docker, deployed to AWS with ECS Fargate, and automated everything with GitHub Actions.

Post 8 (this one) added integration tests that boot a real server and verify every endpoint.

In the next post, we'll flip to the iOS side and test the Landmarks app itself. The closure-based dependency injection from Post 3 makes the app trivially testable - we'll add a Reservation feature and write tests that prove it works without hitting any server.