Testing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with fluent 4.13.0, fluent-sqlite-driver 4.9.0, fluent-postgres-driver 2.14.0, leaf 4.5.2 and queues 1.18.0, and the Swift Testing library that ships with the Swift 6.4 toolchain, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in one scratch package, and the whole suite shown here was run with swift test on macOS; the output quoted is captured from those runs. Two things were not run: the PostgreSQL suite (no server was available, so it compiled and was skipped, and the guard that protects other databases was exercised) and the GitHub Actions workflow. Vapor 5 is at 5.0.0-beta.4 and is not released yet; where its testing module differs, that is noted from its source. The examples extend the Bookshelf service of the earlier pages of this section. Swift Testing itself is covered by the Swift Reference’s Testing page, and async functions and Sendable by Async/Await and Tasks and Actors, Isolation and Sendable; this page is about what is specific to Vapor.

What Vapor gives you for tests

A Vapor application is an ordinary Swift value, an Application, that can be created, configured, started and shut down inside a test process. Most of what needs testing in a web service (routing, middleware, content decoding, authentication, validation, database access, jobs) works without a network, so Vapor ships helpers that send a request straight into the application and hand back the response.

The helpers are products of the vapor package itself, not separate repositories, so they always match the Vapor version you build against:

Product Built on Status in 4.122

VaporTesting

Swift Testing (import Testing)

current; added in 4.110.0 (2024-12-21); the one to use for new tests

XCTVapor

XCTest

legacy; still shipped and working, kept for existing XCTest suites

XCTQueues (package queues)

either framework

in-memory queue driver for tests, .asyncTest

VaporTesting re-exports Vapor, so import VaporTesting is enough in a test file. Both testing products share internal request and response types (TestingHTTPRequest and TestingHTTPResponse), and each has its own entry point: app.testing() in VaporTesting and app.testable() in XCTVapor. Using the wrong one does not fail loudly: assertions made through the other framework are not reported, which is the most common reason for a test that stays green when it should not (see Common errors).

Where tests enter the request

In the request lifecycle (SwiftNIO accepts the connection and parses HTTP, Vapor builds a Request, the global middleware run, the router matches a route, group middleware and the handler run, and the returned value is encoded into the Response), a test using the default in-memory method enters at the Vapor step: it builds the Request itself and calls the application’s responder chain, so middleware, routing, Content decoding, authenticators, the handler and response encoding all run exactly as in production. Only the socket and the HTTP parser are skipped. The other method, .running, keeps them.

Two ways app.testing().test reaches the routes: in memory, calling the responder chain directly, or running, through a real server and an HTTP client

Tests therefore sit in three layers, and each needs different tools:

Layer What it checks Tools

Plain unit tests

pure functions, DTO mapping, validation rules, services behind a protocol

Swift Testing alone, no Application

Route tests (the bulk)

status codes, headers, JSON bodies, authentication, validation errors, database effects, enqueued jobs

withApp, app.testing(), SQLite in memory

Wire-level and end-to-end tests

TLS, Date and Connection headers, WebSockets, a deployed container, a browser

.running, app.startup(), a real database; browser tests are a separate topic

The application under test

The examples use one small Bookshelf application so that every test listing can be compiled and run. It reuses the models of the earlier pages (Author, Book, User, Review and the CreateBookshelf migration, see Fluent Models and Migrations and Schema) and adds a hashed API-token table, a catalogue page rendered with Leaf, an echo WebSocket and one background job. Because tests need to choose the database, the password hasher and the queue driver, configure(_:) reads them from the environment instead of hard-coding them:

import Fluent
import FluentPostgresDriver
import FluentSQLiteDriver
import Leaf
import Queues
import Vapor

struct UnsafeTestDatabase: Error, CustomStringConvertible {
    var description: String { "refusing to run tests against a database whose name does not end in _test" }
}

func configure(_ app: Application) async throws {
    // The database comes from the environment; only .testing falls back to an in-memory SQLite database.
    if let url = Environment.get("DATABASE_URL") {
        if app.environment == .testing, !url.hasSuffix("_test") { throw UnsafeTestDatabase() }
        try app.databases.use(.postgres(url: url), as: .psql)
    } else if app.environment == .testing {
        app.databases.use(.sqlite(.memory), as: .sqlite)
    } else {
        app.databases.use(.sqlite(.file("bookshelf.sqlite")), as: .sqlite)
    }
    app.migrations.add(CreateBookshelf(), CreateApiToken())

    // bcrypt at cost 12 makes every login take ~250 ms; tests do not need that.
    app.passwords.use(app.environment == .testing ? .plaintext : .bcrypt)

    app.views.use(.leaf)
    app.queues.add(NotifyAuthorJob())
    try routes(app)
}

Four decisions in this function exist for the tests:

  • DATABASE_URL selects PostgreSQL; .testing with no URL selects SQLite in memory. The guard refuses a URL that does not end in _test, so a developer with a production DATABASE_URL exported in their shell cannot wipe it by running swift test.

  • .plaintext hashing in .testing. bcrypt at the default cost makes every login take a few hundred milliseconds, which multiplies across a suite; app.passwords.use(.plaintext) makes User.passwordHash hold the password as typed. It is only acceptable because the .testing environment never runs in production. If you prefer to keep real hashes, .bcrypt(cost: 4) is accepted by the same call and was verified to produce $2b$04$ hashes.

  • No queue driver is chosen here. The production driver (Redis, see Queues and Background Jobs) is installed by the entrypoint; the test helper installs the in-memory one before calling configure.

  • No autoMigrate() in configure. Migrations run from the entrypoint in production (as a separate migrate step, see Commands) and from the test helper in tests, so the test decides when the schema exists.

The routes, the authenticators, the token model and the job are listed once here so that the tests below are easy to follow.

import Fluent
import Queues
import Vapor

struct BookDTO: Content, Equatable {
    var id: UUID?
    var title: String
    var isbn: String
    var publishedYear: Int
}

struct CreateBook: Content, Validatable {
    var title: String
    var isbn: String
    var publishedYear: Int
    var authorID: UUID

    static func validations(_ validations: inout Validations) {
        validations.add("title", as: String.self, is: !.empty)
        validations.add("isbn", as: String.self, is: .count(10...13))
        validations.add("publishedYear", as: Int.self, is: .range(1450...2100))
    }
}

struct BookFilter: Content { var year: Int? }

struct NewReview: Content { var rating: Int; var body: String }
struct TokenResponse: Content { var token: String; var expiresAt: Date }

struct BasicLogin: AsyncBasicAuthenticator {
    func authenticate(basic: BasicAuthorization, for request: Request) async throws {
        guard let user = try await User.query(on: request.db).filter(\.$email == basic.username).first(),
              try await request.password.async.verify(basic.password, created: user.passwordHash).get()
        else { return }
        request.auth.login(user)
    }
}

struct TokenLogin: AsyncBearerAuthenticator {
    func authenticate(bearer: BearerAuthorization, for request: Request) async throws {
        let hash = SHA256.hash(data: Data(bearer.token.utf8)).map { String(format: "%02x", $0) }.joined()
        guard let token = try await ApiToken.query(on: request.db).filter(\.$tokenHash == hash)
                .with(\.$user).first(), token.expiresAt > Date()
        else { return }
        request.auth.login(token.user)
    }
}

