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 |
|---|---|---|
|
Swift Testing ( |
current; added in 4.110.0 (2024-12-21); the one to use for new tests |
|
XCTest |
legacy; still shipped and working, kept for existing XCTest suites |
|
either framework |
in-memory queue driver for tests, |
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.
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 |
Route tests (the bulk) |
status codes, headers, JSON bodies, authentication, validation errors, database effects, enqueued jobs |
|
Wire-level and end-to-end tests |
TLS, |
|
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_URLselects PostgreSQL;.testingwith no URL selects SQLite in memory. The guard refuses a URL that does not end in_test, so a developer with a productionDATABASE_URLexported in their shell cannot wipe it by runningswift test. -
.plaintexthashing in.testing. bcrypt at the default cost makes every login take a few hundred milliseconds, which multiplies across a suite;app.passwords.use(.plaintext)makesUser.passwordHashhold the password as typed. It is only acceptable because the.testingenvironment 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()inconfigure. Migrations run from the entrypoint in production (as a separatemigratestep, 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"),
] }
Source: vapor/template: Package.swift
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:
-
Application.make(.testing)creates the application in thetestingenvironment; -
it awaits your
configureclosure (routes, databases, migrations, middleware); -
it runs the test body with the application;
-
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:
-
headersandbodyare the simple form:headers: ["Authorization": "Bearer abc"],body: ByteBuffer(string: "..."). -
beforeRequestreceives the request asinout TestingHTTPRequest, so you can setreq.headers.basicAuthorizationandreq.headers.bearerAuthorization, and use theContentencoders:try req.content.encode(value)sets the body and theContent-Type(JSON by default), andtry req.query.encode(value)appends a query string. The request’smethod,url,headersandbodyare plain properties. -
afterResponseis where the assertions go. It is optional, so a call that only needs a side effect omits it. -
testreturns the tester, so calls can be chained for a sequence of requests against the same application, though separate statements read better. -
sendRequestreturns theTestingHTTPResponseinstead 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
Abortis not an error here, because the defaultErrorMiddlewareturns it into a 4xx response that you assert on.testrecords a Swift Testing issue at the call site and rethrows when something outside that chain throws:afterResponseorbeforeRequestitself, a failure to start the server in.runningmode, or a responder chain from which you removedErrorMiddleware.
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 |
|---|---|---|
|
|
decodes the body with the decoder for the response’s |
|
|
records an issue unless the first string contains the second |
|
|
decodes a JSON string with |
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:
-
.serializedon 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 inunknownBookappear in the output as one test "with 2 test cases", run concurrently.not-a-uuidshows that a malformed identifier is a 404 in this application, becauseBook.findis givennil. -
The
async throwsonafterResponseis needed when the closure decodes or throws;asyncalone is enough for plain#expectcalls, 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
.createdgave:
✘ 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 |
|
|
Skipped layers |
socket, HTTP codecs, connection handling, server-added headers |
none |
Response body |
collected from the |
read by the client, up to 1 MB |
Server lifecycle |
nothing started |
started and stopped by every |
Port |
none; safe to run many in parallel |
a fixed port, so tests using the same port must not overlap; |
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
})
}
}
}
Source: vapor/vapor
4.122.2: TestingApplicationTester.swift (the .running method)
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
setenvandEnvironment.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.
Source: Swift Testing: TestScoping
Database tests
The database is the part of a Vapor service that is hardest to test well, so the rules are worth stating:
-
Tests never touch a database that matters. The
configureabove gives.testingan in-memory SQLite database unlessDATABASE_URLis set, and refuses a URL that does not end in_test. -
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. -
A test leaves nothing behind.
autoRevert()runs the migrations'revertmethods, which matters for a shared PostgreSQL database and costs almost nothing for SQLite in memory. It also tests therevertimplementations.
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 addautoMigrate()andautoRevert().try? await app.autoRevert()in thecatchmakes a failing test still clean up, without hiding the original error. -
app.queues.use(.asyncTest)comes beforeconfigure(app), becauseconfigureregisters 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 ofGET /booksdoes not depend onPOST /booksworking. Use the API for setup only when that is the thing under test, astoken(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)
}
}
}
Source: Vapor docs: Testing, database integration tests; Swift Testing: Running tests serially or in parallel
Choosing the database for the test run
| Option | Good for | Watch out for |
|---|---|---|
SQLite |
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 |
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: |
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 |
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) })
}
}
}
Source: Vapor docs: Authentication
What this suite pins down, and why each case is there:
-
401 for missing and wrong credentials alike. The default
GuardMiddlewarethrowsAbort(.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.
loginstores only its SHA-256, so the test would need the raw token from the response; that is whytoken(email:password:)goes through the endpoint. -
Plaintext passwords exist only here.
seedstores"test-password"as the hash because.plaintextis the hasher in.testing; productionconfigureselects 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)
}
}
}
Source: Vapor docs: Validation
Two details are easy to get wrong:
-
The
ErrorBodyshape. Vapor’s defaultErrorMiddlewarewriteserroras a boolean. Decoding the body as[String: String]fails withExpected 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:
-
app.queues.use(.asyncTest)(installed bywithBookshelf) replaces the Redis driver with an array and a dictionary in memory. Despite the module name,XCTQueueshas no XCTest dependency; it is the supported test driver for Swift Testing too. -
After the request,
app.queues.asyncTest.contains(NotifyAuthorJob.self)andapp.queues.asyncTest.first(NotifyAuthorJob.self)read the dispatched payload without running it.app.queues.asyncTest.queueholds the pending job identifiers andapp.queues.asyncTest.jobsthe stored job data. -
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 startingstartInProcessJobs()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()
}
}
}
Source: Vapor docs: WebSockets
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, withoutapp.environment.commandInput.arguments = ["serve"], failed in this scratch package with the error "Unknown command" for--test-bundle-path: an argument thatswift testpasses to the test runner reached Vapor’s command parser, because the argument sanitizing of the.testingenvironment 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
connectreturned; the upgrade callback can run afterconnectreturns, so it failed in isolation and passed when other tests slowed the run. AnAsyncStreammakes 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
withAppdoes 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.localAddressif you need a free port, and keep every WebSocket test in one.serializedsuite.
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"))
})
}
}
Source: Vapor docs: Leaf getting started
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
configureruns, asapp.queues.use(.asyncTest)does here. Anything stored throughapp.storageand aStorageKeycan be overwritten by the test (see Services, Storage and Dependency Injection). -
Replace the outbound HTTP client with a stub
Clientthroughapp.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
Dateprovider or choosing stored timestamps, as the expired-token test does. -
Replace email, push and storage backends with a recording implementation, usually an
actorthat 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
CommandContextwith a console that records its output, and run the command through the sameConsole.runthe application uses (Commands). -
File uploads send
body:with aByteBufferand the rightContent-Type, or encode amultipart/form-dataform with theContentmachinery; 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)
})
}
}
Source: Vapor docs: Testing, XCTVapor
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
use the matching tester and assertion family; do not mix |
"Unknown command" for |
the test runner’s arguments reached Vapor’s command parser |
|
|
an |
use |
|
|
add the import to the test file and the product to the test target |
|
two suites or a development server using the same port |
use |
a WebSocket or queue test hangs the run |
waiting for a reply that never comes |
|
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 |
|
decoding Vapor’s error body as |
decode with a struct that has |
|
the working directory is not the package root |
set the scheme’s working directory, or set |
|
the Bookshelf |
declare test tags in a file that imports only |
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 |
|
Version notes and outdated material
-
4.110.0 (2024-12-21) added the
VaporTestingmodule withwithApp,app.testing()and theexpect...helpers. Documentation, blog posts and books from before that useXCTVapor, and many still show theapp.test(...)and synchronoustry app.test(...)forms of early Vapor 4. -
XCTVaporkeeps 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-
Sendablestate in@Sendableclosures (for example the callbacks ofWebSocket.onText) fail to compile in the Swift 6 language mode; the examples useNIOLockedValueBoxorAsyncStream, and theafterResponseandbeforeRequestclosures 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.testingenvironment and a separate test database (still right), Docker Compose for a Linux run (replaced today by a CI service container or a singledocker 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
XCTVaportarget;VaporTestingis 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 aTestClient, which is aClient, so a test callsclient.get("hello")and readsres.statusand the body, instead ofapp.testing().test(.GET, ...). The method argument is still.inMemoryor.running. -
A running test defaults to port 0, bound on
127.0.0.1, andLiveTestOptions/LiveClientOptionsconfigure the host, port, TLS trust and client timeout (30 seconds by default);TestClient.portreports the port that was bound. -
withApptakesenvironment,configuration, a swift-configurationConfigReader, a logger andservices, and shuts the application down withapp.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).