第11章:测试¶
测试是软件开发过程中的一个重要部分。编写单元测试并尽可能地使其自动化,可以使你快速开发和发展你的应用程序。
在本章中,你将学习如何为你的Vapor
应用程序编写测试。你将了解为什么测试很重要,以及它如何与Swift Package Manager
一起工作。接下来,你将学习如何为前几章中的TIL
应用程序编写测试。最后,你会看到为什么测试在Linux
上很重要,以及如何使用Docker
在Linux
上测试你的代码。
为什么要写测试?¶
软件测试与软件开发本身一样古老。现代的服务器应用程序每天都要部署很多次,所以确保一切按预期工作是很重要的。为你的应用程序编写测试,使你相信代码是健全的。
当你重构你的代码时,测试也给你带来信心。在过去的几章中,你已经发展和改变了TIL
应用程序。手动测试应用程序的每一个部分是缓慢而费力的,而且这个应用程序很小 为了快速开发新的功能,你要确保现有的功能不被破坏。有一个广泛的测试集,可以让你在修改代码时验证所有的东西仍然可以工作。
测试也可以帮助你设计你的代码。测试驱动开发是一个流行的开发过程,在写代码之前先写测试。这有助于确保你对你的代码有充分的测试覆盖。测试驱动开发也有助于你设计你的代码和API
。
用SwiftPM
编写测试¶
在iOS
上,Xcode
将测试链接到一个特定的测试目标。Xcode
配置了一个方案来使用该目标,你可以在Xcode
中运行你的测试。Objective-C
运行时扫描你的XCTestCase
并挑选出名称以test
开头的方法。在Linux
和SwiftPM
上,没有Objective-C
运行时间。也没有Xcode
项目来记住方案和测试的位置。
在Xcode
中,打开Package.swift
。在targets
数组中定义了一个测试目标:
.testTarget(name: "AppTests", dependencies: [
.target(name: "App"),
.product(name: "XCTVapor", package: "vapor"),
])
这定义了一个testTarget
类型,依赖于App
和Vapor
的XCTVapor
。测试必须住在Tests/
目录下。在这个例子中,就是Tests/AppTests
。
Xcode
创建了TILApp
方案并将AppTests
作为测试目标添加到该方案中。你可以用Command-U
或Product ▸ Test
正常运行这些测试:
测试用户¶
编写你的第一个测试¶
在Tests/AppTests
中创建一个新文件,名为UserTests.swift
。这个文件将包含所有与用户有关的测试。打开这个新文件并插入以下内容:
@testable import App
import XCTVapor
final class UserTests: XCTestCase {
}
这将创建XCTestCase
,你将用来测试你的用户,并导入必要的模块以使一切工作。
接下来,在UserTests
中添加以下内容,测试从API
中获取用户:
func testUsersCanBeRetrievedFromAPI() throws {
// 1
let expectedName = "Alice"
let expectedUsername = "alice"
// 2
let app = Application(.testing)
// 3
defer { app.shutdown() }
// 4
try configure(app)
// 5
let user = User(
name: expectedName,
username: expectedUsername)
try user.save(on: app.db).wait()
try User(name: "Luke", username: "lukes")
.save(on: app.db)
.wait()
// 6
try app.test(.GET, "/api/users", afterResponse: { response in
// 7
XCTAssertEqual(response.status, .ok)
// 8
let users = try response.content.decode([User].self)
// 9
XCTAssertEqual(users.count, 2)
XCTAssertEqual(users[0].name, expectedName)
XCTAssertEqual(users[0].username, expectedUsername)
XCTAssertEqual(users[0].id, user.id)
})
}
在这个测试中,有很多事情要做,下面是详细的情况:
- 为测试定义一些预期值:一个用户的名字和用户名。
- 创建一个
Application
,类似于main.swift
。这将创建一个完整的Application
对象,但不会开始运行该应用程序。注意,你在这里使用的是.testing
环境。 - 在测试结束时关闭应用程序。这可以确保你正确地关闭数据库连接并清理事件循环。
- 为测试配置你的应用程序。这有助于确保你正确配置你的真实应用程序,因为你的测试调用了相同的
configure(_:)
。 - 使用应用程序的数据库对象,创建几个用户并将其保存在数据库中。
- 使用
XCTVapor
-Vapor
的测试模块 - 向/api/users
发送GET
请求。使用XCTVapor
,你指定一个路径和HTTP
方法。XCTVapor
还允许你在发送请求前和收到响应后提供闭包运行。 - 确保收到的响应包含预期的状态代码。
- 将响应体解码为一个
User
数组。 - 确保响应中有正确数量的用户,并且第一个用户与测试开始时创建的用户一致。
接下来,你必须更新你的应用程序的配置以支持测试。打开configure.swift
,在app.databases.use
前添加以下内容:
let databaseName: String
let databasePort: Int
// 1
if (app.environment == .testing) {
databaseName = "vapor-test"
databasePort = 5433
} else {
databaseName = "vapor_database"
databasePort = 5432
}
这将根据环境的不同为数据库名称和端口设置属性。你将使用不同的名称和端口来测试和运行应用程序。接下来,把对app.databases.use
的调用替换为以下内容:
app.databases.use(.postgres(
hostname: Environment.get("DATABASE_HOST")
?? "localhost",
port: databasePort,
username: Environment.get("DATABASE_USERNAME")
?? "vapor_username",
password: Environment.get("DATABASE_PASSWORD")
?? "vapor_password",
database: Environment.get("DATABASE_NAME")
?? databaseName
), as: .psql)
如果你不提供环境变量,这将从上面设置的属性中设置数据库端口和名称。这些变化允许你在生产数据库以外的数据库上运行你的测试。这可以确保你在一个已知的状态下开始每个测试,不破坏实时数据。由于你使用Docker
来托管你的数据库,在同一台机器上设置另一个数据库很简单。在终端,输入以下内容:
docker run --name postgres-test \
-e POSTGRES_DB=vapor-test \
-e POSTGRES_USER=vapor_username \
-e POSTGRES_PASSWORD=vapor_password \
-p 5433:5432 -d postgres
这与你在第6章"配置数据库"中使用的命令相似,但它改变了容器名称和数据库名称。Docker
容器也被映射到主机端口5433,以避免与现有数据库冲突。
运行这些测试,它们应该通过。然而,如果你再次运行测试,它们会失败。第一次测试运行在数据库中添加了两个用户,第二次测试运行现在有四个用户,因为数据库没有被重置。
打开UserTests.swift
,在try configure(app)
后面添加以下内容:
try app.autoRevert().wait()
try app.autoMigrate().wait()
这增加了一些命令来恢复数据库中的任何迁移,然后再次运行迁移。这为你提供了一个干净的数据库来进行每个测试。
再次建立并运行测试,这次他们会通过的!
测试扩展¶
第一个测试包含很多所有测试都需要的代码。提取共同的部分,使测试更容易阅读,并简化未来的测试。在Tests/AppTests
中为这些扩展之一创建一个新文件,称为Application+Testable.swift
。打开新文件并添加以下内容:
import XCTVapor
import App
extension Application {
static func testable() throws -> Application {
let app = Application(.testing)
try configure(app)
try app.autoRevert().wait()
try app.autoMigrate().wait()
return app
}
}
这个函数允许你创建一个可测试的Application
对象,配置它并设置数据库。接下来,在Tests/AppTests
中创建一个新文件,名为Models+Testable.swift
。打开新文件并创建一个扩展名来创建一个User
:
@testable import App
import Fluent
extension User {
static func create(
name: String = "Luke",
username: String = "lukes",
on database: Database
) throws -> User {
let user = User(name: name, username: username)
try user.save(on: database).wait()
return user
}
}
这个函数在数据库中保存一个用户,该用户是根据所提供的详细资料创建的。它有默认值,所以如果你不关心它们,就不必提供任何值。
在创建了所有这些之后,你现在可以重写你的用户测试。打开UserTests.swift
,删除testUsersCanBeRetrievedFromAPI()
。
接下来,在UserTests
中为所有的测试创建公共属性:
let usersName = "Alice"
let usersUsername = "alicea"
let usersURI = "/api/users/"
var app: Application!
接下来实现setUpWithError()
来运行每个测试前必须执行的代码:
override func setUpWithError() throws {
app = try Application.testable()
}
这就为测试创建了一个Application
,也重置了数据库。
接下来,实现tearDownWithError()
来关闭应用程序:
override func tearDownWithError() throws {
app.shutdown()
}
最后,重写testUsersCanBeRetrievedFromAPI()
以使用所有新的辅助方法:
func testUsersCanBeRetrievedFromAPI() throws {
let user = try User.create(
name: usersName,
username: usersUsername,
on: app.db)
_ = try User.create(on: app.db)
try app.test(.GET, usersURI, afterResponse: { response in
XCTAssertEqual(response.status, .ok)
let users = try response.content.decode([User].self)
XCTAssertEqual(users.count, 2)
XCTAssertEqual(users[0].name, usersName)
XCTAssertEqual(users[0].username, usersUsername)
XCTAssertEqual(users[0].id, user.id)
})
}
这个测试与之前的测试完全一样,但更具有可读性。它也使接下来的测试更容易写。再次运行测试以确保它们仍然工作。
测试用户API
¶
打开UserTests.swift
,使用测试助手方法添加以下内容,测试通过API
保存用户:
func testUserCanBeSavedWithAPI() throws {
// 1
let user = User(name: usersName, username: usersUsername)
// 2
try app.test(.POST, usersURI, beforeRequest: { req in
// 3
try req.content.encode(user)
}, afterResponse: { response in
// 4
let receivedUser = try response.content.decode(User.self)
// 5
XCTAssertEqual(receivedUser.name, usersName)
XCTAssertEqual(receivedUser.username, usersUsername)
XCTAssertNotNil(receivedUser.id)
// 6
try app.test(.GET, usersURI,
afterResponse: { secondResponse in
// 7
let users =
try secondResponse.content.decode([User].self)
XCTAssertEqual(users.count, 1)
XCTAssertEqual(users[0].name, usersName)
XCTAssertEqual(users[0].username, usersUsername)
XCTAssertEqual(users[0].id, receivedUser.id)
})
})
}
以下是该测试的内容:
- 创建一个具有已知值的
User
对象。 - 使用
test(_:_:beforeRequest:afterResponse:)
向API
发送一个POST
请求。 - 在发送请求之前,用创建的用户对请求进行编码。
- 将响应体解码为
User
对象。 - 断言来自
API
的响应与预期值相匹配。 - 发出另一个请求,从
API
中获取所有的用户。 - 确保响应只包含你在第一个请求中创建的用户。
运行测试以确保新的测试工作!
接下来,添加下面的测试,从API
中获取一个用户:
func testGettingASingleUserFromTheAPI() throws {
// 1
let user = try User.create(
name: usersName,
username: usersUsername,
on: app.db)
// 2
try app.test(.GET, "\(usersURI)\(user.id!)",
afterResponse: { response in
let receivedUser = try response.content.decode(User.self)
// 3
XCTAssertEqual(receivedUser.name, usersName)
XCTAssertEqual(receivedUser.username, usersUsername)
XCTAssertEqual(receivedUser.id, user.id)
})
}
以下是该测试的内容:
- 在数据库中保存一个已知值的用户。
- 在
/api/users/<USER ID>
处获取该用户。 - 断言这些值与创建用户时提供的值相同。
测试用户API的最后一部分是检索用户的首字母缩写。打开Models+Testable.swift
,在文件的末尾,创建一个新的扩展名来创建缩写:
extension Acronym {
static func create(
short: String = "TIL",
long: String = "Today I Learned",
user: User? = nil,
on database: Database
) throws -> Acronym {
var acronymsUser = user
if acronymsUser == nil {
acronymsUser = try User.create(on: database)
}
let acronym = Acronym(
short: short,
long: long,
userID: acronymsUser!.id!)
try acronym.save(on: database).wait()
return acronym
}
}
这将创建一个首字母缩写,并将其与所提供的值一起保存在数据库中。如果你没有提供任何值,它就使用默认值。如果你没有为首字母缩写提供一个用户,它会先创建一个用户来使用。
接下来,打开UserTests.swift
,创建一个方法来测试获取用户的首字母缩写:
func testGettingAUsersAcronymsFromTheAPI() throws {
// 1
let user = try User.create(on: app.db)
// 2
let acronymShort = "OMG"
let acronymLong = "Oh My God"
// 3
let acronym1 = try Acronym.create(
short: acronymShort,
long: acronymLong,
user: user,
on: app.db)
_ = try Acronym.create(
short: "LOL",
long: "Laugh Out Loud",
user: user,
on: app.db)
// 4
try app.test(.GET, "\(usersURI)\(user.id!)/acronyms",
afterResponse: { response in
let acronyms = try response.content.decode([Acronym].self)
// 5
XCTAssertEqual(acronyms.count, 2)
XCTAssertEqual(acronyms[0].id, acronym1.id)
XCTAssertEqual(acronyms[0].short, acronymShort)
XCTAssertEqual(acronyms[0].long, acronymLong)
})
}
以下是该测试的内容:
- 为缩略语创建一个用户。
-
为首字母缩写定义一些预期值。
-
使用创建的用户在数据库中创建两个首字母缩写。对第一个首字母缩写使用预期值。
- 通过向
/api/users/<user ID>/acronyms
发送请求,从API
获得用户的首字母缩写。 - 断言响应返回正确数量的首字母缩写,并且第一个首字母缩写符合预期值。
运行测试,以确保变化的工作!
测试缩略语和类别¶
打开Models+Testable.swift
,在文件的底部,添加一个新的扩展名,以简化创建类别:
extension App.Category {
static func create(
name: String = "Random",
on database: Database
) throws -> App.Category {
let category = Category(name: name)
try category.save(on: database).wait()
return category
}
}
像其他模型辅助函数一样,create(name:on:)
将name
作为参数,在数据库中创建一个类别。缩略语API
和类别API
的测试是本章的启动项目的一部分。打开CategoryTests.swift
,取消对所有代码的注释。这些测试遵循与用户测试相同的模式。
打开AcronymTests.swift
并取消对所有代码的注释。这些测试也遵循与之前类似的模式,但有一些额外的测试是针对缩略语API
中的额外路线。这些测试包括更新首字母缩写、删除首字母缩写和不同的Fluent
查询路线。
运行所有的测试,确保它们都能工作。你应该有一片绿色的测试海洋,每条路由都被测试过了。
在Linux
上测试¶
在本章的前面,你了解到为什么测试你的应用程序很重要。对于服务器端的Swift
,在Linux
上的测试尤其重要。例如,当你把你的应用程序部署到Heroku
时,你将部署到一个不同于你用于开发的操作系统上。在与部署环境相同的环境中测试你的应用程序是至关重要的。
为什么会这样呢?Linux
上的Foundation
和macOS
上的Foundation
是不一样的。macOS
上的Foundation
仍然使用Objective-C
框架,该框架经过了多年的全面测试。Linux
使用的是纯粹的Swift Foundation
框架,它并没有经过那么多的测试。实施状态列表github.com/apple/swift-corelibs-foundation/blob/master/Docs/Status.md显示,许多功能在Linux
上仍未实现。如果你使用这些功能,你的应用程序可能会崩溃。虽然情况在不断改善,但你仍然必须确保一切都能在Linux
上按预期工作。
在Linux
上运行测试¶
在Linux
上运行测试需要你做一些与在macOS
上运行不同的事情。如前所述,Objective-C
的运行时间决定了你的XCTestCase
提供的测试方法。在Linux
上,没有运行时来做这个,所以你必须把Swift
指向正确的方向。Swift 5.1
引入了测试发现,它解析你的测试类来寻找要运行的测试。
当你在Linux
上调用swift test
时,你必须通过--enable-test-discovery
标志。
早期反馈在软件开发中总是有价值的,在Linux上运行测试也不例外。使用持续集成系统在Linux上自动测试是至关重要的,但如果你想在Mac
上测试Linux
,会发生什么?
好吧,你已经在使用Docker
为PostgreSQL
数据库运行Linux
了! 所以,你也可以使用Docker
在Linux
环境下运行你的测试。在项目目录中,创建一个名为testing.Dockerfile
的新文件。
在文本编辑器中打开该文件并添加以下内容:
# 1
FROM swift:5.2
# 2
WORKDIR /package
# 3
COPY . ./
# 4
CMD ["swift", "test", "--enable-test-discovery"]
以下是Dockerfile
的作用:
- 使用
Swift 5.2
的图像。 - 设置工作目录为
/package
。 - 将当前目录的内容复制到容器中的
/package
。 - 将默认命令设置为
swift test --enable-test-discovery
。这是Docker
在你运行Docker
文件时执行的命令。
测试需要一个PostgreSQL
数据库才能运行。默认情况下,Docker
容器不能看到对方。然而,Docker
有一个工具,Docker Compose
,旨在将不同的容器连接在一起,用于测试和运行应用程序。Vapor
已经为运行你的应用程序提供了一个compose
文件,但你将使用一个不同的文件进行测试。在项目目录中创建一个名为docker-compos-testing.yml
的新文件。
在编辑器中打开该文件,添加以下内容:
# 1
version: '3'
# 2
services:
# 3
til-app:
# 4
depends_on:
- postgres
# 5
build:
context: .
dockerfile: testing.Dockerfile
# 6
environment:
- DATABASE_HOST=postgres
- DATABASE_PORT=5432
# 7
postgres:
# 8
image: "postgres"
# 9
environment:
- POSTGRES_DB=vapor-test
- POSTGRES_USER=vapor_username
- POSTGRES_PASSWORD=vapor_password
下面是这个的作用:
- 指定
Docker Compose
的版本。 - 定义这个应用程序的服务。
-
为
TIL
应用程序定义一个服务。 -
设置对
Postgres
容器的依赖性,因此Docker Compose
首先启动Postgres
容器。 - 在当前目录下构建
testing.Dockerfile
--你之前创建的Dockerfile
。 - 注入
DATABASE_HOST
环境变量。Docker Compose
有一个内部DNS
解析器。这允许til-app
容器以postgres
主机名连接到postgres
容器。同时设置数据库的端口。 - 为
Postgres
容器定义一个服务。 - 使用标准的
Postgres
图像。 - 为测试数据库设置与本章开始时相同的环境变量。
最后在Xcode
中打开configure.swift
,允许将数据库端口设置为环境变量进行测试。替换:
if (app.environment == .testing) {
databaseName = "vapor-test"
databasePort = 5433
} else {
为以下内容:
if (app.environment == .testing) {
databaseName = "vapor-test"
if let testPort = Environment.get("DATABASE_PORT") {
databasePort = Int(testPort) ?? 5433
} else {
databasePort = 5433
}
} else {
如果设置了DATABASE_PORT
环境变量,这将使用该变量,否则默认端口为5433
。这允许你使用docker-compos-testing.yml
中设置的端口。要在Linux
中测试你的应用程序,打开终端并输入以下内容:
# 1
docker-compose -f docker-compose-testing.yml build
# 2
docker-compose -f docker-compose-testing.yml up \
--abort-on-container-exit
下面是这个的作用:
- 使用之前创建的
compose
文件构建不同的docker
容器。 - 从先前创建的
compose
文件旋转不同的容器,并运行测试。--abort-on-container-exit
告诉Docker Compose
在til-app
容器停止时停止postgres
容器。用于这个测试的postgres
容器与你在开发过程中使用的容器不同,也不冲突。
当测试完成后,你会在终端看到所有测试通过的输出:
接下来去哪?¶
在本章中,你学到了如何测试你的Vapor
应用程序,以确保它们能正常工作。为你的应用程序编写测试也意味着你可以在Linux
上运行这些测试。这使你相信你的应用程序在部署时能正常工作。拥有一个好的测试套件可以让你快速发展和调整你的应用程序。
Vapor
的架构对协议有很大的依赖性。这与Vapor
对Swift
扩展和可切换服务的使用相结合,使测试变得简单和可扩展。对于大型应用,你甚至可能想引入一个数据抽象层,这样你就不是在用一个真正的数据库进行测试。
这意味着你不必连接到数据库来测试你的主要逻辑,并会加快测试的速度。
定期运行你的测试是很重要的。使用持续集成(CI
)系统,如Jenkins
或GitHub Actions
,允许你测试每一次提交。
你还必须保持你的测试是最新的。在未来的章节中,如果行为发生了变化,比如引入了身份验证,你要改变测试以适应这些新功能。