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 Request → Vapor Server → Middleware → Route → Controller → Database → Response → HTTP 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.