func routes(_ app: Application) throws {
    app.get("health") { _ in "ok" }

    let books = app.grouped("books")
    books.get { req async throws -> [BookDTO] in
        let filter = try req.query.decode(BookFilter.self)
        let query = Book.query(on: req.db).filter(\.$status == .published).sort(\.$title)
        if let year = filter.year { query.filter(\.$publishedYear == year) }
        return try await query.all()
            .map { BookDTO(id: $0.id, title: $0.title, isbn: $0.isbn, publishedYear: $0.publishedYear) }
    }
    books.get(":bookID") { req async throws -> BookDTO in
        guard let book = try await Book.find(req.parameters.get("bookID"), on: req.db) else { throw Abort(.notFound) }
        return BookDTO(id: book.id, title: book.title, isbn: book.isbn, publishedYear: book.publishedYear)
    }

    // Everything below needs a bearer token.
    let secured = app.grouped(TokenLogin(), User.guardMiddleware())
    secured.post("books") { req async throws -> Response in
        try CreateBook.validate(content: req)
        let input = try req.content.decode(CreateBook.self)
        let book = Book(title: input.title, isbn: input.isbn, publishedYear: input.publishedYear,
                        priceCents: 0, status: .published, authorID: input.authorID)
        try await book.save(on: req.db)
        let dto = BookDTO(id: book.id, title: book.title, isbn: book.isbn, publishedYear: book.publishedYear)
        return try await dto.encodeResponse(status: .created, for: req)
    }
    secured.post("books", ":bookID", "reviews") { req async throws -> HTTPStatus in
        let user = try req.auth.require(User.self)
        let input = try req.content.decode(NewReview.self)
        guard let book = try await Book.find(req.parameters.get("bookID"), on: req.db) else { throw Abort(.notFound) }
        let review = Review(rating: input.rating, body: input.body, bookID: try book.requireID(), userID: try user.requireID())
        try await review.save(on: req.db)
        try await req.queue.dispatch(NotifyAuthorJob.self, NotifyAuthor(reviewID: try review.requireID()))
        return .created
    }
    secured.get("me") { req async throws -> String in try req.auth.require(User.self).email }

    // Basic auth in, bearer token out.
    app.grouped(BasicLogin(), User.guardMiddleware()).post("login") { req async throws -> TokenResponse in
        let user = try req.auth.require(User.self)
        let raw = [UInt8].random(count: 32).hex
        let expires = Date().addingTimeInterval(3600)
        let hash = SHA256.hash(data: Data(raw.utf8)).map { String(format: "%02x", $0) }.joined()
        try await ApiToken(tokenHash: hash, userID: try user.requireID(), expiresAt: expires).save(on: req.db)
        return TokenResponse(token: raw, expiresAt: expires)
    }

    app.get("catalogue") { req async throws -> View in
        struct Context: Encodable { let books: [BookDTO] }
        let items = try await Book.query(on: req.db).filter(\.$status == .published).sort(\.$title).all()
            .map { BookDTO(id: $0.id, title: $0.title, isbn: $0.isbn, publishedYear: $0.publishedYear) }
        return try await req.view.render("catalogue", Context(books: items))
    }

    app.webSocket("echo") { _, ws in
        ws.onText { ws, text in ws.send("echo: \(text)") }
    }
}
import Fluent
import Queues
import Vapor

struct NotifyAuthor: Codable, Sendable {
    let reviewID: UUID
}

/// Records which authors were notified; a real job would call an email or push service.
actor NotificationLog {
    private(set) var notified: [UUID] = []
    func record(_ reviewID: UUID) { notified.append(reviewID) }
}

struct NotificationLogKey: StorageKey { typealias Value = NotificationLog }

extension Application {
    var notificationLog: NotificationLog {
        if let existing = storage[NotificationLogKey.self] { return existing }
        let log = NotificationLog()
        storage[NotificationLogKey.self] = log
        return log
    }
}

struct NotifyAuthorJob: AsyncJob {
    typealias Payload = NotifyAuthor

    func dequeue(_ context: QueueContext, _ payload: NotifyAuthor) async throws {
        await context.application.notificationLog.record(payload.reviewID)
    }

    func error(_ context: QueueContext, _ error: any Error, _ payload: NotifyAuthor) async throws {
        context.logger.error("notify failed: \(error)")
    }
}

The token table stores only the SHA-256 hash of each bearer token, as described in Fluent Authentication and Tokens:

final class ApiToken: Model, @unchecked Sendable {
    static let schema = "api_tokens"
    @ID(key: .id) var id: UUID?
    @Field(key: "token_hash") var tokenHash: String
    @Parent(key: "user_id") var user: User
    @Field(key: "expires_at") var expiresAt: Date
    init() {}
    init(tokenHash: String, userID: User.IDValue, expiresAt: Date) {
        self.tokenHash = tokenHash; self.$user.id = userID; self.expiresAt = expiresAt
    }
}

struct CreateApiToken: AsyncMigration {
    func prepare(on db: any Database) async throws {
        try await db.schema("api_tokens").id().field("token_hash", .string, .required).unique(on: "token_hash")
            .field("user_id", .uuid, .required, .references("users", "id", onDelete: .cascade))
            .field("expires_at", .datetime, .required).create()
    }
    func revert(on db: any Database) async throws { try await db.schema("api_tokens").delete() }
}

extension User: Authenticatable {}

Setting up the test target

The test target depends on the application target and on VaporTesting. Add XCTVapor only while legacy XCTest files remain, and XCTQueues (a product of the queues package) if you test jobs. The same Swift settings as the application are used, so the tests compile in the Swift 6 language mode with the same upcoming features:

// swift-tools-version:6.3
import PackageDescription

let package = Package(
    name: "App",
    platforms: [.macOS(.v13)],
    dependencies: [
        .package(url: "https://github.com/vapor/vapor.git", from: "4.122.2"),
        .package(url: "https://github.com/vapor/fluent.git", from: "4.13.0"),
        .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.9.0"),
        .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.14.0"),
        .package(url: "https://github.com/vapor/leaf.git", from: "4.5.2"),
        .package(url: "https://github.com/vapor/queues.git", from: "1.18.0"),
    ],
    targets: [
        .executableTarget(name: "App", dependencies: [
            .product(name: "Vapor", package: "vapor"),
            .product(name: "Fluent", package: "fluent"),
            .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
            .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
            .product(name: "Leaf", package: "leaf"),
            .product(name: "Queues", package: "queues"),
        ], swiftSettings: swiftSettings),
        .testTarget(name: "AppTests", dependencies: [
            .target(name: "App"),
            .product(name: "VaporTesting", package: "vapor"),
            .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
            .product(name: "XCTQueues", package: "queues"),
        ], swiftSettings: swiftSettings),
    ]
)

var swiftSettings: [SwiftSetting] { [
    .enableUpcomingFeature("ExistentialAny"),
    .enableUpcomingFeature("InternalImportsByDefault"),
    .enableUpcomingFeature("MemberImportVisibility"),
    .enableUpcomingFeature("InferIsolatedConformances"),
    .enableUpcomingFeature("ImmutableWeakCaptures"),
] }

Three details come from the upcoming-feature flags. With MemberImportVisibility, a test file must import every module whose members it uses: app.queues needs import Queues, .sqlite(.memory) needs import FluentSQLiteDriver, app.db needs import Fluent; the compiler says "is not available due to missing import of defining module". With InternalImportsByDefault the same applies to what the application target re-exports. And @testable import App works on an executable target, which is how the template’s tests reach configure(_:). The test files live in Tests/AppTests, one file per area, and swift test discovers them without any registration (the LinuxMain.swift files and allTests arrays of older books are gone, see Version notes and outdated material).

A first test

import Testing
import VaporTesting
@testable import App

@Test("health check answers ok")
func health() async throws {
    try await withApp(configure: configure) { app in
        try await app.testing().test(.GET, "health", afterResponse: { res async in
            #expect(res.status == .ok)
            #expect(res.body.string == "ok")
        })
    }
}

withApp is the lifecycle helper that VaporTesting provides. In 4.122.2 it does this, in order:

  1. Application.make(.testing) creates the application in the testing environment;

  2. it awaits your configure closure (routes, databases, migrations, middleware);

  3. it runs the test body with the application;

  4. it awaits app.asyncShutdown(), and does the same, ignoring a second error, if anything threw.

An Application must be shut down. It owns an event loop group reference, database connection pools and a lock registry, and Application.deinit logs Application.shutdown() was not called before Application deinitialized. and shuts down synchronously if you forgot. withApp makes that impossible to forget, which is its whole purpose; it returns whatever the test body returns and is marked @discardableResult.

