
Vapor 教程系列 - 04 Web API 测试与稳定性验证
上一篇文章我们完成了模型关系的实现和相关 API 的开发,相信你已经掌握了如何构建具有完整数据关系的 Web API。现在我们面临一个新的挑战:如何确保这些 API 能稳定可靠地工作? 🤔 本文将带你学习如何为 TIL 应用编写和运行测试,确保代码质量和接口稳定性。
- 第一部分:构建 Web API
- 创建项目和设置数据库与路由
- 实现基本 CRUD 操作与控制器封装
- 进阶数据库模型与 API 实现
- Web API 测试与稳定性验证
- 异步编程与全面错误处理
- 第二部分:制作简单的前端网页应用
- 第三部分:数据校验、用户验证和授权
- 第四部分:Vapor 的进阶使用
- 第五部分:生产部署
上一篇文章我们完成了模型关系的实现和相关 API 的开发,相信你已经掌握了如何构建具有完整数据关系的 Web API。现在我们面临一个新的挑战:如何确保这些 API 能稳定可靠地工作? 🤔 本文将带你学习如何为 TIL 应用编写和运行测试,确保代码质量和接口稳定性。
为什么要写测试? 🎯
在开始写测试代码之前,我们先思考一个问题:为什么要写测试?想象一下以下场景:
- 你刚刚修改了一段处理用户数据的代码,但不确定是否会影响到其他功能 😰
- 你想重构某个 API 的实现,但担心会破坏现有的功能 😱
- 你的应用即将发布,需要确保所有功能都能正常工作 🚀
这些场景都需要反复测试应用的功能。如果每次都手动测试,不仅耗时而且容易遗漏。而自动化测试可以帮助我们:
- ✅ 快速验证代码修改是否影响现有功能
- 🔄 支持频繁迭代和重构
- 💪 增强代码质量和可维护性
- 📝 通过测试用例记录 API 的预期行为
配置测试环境 ⚙️
测试依赖
首先需要看一下 Package.swift 中是否添加测试所需的依赖。Vapor 提供了 XCTVapor 模块用于测试,它基于 XCTest 框架并提供了一些便捷的测试工具。打开 Package.swift,可以找到 testTarget 部分,一般不需要我们添加:
1 2 3 4 5 6.testTarget(name: "AppTests", dependencies: [ .target(name: "App"), .product(name: "XCTVapor", package: "vapor") ], swiftSettings: swiftSettings )
配置测试数据库
每次运行测试都需要一个干净的数据库环境。我们使用 Docker 启动一个独立的测试数据库:
1 2 3 4 5docker run --name postgres-test \ -e POSTGRES_DB=vapor-test \ -e POSTGRES_USER=vapor_username \ -e POSTGRES_PASSWORD=vapor_password \ -p 5434:5432 -d postgres
其中:
vapor_username和vapor_password继续使用之前的即可;- 端口
5434这个可以设置为其它,只要是端口没有被其它服务占用就好。
然后修改 configure.swift ,添加测试环境的配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37// configures your application public func configure(_ app: Application) async throws { // uncomment to serve files from /Public folder // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) let databaseName: String let databasePort: Int if app.environment == .testing { // 要与测试数据库端口一致 databasePort = 5434 databaseName = "vapor-test" } else { databasePort = Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber databaseName = Environment.get("DATABASE_NAME") ?? "vapor_database" } app.databases.use( DatabaseConfigurationFactory.postgres( configuration: .init( hostname: Environment.get("DATABASE_HOST") ?? "localhost", port: databasePort, username: Environment.get("DATABASE_USERNAME") ?? "vapor_username", password: Environment.get("DATABASE_PASSWORD") ?? "vapor_password", database: databaseName, tls: .prefer(try .init(configuration: .clientDefault)) ) ), as: .psql ) app.migrations.add(CreateUser()) app.migrations.add(CreateAcronym()) app.migrations.add(CreateCategory()) app.migrations.add(CreateAcronymCategoryPivot()) // register routes try routes(app) }
优化测试代码结构 🏗
为了让测试代码更简洁易用,我们先创建一些辅助方法。在 Tests/AppTests 目录下创建以下文件:
Models+Testable.swift:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36@testable import App import Fluent extension User { static func create( name: String = "Luke", username: String = "lukes", on database: Database ) async throws -> User { let user = User(name: name, username: username) try await user.save(on: database) return user } } extension Acronym { static func create( short: String = "TIL", long: String = "Today I Learned", user: User? = nil, on database: Database ) async throws -> Acronym { var acronymsUser = user if acronymsUser == nil { acronymsUser = try await User.create(on: database) } let acronym = Acronym( short: short, long: long, userID: acronymsUser!.id!) try await acronym.save(on: database) return acronym } }
有了这些辅助方法,我们就可以更优雅地编写测试用例了。
编写基础的 API 测试 🧪
让我们从测试 Acronym 的基本 CRUD 操作开始。创建 Tests/AppTests/AcronymTests.swift:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76@testable import App import XCTVapor import Fluent final class AcronymTests: XCTestCase { let acronymsURI = "/api/acronyms/" let acronymShort = "OMG" let acronymLong = "Oh My God" var app: Application! override func setUp() async throws { self.app = try await Application.make(.testing) try await configure(app) try await app.autoMigrate() } override func tearDown() async throws { try await app.autoRevert() try await self.app.asyncShutdown() self.app = nil } func testAcronymsCanBeRetrievedFromAPI() async throws { // 创建测试数据 let acronym = try await Acronym.create( short: acronymShort, long: acronymLong, on: app.db) // 测试 GET 请求 try await app.test(.GET, acronymsURI, afterResponse: { res async throws in XCTAssertEqual(res.status, .ok) let acronyms = try res.content.decode([Acronym].self) XCTAssertEqual(acronyms.count, 1) XCTAssertEqual(acronyms[0].short, acronymShort) XCTAssertEqual(acronyms[0].long, acronymLong) XCTAssertEqual(acronyms[0].id, acronym.id) }) } func testCreateAcronym() async throws { let user = try await User.create(on: app.db) let createData = AcronymCreateData( short: acronymShort, long: acronymLong, userID: user.id!) try await app.test(.POST, acronymsURI, beforeRequest: { req async throws in try req.content.encode(createData) }, afterResponse: { res async throws in XCTAssertEqual(res.status, .ok) let received = try res.content.decode(Acronym.self) XCTAssertEqual(received.short, acronymShort) XCTAssertEqual(received.long, acronymLong) XCTAssertNotNil(received.id) // 验证数据是否保存到数据库 let savedAcronym = try await Acronym.find(received.id, on: app.db) XCTAssertNotNil(savedAcronym) XCTAssertEqual(savedAcronym?.short, acronymShort) }) } func testDeleteAcronym() async throws { let acronym = try await Acronym.create(on: app.db) try await app.test(.DELETE, "\(acronymsURI)\(acronym.id!)") { res async throws in XCTAssertEqual(res.status, .noContent) // 验证数据是否从数据库中删除 let deletedAcronym = try await Acronym.find(acronym.id, on: app.db) XCTAssertNil(deletedAcronym) } } }
有了这些辅助方法,我们就可以更优雅地编写测试用例了。
编写基础的 API 测试 🧪
让我们从测试 Acronym 的基本 CRUD 操作开始。创建 Tests/AppTests/AcronymTests.swift:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76@testable import App import XCTVapor import Fluent final class AcronymTests: XCTestCase { let acronymsURI = "/api/acronyms/" let acronymShort = "OMG" let acronymLong = "Oh My God" var app: Application! override func setUp() async throws { self.app = try await Application.make(.testing) try await configure(app) try await app.autoMigrate() } override func tearDown() async throws { try await app.autoRevert() try await self.app.asyncShutdown() self.app = nil } func testAcronymsCanBeRetrievedFromAPI() async throws { // 创建测试数据 let acronym = try await Acronym.create( short: acronymShort, long: acronymLong, on: app.db) // 测试 GET 请求 try await app.test(.GET, acronymsURI, afterResponse: { res async throws in XCTAssertEqual(res.status, .ok) let acronyms = try res.content.decode([Acronym].self) XCTAssertEqual(acronyms.count, 1) XCTAssertEqual(acronyms[0].short, acronymShort) XCTAssertEqual(acronyms[0].long, acronymLong) XCTAssertEqual(acronyms[0].id, acronym.id) }) } func testCreateAcronym() async throws { let user = try await User.create(on: app.db) let createData = AcronymCreateData( short: acronymShort, long: acronymLong, userID: user.id!) try await app.test(.POST, acronymsURI, beforeRequest: { req async throws in try req.content.encode(createData) }, afterResponse: { res async throws in XCTAssertEqual(res.status, .ok) let received = try res.content.decode(Acronym.self) XCTAssertEqual(received.short, acronymShort) XCTAssertEqual(received.long, acronymLong) XCTAssertNotNil(received.id) // 验证数据是否保存到数据库 let savedAcronym = try await Acronym.find(received.id, on: app.db) XCTAssertNotNil(savedAcronym) XCTAssertEqual(savedAcronym?.short, acronymShort) }) } func testDeleteAcronym() async throws { let acronym = try await Acronym.create(on: app.db) try await app.test(.DELETE, "\(acronymsURI)\(acronym.id!)") { res async throws in XCTAssertEqual(res.status, .noContent) // 验证数据是否从数据库中删除 let deletedAcronym = try await Acronym.find(acronym.id, on: app.db) XCTAssertNil(deletedAcronym) } } }
添加完代码后我们可以先运行测试一下试试效果:
- 找到编辑器——测试;
- 鼠标悬停在 AppTests,选择 Run Test 按钮。
正常的话就会看到图片右下角运行测试通过的提示文本。
测试模型关系 🔗
接下来测试模型之间的关联关系,我们这里测试的是 User 和 Acronym 的关系。在 AcronymTests 中添加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42extension AcronymTests { func testGetAcronymUser() async throws { let user = try await User.create( name: "Alice", username: "alice", on: app.db) let acronym = try await Acronym.create( short: acronymShort, long: acronymLong, user: user, on: app.db) try await app.test(.GET, "\(acronymsURI)\(acronym.id!)/user") { res async throws in let acronymUser = try res.content.decode(User.self) XCTAssertEqual(acronymUser.id, user.id) XCTAssertEqual(acronymUser.name, "Alice") XCTAssertEqual(acronymUser.username, "alice") } } func testGetUserAcronyms() async throws { let user = try await User.create(on: app.db) let acronym1 = try await Acronym.create( short: acronymShort, long: acronymLong, user: user, on: app.db) _ = try await Acronym.create( short: "LOL", long: "Laugh Out Loud", user: user, on: app.db) try await app.test(.GET, "/api/users/\(user.id!)/acronyms") { res async throws in let acronyms = try res.content.decode([Acronym].self) XCTAssertEqual(acronyms.count, 2) XCTAssertEqual(acronyms[0].id, acronym1.id) XCTAssertEqual(acronyms[0].short, acronymShort) XCTAssertEqual(acronyms[0].long, acronymLong) } } }
编写单元测试其实是比较枯燥的工作,目前 AI 撰写单元测试的能力已经很强了,经过测试,完全可以帮我们撰写 Vapor 的测试代码,使用 Github Copilot Edits 或者 Cursor 的 composer 都有很棒的效果。
小结 📝
本文我们学习了:
- 为什么测试对于 Web API 开发很重要
- 如何配置测试环境和数据库
- 如何编写和组织测试代码
- 如何测试基本的 CRUD 操作
- 如何测试模型之间的关联关系
通过编写完善的测试,我们可以:
- ✅ 确保 API 功能符合预期
- 🔄 安全地重构和优化代码
- 💪 提高代码质量
- 📝 为 API 提供活的文档
记住,"测试是开发的好朋友,不是累赘。" 希望通过本文的学习,你能够建立起对测试的正确认识,并在实际开发中灵活运用测试来提升代码质量! 💪
推荐阅读
源码获取
本文代码:s1-4.zip
下一篇文章我们将一起探索异步编程与错误处理,敬请期待! 👋



