Vapor 大型教程
为认证更新测试用例
接口加上认证后,原本的单元测试会全部失败。本文更新测试辅助方法和用例,让带 token 的请求也能被自动化测试覆盖。
先更新测试数据与辅助方法
创建用户时去掉明文密码相关断言,并让查询返回的是 User.Public:
func testUserCanBeSavedWithAPI() throws {
let user = User(name: usersName, username: usersUsername, password: "password")
...
}
func testUsersCanBeRetrievedFromAPI() throws {
...
// 发请求并获得响应
let users = try app.getResponse(to: usersURI, decodeTo: [User.Public].self)
...
}
给 User 增加一个测试用的 create 工厂方法,自动用 BCrypt 把密码哈希后再落库:
import Crypto
extension User {
static func create(
name: String = "Luke",
username: String? = nil,
on connection: PostgreSQLConnection) throws -> User {
var createUsername: String
if let suppliedUsername = username {
createUsername = suppliedUsername
} else {
createUsername = UUID().uuidString
}
let password = try BCrypt.hash("password")
let user = User(name: name, username: createUsername, password: password)
return try user.save(on: connection).wait()
}
}
重置测试数据库
改完模型后,重置 docker 测试数据库让结构生效:
$ docker stop postgres-test
$ docker rm postgres-test
$ docker run --name postgres-test -e POSTGRES_DB=vapor-test \
-e POSTGRES_USER=vapor -e POSTGRES_PASSWORD=password \
-p 5433:5432 -d postgres
现在直接跑测试会崩溃,因为那些需要认证的 API 调用会失败:请求没有带 token,被 guard 中间件拦下了。
给测试辅助加上登录逻辑
在 Application+testable.swift 里扩展 sendRequest 与 getResponse,当传入 loggedInRequest 或 loggedInUser 时,先以 Basic 方式请求登录接口拿到 token,再把 Bearer <token> 写进请求头:
import Authentication
extension Application {
...
func sendRequest<T>(
to path: String,
method: HTTPMethod,
headers: HTTPHeaders = .init(),
body: T? = nil,
loggedInRequest: Bool = false,
loggedInUser: User? = nil
) throws -> Response where T: Content {
var headers = headers
if loggedInRequest || loggedInUser != nil {
let username: String
if let user = loggedInUser {
username = user.username
} else {
username = "admin"
}
let credentials = BasicAuthorization(username: username, password: "password")
var tokenHeaders = HTTPHeaders()
tokenHeaders.basicAuthorization = credentials
let tokenResponse = try self.sendRequest(to: "/api/users/login", method: .POST, headers: tokenHeaders)
let token = try tokenResponse.content.syncDecode(Token.self)
headers.add(name: .authorization, value: "Bearer \(token.token)")
}
// 创建一个和发送到 app 的 HTTP 请求
let request = HTTPRequest(method: method,
url: URL(string: path)!,
headers: headers)
let wrappedRequest = Request(http: request, using: self)
if let body = body {
try wrappedRequest.content.encode(body)
}
// 因为 app 本身没有正式运行,所以这里手动进行响应
let responder = try self.make(Responder.self)
return try responder.respond(to: wrappedRequest).wait()
}
func sendRequest(
to path: String,
method: HTTPMethod,
headers: HTTPHeaders = .init(),
loggedInRequest: Bool = false,
loggedInUser: User? = nil
) throws -> Response {
let emptyContent: EmptyContent? = nil
return try sendRequest(to: path,
method: method,
headers: headers,
body: emptyContent,
loggedInRequest:loggedInRequest,
loggedInUser: loggedInUser)
}
func sendRequest<T>(
to path: String,
method: HTTPMethod,
headers: HTTPHeaders,
data: T,
loggedInRequest: Bool = false,
loggedInUser: User? = nil
) throws where T: Content {
_ = try sendRequest(to: path,
method: method,
headers: headers,
body: data,
loggedInRequest: loggedInRequest,
loggedInUser: loggedInUser)
}
func getResponse<C, T>(
to path: String,
method: HTTPMethod = .GET,
headers: HTTPHeaders = .init(),
data: C? = nil,
decodeTo type: T.Type,
loggedInRequest: Bool = false,
loggedInUser: User? = nil
) throws -> T where C: Content, T: Decodable {
let response = try self.sendRequest(to: path,
method: method,
headers: headers,
body: data,
loggedInRequest: loggedInRequest,
loggedInUser: loggedInUser)
// 从响应数据中解析出用户数据,也即从数据库中检索出来的用户数据
return try response.content.decode(type).wait()
}
func getResponse<T> (
to path: String,
method: HTTPMethod = .GET,
headers: HTTPHeaders = .init(),
decodeTo type: T.Type,
loggedInRequest: Bool = false,
loggedInUser: User? = nil
) throws -> T where T: Decodable {
let emptyContent: EmptyContent? = nil
return try self.getResponse(to: path,
method: method,
headers: headers,
data: emptyContent,
decodeTo: type,
loggedInRequest: loggedInRequest,
loggedInUser: loggedInUser)
}
}
更新缩略语测试
创建、更新、删除等写操作都带上 loggedInRequest: true 或指定 loggedInUser:
func testAcronymCanBeSavedWithAPI() throws {
...
let receivedAcronym = try app.getResponse(to: acronymsURI, method: .POST, headers: ["Content-Type": "application/json"], data: acronym, decodeTo: Acronym.self, loggedInRequest: true)
...
}
func testUpdatingAnAcronym() throws {
...
try app.sendRequest(to: "\(acronymsURI)\(acronym.id!)", method: .PUT, headers: ["Content-Type": "application/json"], data: updatedAcronym, loggedInUser: newUser)
...
}
func testDeletingAnAcronym() throws {
...
_ = try app.sendRequest(to: "\(acronymsURI)\(acronym.id!)", method: .DELETE, loggedInRequest: true)
...
}
func testGettingAnAcronymsUser() throws {
...
let acronymsUser = try app.getResponse(to: "\(acronymsURI)\(acronym.id!)/user", decodeTo: User.Public.self)
...
}
func testAcronymsCategories() throws {
...
let categories = try app.getResponse(to: "\(acronymsURI)\(acronym.id!)/categories", decodeTo: [App.Category].self, loggedInRequest: true)
...
_ = try app.sendRequest(to: "\(acronymsURI)\(acronym.id!)/categories/\(category.id!)", method: .DELETE)
let newCategories = try app.getResponse(to: "\(acronymsURI)\(acronym.id!)/categories", decodeTo: [App.Category].self, loggedInRequest: true)
...
}
func testAcronymsCategories() throws {
...
let request1URL = "\(acronymsURI)\(acronym.id!)/categories/\(category.id!)"
_ = try app.sendRequest(to: request1URL,method: .POST,loggedInRequest: true)
let request2URL = "\(acronymsURI)\(acronym.id!)/categories/\(category2.id!)"
_ = try app.sendRequest(to: request2URL,method: .POST,loggedInRequest: true)
let categories = try app.getResponse(to: "\(acronymsURI)\(acronym.id!)/categories", decodeTo: [App.Category].self)
XCTAssertEqual(categories.count, 2)
XCTAssertEqual(categories[0].id, category.id)
XCTAssertEqual(categories[0].name, category.name)
XCTAssertEqual(categories[1].id, category2.id)
XCTAssertEqual(categories[1].name, category2.name)
let request3URL = "\(acronymsURI)\(acronym.id!)/categories/\(category.id!)"
_ = try app.sendRequest(to: request3URL,method: .DELETE,loggedInRequest: true)
...
}
更新分类测试与用户测试
func testCategoryCanBeSavedWithAPI() throws {
...
let receivedCategory = try app.getResponse(to: categoriesURI, method: .POST, headers: ["Content-Type": "application/json"], data: category, decodeTo: Category.self, loggedInRequest:true)
...
}
func testGettingACategoriesAcronymsFromTheAPI() throws {
...
let acronym1URL = "/api/acronyms/\(acronym.id!)/categories/\(category.id!)"
_ = try app.sendRequest(to: acronym1URL, method: .POST,loggedInRequest:true)
let acronym2URL = "/api/acronyms/\(acronym2.id!)/categories/\(category.id!)"
_ = try app.sendRequest(to: acronym2URL, method: .POST, loggedInRequest: true)
let acronyms = try app.getResponse(to: "\(categoriesURI)\(category.id!)/acronyms", decodeTo: [Acronym].self, loggedInRequest: true
...
}
func testUsersCanBeRetrievedFromAPI() throws {
...
// 发请求并获得响应
let users = try app.getResponse(to: usersURI, decodeTo: [User.Public].self)
...
// 考虑到有一个管理员帐户
XCTAssertEqual(users.count, 3)
XCTAssertEqual(users[1].name, usersName)
XCTAssertEqual(users[1].username, usersUsername)
XCTAssertEqual(users[1].id, user.id)
}
func testUserCanBeSavedWithAPI() throws {
let user = User(name: usersName, username: usersUsername, password: "password")
let receivedUser = try app.getResponse(to: usersURI,
method: .POST,
headers: ["Content-Type":"application/json"],
data: user,
decodeTo: User.Public.self,
loggedInRequest: true)
...
let users = try app.getResponse(to: usersURI,
decodeTo: [User.Public].self)
...
// 考虑到有一个管理员帐户
XCTAssertEqual(users.count, 2)
XCTAssertEqual(users[1].name, usersName)
XCTAssertEqual(users[1].username, usersUsername)
XCTAssertEqual(users[1].id, receivedUser.id)
}
func testGettingASingleUserFromTheAPI() throws {
...
let receivedUser = try app.getResponse(to: "\(usersURI)\(user.id!)", decodeTo: User.Public.self)
...
}
修改完之后一定要把单元测试完整跑一遍,确保所有受保护的接口在带 token 的情况下都能正常工作。