The testing environment (Environment.testing) has three consequences worth knowing. It is what app.environment == .testing checks in configure. It loads .env.testing (and then .env) from the working directory into the process environment, as described in Configuration and Environment, which is the place for a DATABASE_URL that only tests use. And it sanitizes the command-line arguments of the process, so the arguments swift test passes to the test bundle are not parsed as Vapor options.

afterResponse is called with a TestingHTTPResponse: status, headers, body (a ByteBuffer, with a .string convenience property) and the same content container that Request has, so res.content.decode(BookDTO.self) uses the app’s configured decoders. The closure may be async and may throw; #expect and #require work inside it because it runs in the test’s own task.

Sending requests

The full signature of the tester method is:

// Vapor 4.122.2, Sources/VaporTesting/TestingApplicationTester.swift, abridged
extension TestingApplicationTester {
    @discardableResult
    public func test(
        _ method: HTTPMethod,
        _ path: String,
        headers: HTTPHeaders = [:],
        body: ByteBuffer? = nil,
        beforeRequest: (inout TestingHTTPRequest) async throws -> () = { _ in },
        afterResponse: (TestingHTTPResponse) async throws -> () = { _ in }
    ) async throws -> TestingApplicationTester

    public func sendRequest(
        _ method: HTTPMethod,
        _ path: String,
        headers: HTTPHeaders = [:],
        body: ByteBuffer? = nil,
        beforeRequest: (inout TestingHTTPRequest) async throws -> () = { _ in }
    ) async throws -> TestingHTTPResponse
}

(The real methods also take the fileID, filePath, line and column of the call site, which is how a failure is reported at the line of your test(...) call.) The pieces:

  • headers and body are the simple form: headers: ["Authorization": "Bearer abc"], body: ByteBuffer(string: "...").

  • beforeRequest receives the request as inout TestingHTTPRequest, so you can set req.headers.basicAuthorization and req.headers.bearerAuthorization, and use the Content encoders: try req.content.encode(value) sets the body and the Content-Type (JSON by default), and try req.query.encode(value) appends a query string. The request’s method, url, headers and body are plain properties.

  • afterResponse is where the assertions go. It is optional, so a call that only needs a side effect omits it.

  • test returns the tester, so calls can be chained for a sequence of requests against the same application, though separate statements read better.

  • sendRequest returns the TestingHTTPResponse instead of passing it to a closure. Use it when a later request depends on an earlier response (log in, then call), when several assertions need the same decoded value, or inside helpers.

  • Errors: a thrown Abort is not an error here, because the default ErrorMiddleware turns it into a 4xx response that you assert on. test records a Swift Testing issue at the call site and rethrows when something outside that chain throws: afterResponse or beforeRequest itself, a failure to start the server in .running mode, or a responder chain from which you removed ErrorMiddleware.

Three helpers decode a response without writing the boilerplate. They are free functions of VaporTesting and replace their XCTAssert... counterparts:

VaporTesting XCTVapor What it does

expectContent(_:_:_:)

XCTAssertContent

decodes the body with the decoder for the response’s Content-Type and passes the value to a closure; records an issue if there is no content type or decoding fails

expectContains(_:_:)

XCTAssertContains

records an issue unless the first string contains the second

expectJSONEquals(_:_:)

XCTAssertEqualJSON

decodes a JSON string with JSONDecoder and compares it with an Equatable value

All of the above in one suite:

import Fluent
import Testing
import VaporTesting
@testable import App

@Suite("Books API", .serialized)
struct BookTests {
    @Test("health check answers ok")
    func health() async throws {
        try await withApp(configure: configure) { app in
            try await app.testing().test(.GET, "health", afterResponse: { res async in
                #expect(res.status == .ok)
                #expect(res.body.string == "ok")
            })
        }
    }

    @Test("lists only published books")
    func listsPublished() async throws {
        try await withBookshelf { app in
            _ = try await seed(app.db)
            try await app.testing().test(.GET, "books", afterResponse: { res async throws in
                #expect(res.status == .ok)
                #expect(res.headers.contentType == .json)
                let books = try res.content.decode([BookDTO].self)
                #expect(books.map(\.title) == ["The Dispossessed"])
            })
        }
    }

    @Test("query parameters can be written into the path or encoded from a Content type")
    func queryParameters() async throws {
        try await withBookshelf { app in
            _ = try await seed(app.db)
            try await app.testing().test(.GET, "books?year=1974", afterResponse: { res async throws in
                #expect(try res.content.decode([BookDTO].self).count == 1)
            })
            try await app.testing().test(.GET, "books", beforeRequest: { req in
                try req.query.encode(BookFilter(year: 1999))
            }, afterResponse: { res async throws in
                #expect(try res.content.decode([BookDTO].self).isEmpty)
            })
        }
    }

    @Test("an unknown id is a 404", arguments: [
        "00000000-0000-0000-0000-000000000000",
        "not-a-uuid",
    ])
    func unknownBook(id: String) async throws {
        try await withBookshelf { app in
            try await app.testing().test(.GET, "books/\(id)", afterResponse: { res async in
                #expect(res.status == .notFound)
            })
        }
    }

    @Test("expectContent decodes the body for you")
    func decoded() async throws {
        try await withBookshelf { app in
            let fixtures = try await seed(app.db)
            let id = try fixtures.book.requireID()
            try await app.testing().test(.GET, "books/\(id)", afterResponse: { res async throws in
                expectContent(BookDTO.self, res) { dto in
                    #expect(dto.isbn == "9780061054884")
                }
                expectContains(res.body.string, "Dispossessed")
            })
        }
    }

    @Test("sendRequest returns the response for several assertions or follow-up requests")
    func sendRequest() async throws {
        try await withBookshelf { app in
            _ = try await seed(app.db)
            let tester = try app.testing()
            let list = try await tester.sendRequest(.GET, "books")
            let first = try #require(try list.content.decode([BookDTO].self).first)
            let detail = try await tester.sendRequest(.GET, "books/\(try #require(first.id))")
            #expect(detail.status == .ok)
        }
    }
}

Notes on this listing:

  • .serialized on the suite runs its tests one after another. It is not needed here (each test has its own application and its own in-memory database, shown below); it is there because the docs' own advice is to serialize suites that touch a database, and it costs nothing at this size. The trait affects only the tests inside the suite, not other suites, which still run alongside it.

  • arguments: turns one test function into one test case per argument; the two ids in unknownBook appear in the output as one test "with 2 test cases", run concurrently. not-a-uuid shows that a malformed identifier is a 404 in this application, because Book.find is given nil.

  • The async throws on afterResponse is needed when the closure decodes or throws; async alone is enough for plain #expect calls, which is the form the official docs use.

  • A failure is reported with Swift Testing’s expansion of the expression. Changing the expected status to .created gave:

✘ Test failingStatus() recorded an issue at Experiments.swift:10:17: Expectation failed: res.status == .created
↳ res.status == .created → false
↳   res.status → 200 OK
↳   .created → 201 Created
✘ Test failingStatus() recorded an issue at Experiments.swift:11:17: Expectation failed: res.body.string == "OK"
↳ res.body.string == "OK" → false
↳   res.body.string → "ok"

Decoding the plain-text body of /health as BookDTO inside afterResponse showed what an error in the closure looks like (test records it at the call site and rethrows, so the test reports two issues):

✘ Test throwsInside() recorded an issue at Experiments.swift:17:41: Issue recorded
↳ DecodingError.typeMismatch: Expected value of type Dictionary<String, Decodable>. Debug description: Plaintext decoding does not support dictionaries.

In memory or running

app.testing() takes a method: argument, with two values:

public enum Method: Sendable {
    case inMemory
    public static var running: Method          // hostname "localhost", port 8080
    public static func running(port: Int) -> Self
    case running(hostname: String, port: Int)
}
.inMemory (default) .running

Path of a request

Request is built directly and given to app.responder

app.server.start(address:), then AsyncHTTPClient sends a real HTTP request to http://hostname:port

Skipped layers

socket, HTTP codecs, connection handling, server-added headers

none

Response body

collected from the Response.Body

