How do I test the Hummingbird routes with swift-testing?
This article is part of a series.
It’d be way easier to catch any errors in all those routes if instead of that bash script, we could use swift-testing instead.
- https://github.com/carlynorama/HummingbirdExamples/tree/main/03_add_testing
- https://docs.hummingbird.codes/2.0/tutorials/todos#testing-your-application
- Meet Swift Testing (WWDC 2024): https://developer.apple.com/videos/play/wwdc2024/10179/
AppArguments
02_basic_responses added a file that I didn’t mention: AppArguments.swift
import Hummingbird
import Logging
//Allows for different setups for testing server and production server
public protocol AppArguments {
var nameTag:String { get }
var hostname: String { get }
var port: Int { get }
var logLevel: Logger.Level? { get }
}
This struct beings the process of of allowing a separate testing server or servers with different features and the live server.
As a demonstration, in the (the todos example)[https://docs.hummingbird.codes/2.0/tutorials/todos#testing-your-application], where I got this data structure from, the testing is done on in-memory data and the live server points to a postgres db.
An instance of AppArguments can be used at that top of every test as needed to spin up a new server unique to that test. For some testing suites it might make sense to make a group of tests that all point to the same App instance, but in this example each test gets its own little serverlette.
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif
import Hummingbird
import HummingbirdTesting
import Logging
import Testing
@testable import nowWithTesting
struct AppTests {
struct TestArguments: AppArguments {
let nameTag: String = "nwtTestServer"
let hostname = "127.0.0.1"
let port = 0
let logLevel: Logger.Level? = .trace
}
@Test func testCreateApp() async throws {
let app = try await buildApplication(TestArguments())
try await app.test(.router) { client in
let _ = try await client.execute(uri: "/ping", method: .get) { response in
#expect(response.status == .ok)
}
}
}
//more follows...
}
Example Tests
The tests aren’t comprehensive and the “unhappy paths” need improvement on both the server side and the testing side. None the less, these examples provide building blocks for what’s needed:
/goodbye
@Test func testGoodbye() async throws{
let app = try await buildApplication(TestArguments())
try await app.test(.router) { client in
let _ = try await client.execute(uri: "/goodbye", method: .get) { response in
#expect(response.status == .ok)
#expect(String(buffer: response.body) == "Ciao!")
}
}
}
Single wildcard
@Test func testSingleWild() async throws {
let app = try await buildApplication(TestArguments())
try await app.test(.router) { client in
let _ = try await client.execute(uri: "/files/", method: .get) { response in
#expect(response.status == .notFound)
}
let _ = try await client.execute(uri: "/files/testPhrase", method: .get) { response in
#expect(response.status == .ok)
#expect(String(buffer: response.body) == "/files/testPhrase")
}
let _ = try await client.execute(uri: "/files/too/many/things", method: .get) { response in
#expect(response.status == .notFound)
}
}
}
/user route
@Test func testUser() async throws {
let app = try await buildApplication(TestArguments())
try await app.test(.router) { client in
let _ = try await client.execute(uri: "/user/garbfjfesage", method: .get) { response in
#expect(response.status == .badRequest)
}
var intToTest = 43
let _ = try await client.execute(uri: "/user/\(intToTest)", method: .get) { response in
#expect(response.status == .ok)
#expect(String(buffer: response.body) == "\(intToTest)")
}
intToTest = 43131
let _ = try await client.execute(uri: "/user/\(intToTest)", method: .get) { response in
#expect(response.status == .ok)
#expect(String(buffer: response.body) == "\(intToTest)")
}
intToTest = -13214
let name = "some crazy string"
let _ = try await client.execute(uri: "/user/\(intToTest)/\(name)", method: .get) { response in
#expect(response.status == .ok)
#expect(String(buffer: response.body) == "found \(name) for \(intToTest)")
}
}
}
Codable / Decodable
struct MiniCodable:Decodable, ResponseEncodable {
let number: Int
let phrase: String?
}
@Test func testEncodable() async throws {
let app = try await buildApplication(TestArguments())
try await app.test(.router) { client in
let _ = try await client.execute(uri: "/encodable", method: .get) { response in
#expect(response.status == .ok)
//print(String(buffer: response.body))
#expect(String(buffer: response.body) == "{\"number\":5,\"phrase\":\"hello!\"}")
}
}
}
@Test func testDecoding() async throws {
let app = try await buildApplication(TestArguments())
try await app.test(.router) { client in
let _ = try await client.execute(uri: "/decodable/default", method: .post) { response in
#expect(response.status == .badRequest, "not the status expected from an empty post")
}
let codedData = MiniCodable(number: 34, phrase:"something to say")
var buffer = try JSONEncoder().encodeAsByteBuffer(codedData, allocator: ByteBufferAllocator())
// print("coded: \(String(buffer:buffer))")
// var buffer = ByteBuffer(string: "{\"phrase\":\"something to say\",\"number\":34}" )
let _ = try await client.execute(uri: "/decodable/default", method: .post, body:buffer) { response in
#expect(response.status == .ok)
print("responseBody default: \(String(buffer: response.body))")
//json shows up in arbitrary order, so check for values individually.
//responseBody looks like
//"DECODED: MiniCodable(number: 34, phrase: Optional("something to say"))").contains("\"number\":\(codedData.number)"
#expect(String(buffer: response.body).contains("number: \(codedData.number)"))
#expect(String(buffer: response.body).contains("phrase: \"Optional(\(codedData.phrase!))\""))
}
buffer = ByteBuffer(string: "number=\(codedData.number)&phrase=\(codedData.phrase!)")
let _ = try await client.execute(uri: "/decodable/form",
method: .post,
//need header if detecting based on header, which not yet.
//headers: [.contentType: "application/x-www-form-urlencoded"],
body:buffer) { response in
#expect(response.status == .ok)
print("responseBody form: \(String(buffer: response.body))")
#expect(String(buffer: response.body).contains("number: \(codedData.number)"))
#expect(String(buffer: response.body).contains("phrase: \"Optional(\(codedData.phrase!))\""))
}
}
}
Summary
This post just barely scratches the surface of how to test a Hummingbird server. Also all the data being produced is fairly simple text or JSON… what if I wanted to serve a NOT static HTML page? That’s next.