read by the client, up to 1 MB

Server lifecycle

nothing started

started and stopped by every test(...) call

Port

none; safe to run many in parallel

a fixed port, so tests using the same port must not overlap; port: 0 asks the OS for a free one and works

Use for

nearly everything

what only a real connection shows: server configuration (compression, HTTP/2, limits), real headers, TLS, a client library under test

import Fluent
import Testing
import VaporTesting
@testable import App

@Suite("Real HTTP", .serialized)
struct RunningTests {
    @Test("the running tester goes through a real socket and HTTP client")
    func running() async throws {
        try await withBookshelf { app in
            _ = try await seed(app.db)
            // Each test(...) call starts the server on this port, sends the request with AsyncHTTPClient and stops it.
            try await app.testing(method: .running(port: 8092)).test(.GET, "books", afterResponse: { res async throws in
                #expect(res.status == .ok)
                #expect(try res.content.decode([BookDTO].self).count == 1)
                #expect(res.headers.first(name: .date) != nil)       // added by the real HTTP server
            })
        }
    }

    @Test("the in-memory tester skips the HTTP layer")
    func inMemory() async throws {
        try await withBookshelf { app in
            _ = try await seed(app.db)
            try await app.testing().test(.GET, "books", afterResponse: { res async in
                #expect(res.status == .ok)
                #expect(res.headers.first(name: .date) == nil)         // no HTTP server was involved
            })
        }
    }
}

The headers show the difference. The in-memory response had content-type and content-length only; the running response added connection: keep-alive and date, which the HTTP server adds. A request with a body larger than the server’s maxBodySize, a compressed response, or a Host check by a proxy-aware middleware are the sort of behaviour that needs .running.

The running tester starts and stops the server around each test(...) call. It therefore cannot keep a WebSocket open between calls, and it cannot be combined with state that lives in a server run (for that, start the application yourself, as in Testing WebSockets). Its default port is 8080, which collides with a development server and with parallel tests, so always pass a port. The 4.122 source marks defaulting to port 0 in the next major release as a TODO, and the Vapor 5 beta does just that (see Vapor 5).

Swift Testing for Vapor tests

Swift Testing has several features that are particularly useful with Vapor, and a few that behave differently from XCTest.

Isolation and parallelism

Swift Testing runs tests, and suites, concurrently by default; XCTest ran them one at a time unless --parallel was given. Each withApp call creates its own Application, so applications do not interfere with each other, which makes parallelism safe for in-memory tests. What can collide is anything shared:

  • a fixed TCP port (.running, WebSocket tests, app.http.server.configuration.port);

  • a shared database server (PostgreSQL or MySQL) when all tests use one database;

  • a file on disk (SQLite file databases, uploads, a log file);

  • process-global state, notably environment variables set with setenv and Environment.process, and global singletons.

The remedies, from cheapest to most work: give every test its own resource (an in-memory SQLite database per application, port 0); serialize with .serialized on the suite, which orders its tests but does not stop other suites from running at the same time; or run the whole run serially with swift test --no-parallel, which also serializes Swift Testing (a run of two one-second tests took 1.0 s in parallel and 2.1 s with the flag). The results of a suite with .serialized running next to other suites show in the output of a run, for example:

✔ Test "the Leaf template renders published books" passed after 0.205 seconds.
✔ Test "Basic credentials log in and return a token" passed after 0.208 seconds.
✔ Test "a unique constraint violation surfaces as a database error" passed after 0.208 seconds.
✔ Test "every application gets an isolated in-memory database" with 12 test cases passed after 0.218 seconds.
✔ Test run with 22 tests in 7 suites passed after 0.405 seconds.

Applications are isolated because .sqlite(.memory) is not a single database: SQLiteKit gives each SQLiteConfiguration.memory value a random UUID identifier, so two applications never see each other’s data, and the data lives only as long as a connection to it is open. The twelve parameterized runs in the database section below all insert the same unique ISBN and none collides.

Traits and tags

.disabled and .enabled(if:) skip a suite or test and show it as skipped in the report, which is better than #if for environment-dependent suites (the PostgreSQL suite below runs only when DATABASE_URL is set). .timeLimit(.minutes(1)) makes a hung test fail instead of hanging the job, which matters for tests that wait on a socket or a queue. .tags(...) labels tests for grouping and for Xcode’s test plans. The tags themselves are declared once, in a file that imports only Testing, because the application also defines a Tag model (the Bookshelf tags) and Tag would otherwise be ambiguous in files that @testable import App:

import Testing

extension Tag {
    /// Custom tags let you group tests across suites and files, for example to run only the database tests.
    @Tag static var database: Self
    @Tag static var slow: Self
}

A scoped application trait

Repeating withBookshelf { app in ... } around every test is fine and explicit. Swift Testing (Swift 6.1 and later) also lets a trait scope each test, so the application can be provided without a closure. The trait below builds the migrated application, publishes it in a task-local value for the duration of the test, and tears it down afterwards:

import Fluent
import Testing
import VaporTesting
@testable import App

/// A trait that builds a fresh, migrated application for every test in the suite and removes it afterwards.
struct BookshelfAppTrait: TestTrait, SuiteTrait, TestScoping {
    @TaskLocal static var app: Application?
    var isRecursive: Bool { true }

    func provideScope(for test: Test, testCase: Test.Case?,
                      performing function: @Sendable () async throws -> Void) async throws {
        try await withBookshelf { app in
            try await Self.$app.withValue(app) { try await function() }
        }
    }
}

extension Trait where Self == BookshelfAppTrait {
    static var bookshelfApp: Self { Self() }
}

@Suite("Scoped application", .bookshelfApp)
struct ScopedAppTests {
    @Test func listsPublishedBooks() async throws {
        let app = try #require(BookshelfAppTrait.app)
        _ = try await seed(app.db)
        try await app.testing().test(.GET, "books", afterResponse: { res async throws in
            #expect(try res.content.decode([BookDTO].self).count == 1)
        })
    }

    @Test func startsEmptyEveryTime() async throws {
        let app = try #require(BookshelfAppTrait.app)
        #expect(try await Book.query(on: app.db).count() == 0)
    }
}

The trait is isRecursive, so it applies to every test of the suite, and provideScope ran once per test case (it was called with a non-nil testCase for each of the two tests and never for the suite alone); startsEmptyEveryTime passing while its sibling seeds data shows the two get separate applications. The task-local approach has a cost: the test reads the app with #require(BookshelfAppTrait.app), which is less discoverable than a closure parameter, and child tasks started with Task.detached lose the task-local value. Use it when many suites need the same setup.

Database tests

The database is the part of a Vapor service that is hardest to test well, so the rules are worth stating:

  1. Tests never touch a database that matters. The configure above gives .testing an in-memory SQLite database unless DATABASE_URL is set, and refuses a URL that does not end in _test.

  2. Every test starts from a known schema and known rows. The schema comes from the real migrations (autoMigrate()), so the migrations themselves are exercised on every run; the rows come from a fixture function, not from earlier tests.

  3. A test leaves nothing behind. autoRevert() runs the migrations' revert methods, which matters for a shared PostgreSQL database and costs almost nothing for SQLite in memory. It also tests the revert implementations.

The official docs show this as a withAppIncludingDB helper; here it is withBookshelf, plus a seed function and a helper that logs in through the real endpoint:

import Fluent
import Queues
import Testing
import VaporTesting
import XCTQueues
@testable import App

/// The one place that knows how a test app is built: configure, migrate, run the test, revert, shut down.
@discardableResult
func withBookshelf<T>(
    _ test: (Application) async throws -> T
) async throws -> T {
    try await withApp(configure: { app in
        app.queues.use(.asyncTest)            // jobs are recorded in memory instead of sent to Redis
        try await configure(app)
        try await app.autoMigrate()
    }) { app in
        do {
            let result = try await test(app)
            try await app.autoRevert()        // matters for a shared Postgres database, harmless for SQLite memory
            return result
        } catch {
            try? await app.autoRevert()
            throw error
        }
    }
}

struct Fixtures {
    let author: Author
    let book: Book
    let reader: User
}

/// Inserts a small, known data set. Passwords are stored as-is because tests use the plaintext hasher.
func seed(_ db: any Database) async throws -> Fixtures {
    let author = Author(name: "Ursula K. Le Guin")
    try await author.save(on: db)
    let book = Book(title: "The Dispossessed", isbn: "9780061054884", publishedYear: 1974,
                    priceCents: 1299, status: .published, authorID: try author.requireID())
    try await book.save(on: db)
    try await Book(title: "Unpublished Draft", isbn: "9780000000002", publishedYear: 2030,
                   priceCents: 0, status: .draft, authorID: try author.requireID()).save(on: db)
    let reader = User(email: "reader@example.com", passwordHash: "test-password")
    try await reader.save(on: db)
    return Fixtures(author: author, book: book, reader: reader)
}

extension TestingApplicationTester {
    /// Logs in through the real endpoint and returns the bearer token.
    func token(email: String, password: String) async throws -> String {
        let response = try await sendRequest(.POST, "login", headers: [:]) { req in
            req.headers.basicAuthorization = .init(username: email, password: password)
        }
        try #require(response.status == .ok)
        return try response.content.decode(TokenResponse.self).token
    }
}

Points worth noticing:

  • withApp(configure:_:) runs the setup closure before the body and shuts down on error, so the helper only has to add autoMigrate() and autoRevert(). try? await app.autoRevert() in the catch makes a failing test still clean up, without hiding the original error.

  • app.queues.use(.asyncTest) comes before configure(app), because configure registers jobs on the queues configuration that must already have a driver.

  • Fixtures are created with the models, save(on: app.db), not through HTTP, so a test of GET /books does not depend on POST /books working. Use the API for setup only when that is the thing under test, as token(email:password:) does for login.

  • The helper is generic over the test’s return type, so a test can return a value out of the closure.

A suite that exercises the database directly, including parallel isolation and a unique-constraint failure:

import Fluent
import FluentSQLiteDriver
import Testing
import VaporTesting
@testable import App

@Suite("Database", .tags(.database))
struct DatabaseTests {
    // Not .serialized: every test builds its own application with its own in-memory SQLite database,
    // so these 12 may run in parallel and all insert the same unique ISBN without colliding.
    @Test("every application gets an isolated in-memory database", arguments: 1...12)
    func isolated(run: Int) async throws {
        try await withBookshelf { app in
            _ = try await seed(app.db)
            #expect(try await Book.query(on: app.db).count() == 2)
            #expect(try await User.query(on: app.db).count() == 1)
        }
    }

    @Test("a unique constraint violation surfaces as a database error")
    func uniqueIsbn() async throws {
        try await withBookshelf { app in
            let fixtures = try await seed(app.db)
            let duplicate = Book(title: "Copy", isbn: fixtures.book.isbn, publishedYear: 2000, priceCents: 1,
                                 status: .draft, authorID: try fixtures.author.requireID())
            await #expect(throws: (any Error).self) { try await duplicate.save(on: app.db) }
            #expect(try await Book.query(on: app.db).count() == 2)
        }
    }

    @Test("autoRevert leaves an empty schema, so the next autoMigrate starts clean")
    func migrateRevertCycle() async throws {
        try await withApp(configure: { app in
            app.databases.use(.sqlite(.memory), as: .sqlite)
            app.migrations.add(CreateBookshelf(), CreateApiToken())
        }) { app in
            try await app.autoMigrate()
            _ = try await seed(app.db)
            try await app.autoRevert()
            try await app.autoMigrate()
            #expect(try await Author.query(on: app.db).count() == 0)
        }
    }
}

/// Skipped unless DATABASE_URL points at a PostgreSQL server, as it does in the CI job below.
@Suite("PostgreSQL", .serialized, .enabled(if: Environment.get("DATABASE_URL") != nil), .tags(.database, .slow))
struct PostgresTests {
    @Test("the schema migrates, accepts rows and reverts on a real server")
    func roundTrip() async throws {
        try await withBookshelf { app in
            _ = try await seed(app.db)
            #expect(try await Book.query(on: app.db).count() == 2)
        }
    }
}

Choosing the database for the test run

Option Good for Watch out for

SQLite .memory (default)

fast, parallel, zero setup; exercises the migrations and Fluent queries

SQL dialect differences: native enum types and the exact DDL, case rules and collation of LIKE, locking, JSON and array columns. A migration or query that passes on SQLite can fail on PostgreSQL

SQLite file in a temporary directory

a test that needs several processes or a database that outlives the application

delete the file; do not share it between parallel tests

PostgreSQL (or MySQL) in Docker, local

tests that must run the production dialect: ILIKE, JSON columns, array types, constraint behaviour, migrations with PostgreSQL enums

one shared server, so serialize or give each suite its own database

PostgreSQL service container in CI

the same as local Docker, on every pull request

the host name inside a job container is the service name, not localhost

Testcontainers

a throwaway database per test run started from the test code

there is no official Testcontainers module for Swift; the community libraries found on GitHub on 2026-10-17 are small and single-maintainer, so a Compose file or a CI service container is the safer default

A local PostgreSQL for the guarded DATABASE_URL is one command (the password is a placeholder):

docker run --rm -d --name bookshelf-test-db -p 5432:5432 \
  -e POSTGRES_USER=bookshelf -e POSTGRES_PASSWORD=change-me -e POSTGRES_DB=bookshelf_test \
  postgres:17-alpine

DATABASE_URL=postgres://bookshelf:change-me@localhost:5432/bookshelf_test swift test

The suite that needs it is skipped when DATABASE_URL is absent, and configure rejects a URL whose database is not a _test one. Running it against .../bookshelf instead failed immediately with the guard’s message:

✘ Test "the schema migrates, accepts rows and reverts on a real server" recorded an issue at DatabaseTests.swift:49:6: Caught error: refusing to run tests against a database whose name does not end in _test

When several suites share one PostgreSQL database, give each suite its own database (create it from an admin connection in the suite’s setup), or mark them all .serialized and rely on autoRevert(). Running swift test against the development database and relying on truncation is how data gets lost, which is what the _test guard is for. See Fluent: Overview and Drivers for the PostgreSQL configuration and TLS options that a CI database needs.

Testing authentication

Authentication tests send credentials the way a client does and assert on the status code. The helpers on HTTPHeaders make that readable: req.headers.basicAuthorization = .init(username:password:) and req.headers.bearerAuthorization = .init(token:) set the Authorization header with the right scheme and encoding. For tests with many authenticated calls the token(email:password:) helper from Support.swift logs in through POST /login once and returns the token.

import Fluent
import Testing
import VaporTesting
@testable import App

@Suite("Authentication", .serialized)
struct AuthTests {
    @Test("Basic credentials log in and return a token")
    func basicLogin() async throws {
        try await withBookshelf { app in
            _ = try await seed(app.db)
            try await app.testing().test(.POST, "login", beforeRequest: { req in
                req.headers.basicAuthorization = .init(username: "reader@example.com", password: "test-password")
            }, afterResponse: { res async throws in
                #expect(res.status == .ok)
                let token = try res.content.decode(TokenResponse.self)
                #expect(token.token.count == 64)
            })
        }
    }

    @Test("wrong password and missing credentials are both 401")
    func rejected() async throws {
        try await withBookshelf { app in
            _ = try await seed(app.db)
            try await app.testing().test(.POST, "login", beforeRequest: { req in
                req.headers.basicAuthorization = .init(username: "reader@example.com", password: "wrong")
            }, afterResponse: { res async in
                #expect(res.status == .unauthorized)
            })
            try await app.testing().test(.GET, "me", afterResponse: { res async in
                #expect(res.status == .unauthorized)
            })
        }
    }

    @Test("a bearer token reaches protected routes")
    func bearer() async throws {
        try await withBookshelf { app in
            _ = try await seed(app.db)
            let tester = try app.testing()
            let token = try await tester.token(email: "reader@example.com", password: "test-password")
            try await tester.test(.GET, "me", beforeRequest: { req in
                req.headers.bearerAuthorization = .init(token: token)
            }, afterResponse: { res async in
                #expect(res.body.string == "reader@example.com")
            })
            try await tester.test(.GET, "me", beforeRequest: { req in
                req.headers.bearerAuthorization = .init(token: "not-a-token")
            }, afterResponse: { res async in
                #expect(res.status == .unauthorized)
            })
        }
    }

    @Test("an expired token is rejected")
    func expired() async throws {
        try await withBookshelf { app in
            let fixtures = try await seed(app.db)
            let hash = SHA256.hash(data: Data("expired-token".utf8)).map { String(format: "%02x", $0) }.joined()
            try await ApiToken(tokenHash: hash, userID: try fixtures.reader.requireID(),
                               expiresAt: Date().addingTimeInterval(-60)).save(on: app.db)
            try await app.testing().test(.GET, "me", headers: ["Authorization": "Bearer expired-token"],
                                         afterResponse: { res async in #expect(res.status == .unauthorized) })
        }
    }
}

What this suite pins down, and why each case is there:

  • 401 for missing and wrong credentials alike. The default GuardMiddleware throws Abort(.unauthorized) when no authenticator logged a user in, so "no header", "wrong password" and "bad token" are the same response; a test that expected 403 would be testing authorization, not authentication (see Authorization).

  • An expired token is rejected. The row is inserted directly with a past expiresAt, because waiting an hour in a test is not an option; any time-dependent behaviour is tested by choosing the stored timestamp, not by sleeping.

  • The token never appears in the database. login stores only its SHA-256, so the test would need the raw token from the response; that is why token(email:password:) goes through the endpoint.

  • Plaintext passwords exist only here. seed stores "test-password" as the hash because .plaintext is the hasher in .testing; production configure selects bcrypt, so a test that reads a hash out of the production path would not work.

Sessions and cookies follow the same pattern with a twist: the first response carries a Set-Cookie header that the next request must send back as Cookie, so a login-then-use test reads the header from the response returned by sendRequest and replays it in beforeRequest. The details are in Sessions and Session Authentication and Web Login. JWT-protected routes are tested by signing a token with the same JWTKeyCollection the application uses (see JSON Web Tokens).

Testing validation, errors and writes

A request that fails validation (see Validation) is part of the API contract. Vapor’s default error body is {"error":true,"reason":"..."}, and for validation failures reason lists every failing field, so the test decodes it with a small Decodable struct and checks the names, not the whole sentence. The same suite covers the happy path of POST /books, which also proves that the Content-Type set by req.content.encode is what the route expects, and the queue.

import Fluent
import Queues
import Testing
import VaporTesting
import XCTQueues
@testable import App

/// The JSON shape of Vapor's default error response.
struct ErrorBody: Decodable { let error: Bool; let reason: String }

@Suite("Writes, validation and jobs", .serialized)
struct WriteTests {
    @Test("an invalid body is a 400 that names the failing fields")
    func validation() async throws {
        try await withBookshelf { app in
            let fixtures = try await seed(app.db)
            let token = try await app.testing().token(email: "reader@example.com", password: "test-password")
            try await app.testing().test(.POST, "books", beforeRequest: { req in
                req.headers.bearerAuthorization = .init(token: token)
                try req.content.encode(CreateBook(title: "", isbn: "123", publishedYear: 1000,
                                                  authorID: try fixtures.author.requireID()))
            }, afterResponse: { res async throws in
                #expect(res.status == .badRequest)
                let reason = try res.content.decode(ErrorBody.self).reason
                #expect(reason.contains("title"))
                #expect(reason.contains("isbn"))
                #expect(reason.contains("publishedYear"))
            })
            #expect(try await Book.query(on: app.db).count() == 2)   // nothing was inserted
        }
    }

    @Test("a valid POST creates the row and returns 201")
    func create() async throws {
        try await withBookshelf { app in
            let fixtures = try await seed(app.db)
            let token = try await app.testing().token(email: "reader@example.com", password: "test-password")
            try await app.testing().test(.POST, "books", beforeRequest: { req in
                req.headers.bearerAuthorization = .init(token: token)
                try req.content.encode(CreateBook(title: "The Lathe of Heaven", isbn: "9780743270632",
                                                  publishedYear: 1971, authorID: try fixtures.author.requireID()))
            }, afterResponse: { res async throws in
                #expect(res.status == .created)
                #expect(try res.content.decode(BookDTO.self).title == "The Lathe of Heaven")
            })
            let stored = try await Book.query(on: app.db).filter(\.$isbn == "9780743270632").first()
            #expect(stored?.publishedYear == 1971)
        }
    }

    @Test("posting a review enqueues a job; the worker runs it")
    func reviewJob() async throws {
        try await withBookshelf { app in
            let fixtures = try await seed(app.db)
            let token = try await app.testing().token(email: "reader@example.com", password: "test-password")
            let bookID = try fixtures.book.requireID()
            try await app.testing().test(.POST, "books/\(bookID)/reviews", beforeRequest: { req in
                req.headers.bearerAuthorization = .init(token: token)
                try req.content.encode(NewReview(rating: 5, body: "Brilliant"))
            }, afterResponse: { res async in
                #expect(res.status == .created)
            })

            // The handler only enqueued the job.
            #expect(app.queues.asyncTest.contains(NotifyAuthorJob.self))
            let payload = try #require(app.queues.asyncTest.first(NotifyAuthorJob.self))
            #expect(await app.notificationLog.notified.isEmpty)

            // Now run the worker once, in-process, as `swift run App queues` would.
            try await app.queues.queue.worker.run()
            #expect(await app.notificationLog.notified == [payload.reviewID])
            #expect(app.queues.asyncTest.queue.isEmpty)
        }
    }
}

Two details are easy to get wrong:

  • The ErrorBody shape. Vapor’s default ErrorMiddleware writes error as a boolean. Decoding the body as [String: String] fails with Expected to decode String but found bool instead, which was the first failure while writing this page. If the application replaces the default error body with its own (see Error Handling), the test struct follows it.

  • Assert on the database, not only the response. #expect(try await Book.query(on: app.db).count() == 2) after a rejected request proves that nothing was inserted; without it a handler that saved the row and returned 400 would pass.

Testing queues

The handler of POST /books/:bookID/reviews does not notify the author itself: it saves the review and dispatches NotifyAuthorJob. With the in-memory driver from XCTQueues, a test can check both halves separately: that the handler enqueued the right payload, and that the job does the right thing when a worker runs it. The test above does both, in order:

  1. app.queues.use(.asyncTest) (installed by withBookshelf) replaces the Redis driver with an array and a dictionary in memory. Despite the module name, XCTQueues has no XCTest dependency; it is the supported test driver for Swift Testing too.

  2. After the request, app.queues.asyncTest.contains(NotifyAuthorJob.self) and app.queues.asyncTest.first(NotifyAuthorJob.self) read the dispatched payload without running it. app.queues.asyncTest.queue holds the pending job identifiers and app.queues.asyncTest.jobs the stored job data.

  3. try await app.queues.queue.worker.run() runs the worker over the queue once, in the test’s task, so when it returns the job has been dequeued, executed and removed. This is the deterministic replacement for starting startInProcessJobs() and sleeping.

The synchronous .test driver exists for the future-based API; AsyncJob code uses .asyncTest. Retries, delayed jobs and the event delegate are covered, with their tests, in Queues and Background Jobs and Scheduled Jobs.

Testing WebSockets

A WebSocket needs a real connection, so .inMemory cannot test it, and .running stops the server after each test(...) call, so it cannot keep the socket open either. The way that works is to start the application yourself and connect with WebSocket.connect(to:on:) from WebSocketKit (a transitive dependency of Vapor, which import VaporTesting brings in):

import Fluent
import NIOConcurrencyHelpers
import Testing
import VaporTesting
import WebSocketKit
@testable import App

@Suite("Leaf and WebSockets", .serialized)
struct ViewAndSocketTests {
    @Test("the Leaf template renders published books")
    func leaf() async throws {
        try await withBookshelf { app in
            _ = try await seed(app.db)
            try await app.testing().test(.GET, "catalogue", afterResponse: { res async in
                #expect(res.status == .ok)
                #expect(res.headers.contentType?.subType == "html")
                #expect(res.body.string.contains("<li>The Dispossessed (1974)</li>"))
                #expect(!res.body.string.contains("Unpublished Draft"))
            })
        }
    }

    @Test("the WebSocket echoes text", .timeLimit(.minutes(1)))
    func echo() async throws {
        try await withApp(configure: { app in
            app.environment.commandInput.arguments = ["serve"]    // ignore the test runner's own arguments
            app.http.server.configuration.port = 8091
            try await configure(app)
        }) { app in
            try await app.startup()                               // really listens on 127.0.0.1:8091
            let (sockets, openedSocket) = AsyncStream.makeStream(of: WebSocket.self)
            let (replies, reply) = AsyncStream.makeStream(of: String.self)
            try await WebSocket.connect(to: "ws://127.0.0.1:8091/echo", on: app.eventLoopGroup) { ws async in
                ws.onText { _, text async in reply.yield(text) }
                openedSocket.yield(ws)
            }
            var socketIterator = sockets.makeAsyncIterator()
            let ws = try #require(await socketIterator.next())
            try await ws.send("hello")
            var replyIterator = replies.makeAsyncIterator()
            #expect(await replyIterator.next() == "echo: hello")
            try await ws.close()
        }
    }
}

Notes on this listing, which went through two failed versions:

  • app.startup() boots the application and starts the server on the configured port. It reads the application’s command input, and the first version, without app.environment.commandInput.arguments = ["serve"], failed in this scratch package with the error "Unknown command" for --test-bundle-path: an argument that swift test passes to the test runner reached Vapor’s command parser, because the argument sanitizing of the .testing environment did not remove it. The explicit ["serve"] is the robust form.

  • Do not sleep and then read a variable. The first version put the socket in a lock-protected optional and read it right after connect returned; the upgrade callback can run after connect returns, so it failed in isolation and passed when other tests slowed the run. An AsyncStream makes the test wait for the socket and for the reply, with no timing guess.

  • .timeLimit(.minutes(1)) is the safety net: if the server never answers, the test fails after a minute instead of hanging the run.

  • Close the socket before the closure returns, so the shutdown at the end of withApp does not wait on an open connection.

  • Fixed ports (8091 here) mean this suite must not run twice at once; use port 0 plus app.http.server.shared.localAddress if you need a free port, and keep every WebSocket test in one .serialized suite.

The Bookshelf’s review feed and its ticket-based authentication are tested this way in WebSockets. Server-sent events are the same story with a streaming Response.Body, tested by reading the body, and are covered on Server-Sent Events.

Testing Leaf output

A Leaf view is rendered by the route, so the test is a request and an assertion on the HTML. The tests assert on small fragments (one <li>, the absence of a draft title) rather than the whole document, which keeps them stable when the layout changes:

@Test("the Leaf template renders published books")
func leaf() async throws {
    try await withBookshelf { app in
        _ = try await seed(app.db)
        try await app.testing().test(.GET, "catalogue", afterResponse: { res async in
            #expect(res.status == .ok)
            #expect(res.headers.contentType?.subType == "html")
            #expect(res.body.string.contains("<li>The Dispossessed (1974)</li>"))
            #expect(!res.body.string.contains("Unpublished Draft"))
        })
    }
}

Two Leaf-specific points. The view directory is resolved from the application’s working directory (Resources/Views under app.directory.workingDirectory), and swift test runs from the package root, so the template is found. Xcode runs tests from a different directory unless the scheme sets a custom working directory, and the failure is a "view not found" error from the render call, not a test problem. And a Leaf template with an escaped value should be tested with a hostile title (for example one containing <script>), asserting the escaped form is present; Leaf Templates shows that test. For browser behaviour (JavaScript, forms, accessibility) use real-browser tests, which are out of scope here.

Test doubles for services

Anything the application reaches through a protocol can be replaced for the test. The mechanisms, from the pages that define them:

  • Replace a service on the application before configure runs, as app.queues.use(.asyncTest) does here. Anything stored through app.storage and a StorageKey can be overwritten by the test (see Services, Storage and Dependency Injection).

  • Replace the outbound HTTP client with a stub Client through app.clients.use, so a route that calls another service is tested without a network (the stub and its test are in HTTP Client).

  • Replace the clock by injecting a Date provider or choosing stored timestamps, as the expired-token test does.

  • Replace email, push and storage backends with a recording implementation, usually an actor that appends to an array, then assert on what it recorded (see Email and APNs Push Notifications).

Mocking the database is rarely worth it with Fluent: SQLite in memory is fast enough that the real queries can run.

Testing middleware, commands and uploads

Each of these has its own testing section where it is introduced:

  • Middleware is tested through a route behind it, asserting on the added headers, the short-circuit response or the logged effect; the chain’s order is tested by recording the order in a test route (Middleware).

  • Commands do not need the HTTP tester at all: build a CommandContext with a console that records its output, and run the command through the same Console.run the application uses (Commands).

  • File uploads send body: with a ByteBuffer and the right Content-Type, or encode a multipart/form-data form with the Content machinery; see File Uploads and Streaming and Content and Codable.

  • The OpenAPI contract can be checked against the running application with a generated client (see OpenAPI and API Design, Versioning and DTOs).

XCTVapor, the legacy API

XCTVapor is the XCTest equivalent. It still ships in 4.122.2 and still works, and a project can contain both kinds of test target files at the same time, because swift test runs XCTest and Swift Testing in one pass. The same two route tests as above, in XCTest style:

import Fluent
import Queues
import XCTQueues
import XCTVapor
@testable import App

final class LegacyBookTests: XCTestCase {
    var app: Application!

    override func setUp() async throws {
        app = try await Application.make(.testing)
        app.queues.use(.asyncTest)
        try await configure(app)
        try await app.autoMigrate()
    }

    override func tearDown() async throws {
        try await app.autoRevert()
        try await app.asyncShutdown()
        app = nil
    }

    func testListsPublishedBooks() async throws {
        _ = try await seed(app.db)
        try await app.testable().test(.GET, "books", afterResponse: { res async throws in
            XCTAssertEqual(res.status, .ok)
            XCTAssertContent([BookDTO].self, res) { books in
                XCTAssertEqual(books.map(\.title), ["The Dispossessed"])
            }
        })
    }

    func testUnknownBookIs404() async throws {
        try await app.testable().test(.GET, "books/00000000-0000-0000-0000-000000000000", afterResponse: { res async in
            XCTAssertEqual(res.status, .notFound)
        })
    }
}

The migration is mostly renaming, and VaporTesting ships unavailable declarations with renamed: messages (in XCTest+Migration.swift) so the compiler tells you the new name:

XCTVapor VaporTesting

import XCTVapor

import VaporTesting and import Testing

final class ...Tests: XCTestCase

@Suite struct ...Tests

setUp and tearDown creating the app

withApp or a helper like withBookshelf, or a TestScoping trait

func testName()

@Test func name() with an optional display name

app.testable(method:)

app.testing(method:)

XCTApplicationTester

TestingApplicationTester

XCTHTTPRequest and XCTHTTPResponse

TestingHTTPRequest and TestingHTTPResponse

XCTAssertEqual(a, b)

#expect(a == b)

XCTAssertContent, XCTAssertContains, XCTAssertEqualJSON

expectContent, expectContains, expectJSONEquals

try XCTUnwrap(x)

try #require(x)

XCTFail("...")

Issue.record("...")

Mixing the two APIs is the trap, and it is silent. Calling app.testable() from a Swift Testing test and asserting with XCTAssertEqual in the closure, with a status that was wrong on purpose, produced a passing test:

🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻
XCTVapor function triggered in a swift-testing context.
Use 'app.testable()' in XCTest tests, and 'app.testing()' in swift-testing ones.
✔ Test mixed() passed after 0.017 seconds with 2 warnings.

The reverse mistake (app.testing() from an XCTest case) prints a similar banner starting "swift-testing function triggered in a non-swift-testing context. This will result in test failures not being reported" (from the 4.122.2 source). In both directions the only signal is the banner in the log, so a suite is migrated one file at a time, never one call at a time.

The XCTest-era books describe an older world. Vapor 2 tests built a Droplet with Droplet.testable() and registered the tests in Tests/LinuxMain.swift with allTests arrays; Vapor 3 tests built Request(http:using:) values by hand; Vapor 4 moved to XCTVapor and app.test(...); since 4.110 the answer is VaporTesting. The app.test(...) form still compiles, because Application itself conforms to the tester protocol (try await app.test(.GET, "health") calls app.testing() for you), and the synchronous try app.test(...) forms of early Vapor 4 are marked noasync in XCTVapor. SwiftPM has discovered tests on Linux automatically since Swift 5.4, so LinuxMain.swift is not needed.

Running the suite

swift test                                         # everything, Swift Testing and XCTest
swift test --filter AuthTests                      # tests whose identifier matches a regular expression
swift test --skip ViewAndSocketTests               # everything except those
swift test list                                    # the identifiers --filter matches against
swift test --enable-code-coverage                  # also writes coverage data; swift test --show-codecov-path prints where
swift test --maximum-repetitions 20 --repeat-until fail   # repeat Swift Testing tests to hunt a flaky one

The identifiers have the form AppTests.AuthTests/bearer(), so --filter can select a suite or one test. --no-parallel runs the tests one at a time, Swift Testing ones included; without it, Swift Testing tests run in parallel unless their suite is .serialized. In Xcode, the Test navigator and the diamond in the gutter run a single test, and test plans can include or exclude tests by tag. Test output is quieter or louder with the log level (LOG_LEVEL); see Logging for capturing log output in a test and Tracing, Metrics & OpenTelemetry for testing spans and metrics.

Continuous integration

A GitHub Actions workflow with a Linux job (PostgreSQL as a service container, the dialect the application uses in production) and a macOS job (SQLite in memory only). The Linux job runs in the official Swift image; because it runs in a container, the service is reached by its name, postgres, not localhost. Passwords are placeholders: a throwaway database in an ephemeral runner needs no secret, and a real secret would come from ${{ secrets.NAME }}.

name: test

on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  linux:
    runs-on: ubuntu-latest
    container: swift:6.4-noble
    services:
      postgres:
        image: postgres:17-alpine
        env:
          POSTGRES_USER: bookshelf
          POSTGRES_PASSWORD: ci-only-password
          POSTGRES_DB: bookshelf_test
        options: >-
          --health-cmd "pg_isready -U bookshelf"
          --health-interval 5s --health-timeout 5s --health-retries 10
    env:
      DATABASE_URL: postgres://bookshelf:ci-only-password@postgres:5432/bookshelf_test
    steps:
      - uses: actions/checkout@v7
      - name: Run tests with coverage
        run: swift test --enable-code-coverage

  macos:
    runs-on: macos-26
    steps:
      - uses: actions/checkout@v7
      - name: Run tests
        run: swift test

Notes: swift:6.4-noble is the image the Vapor repository’s own CI uses for its newest toolchain; pin the Swift version your application is built with, and keep it the same as the Dockerfile (see Deployment with Docker and Application Lifecycle and Entrypoint for what the production image runs). A macOS job is worth having because the same code compiles with different Foundation and Security implementations on the two platforms. The workflow was not run for this page; the structure follows the Vapor repositories' own workflows and the general pipeline pattern in CI/CD with GitHub Actions.

Common errors

Symptom Cause Fix

A failing assertion never fails the test

XCTest assertions in a Swift Testing test (or app.testing() in an XCTest case); Vapor prints a banner of 🔻 lines instead

use the matching tester and assertion family; do not mix

"Unknown command" for --test-bundle-path from app.startup()

the test runner’s arguments reached Vapor’s command parser

app.environment.commandInput.arguments = ["serve"] before startup()

Application.shutdown() was not called before Application deinitialized. in the log

an Application was created with Application.make and not shut down

use withApp, or asyncShutdown() in a defer-style do/catch

... is not available due to missing import of defining module 'Queues' (or Fluent, FluentSQLiteDriver)

MemberImportVisibility is on, so each file must import what it uses

add the import to the test file and the product to the test target

Address already in use or a connection refused on a fixed port

two suites or a development server using the same port

use .running(port: 0), a distinct port per suite, or .serialized

a WebSocket or queue test hangs the run

waiting for a reply that never comes

.timeLimit, and an AsyncStream or worker.run() instead of sleep

a test passes alone and fails in the full run (or the reverse)

shared state: a port, a database, an environment variable, a file

give each test its own resource, or .serialized; --maximum-repetitions finds it

Expected to decode String but found bool instead

decoding Vapor’s error body as [String: String]

decode with a struct that has error: Bool and reason: String

view not found in a Leaf test run from Xcode

the working directory is not the package root

set the scheme’s working directory, or set app.directory.workingDirectory in the test setup

Tag is ambiguous

the Bookshelf Tag model and Swift Testing’s Tag

declare test tags in a file that imports only Testing, or write Testing.Tag

a migration passes on SQLite and fails on PostgreSQL

dialect differences (enum types, constraint names, column types)

run the suite against PostgreSQL in CI, as above

login tests take seconds

bcrypt at its default cost on every login

.plaintext or .bcrypt(cost: 4) in .testing

Version notes and outdated material

  • 4.110.0 (2024-12-21) added the VaporTesting module with withApp, app.testing() and the expect... helpers. Documentation, blog posts and books from before that use XCTVapor, and many still show the app.test(...) and synchronous try app.test(...) forms of early Vapor 4.

  • XCTVapor keeps working in 4.122.x; it is documented as the alternative for XCTest users, and is not deprecated by attribute, but the official guidance is that Swift Testing is highly recommended for new projects and teams adopting Swift concurrency.

  • Swift 6: tests that capture non-Sendable state in @Sendable closures (for example the callbacks of WebSocket.onText) fail to compile in the Swift 6 language mode; the examples use NIOLockedValueBox or AsyncStream, and the afterResponse and beforeRequest closures themselves are not @Sendable, so plain local state works there.

  • Books: Condon et al. (chapter 11) and Patel (chapters 8 and 9) test with XCTVapor/XCTest, a .testing environment and a separate test database (still right), Docker Compose for a Linux run (replaced today by a CI service container or a single docker run), and Travis CI (replaced by GitHub Actions). Yeung (chapter 4) covers testing and code coverage in the Xcode workflow of the Vapor 3 era. The chapters predate Swift Testing, withApp, parameterized tests, TestScoping, the in-memory queue driver for async jobs and Swift 6 concurrency checking, none of which they mention.

Vapor 5

Vapor 5 is at 5.0.0-beta.4 and not released. Its source on the 5.0.0-beta.4 tag (read, not compiled for this page) shows these differences in the testing module:

  • There is no XCTVapor target; VaporTesting is the only testing product, so XCTVapor suites must be migrated first.

  • The tester is closure-scoped: try await app.testing { client in ... } hands the closure a TestClient, which is a Client, so a test calls client.get("hello") and reads res.status and the body, instead of app.testing().test(.GET, ...). The method argument is still .inMemory or .running.

  • A running test defaults to port 0, bound on 127.0.0.1, and LiveTestOptions / LiveClientOptions configure the host, port, TLS trust and client timeout (30 seconds by default); TestClient.port reports the port that was bound.

  • withApp takes environment, configuration, a swift-configuration ConfigReader, a logger and services, and shuts the application down with app.shutdown().

The concepts here (a test application per test, in-memory by default, a real server when the wire matters, Swift Testing as the framework) carry over; the exact signatures change, so review What’s New, Upgrading and Vapor 5 before moving (Application Lifecycle and Entrypoint covers the other lifecycle changes).