跳转至

第11章:测试

测试是软件开发过程中的一个重要部分。编写单元测试并尽可能地使其自动化,可以使你快速开发和发展你的应用程序。

在本章中,你将学习如何为你的Vapor应用程序编写测试。你将了解为什么测试很重要,以及它如何与Swift Package Manager一起工作。接下来,你将学习如何为前几章中的TIL应用程序编写测试。最后,你会看到为什么测试在Linux上很重要,以及如何使用DockerLinux上测试你的代码。

为什么要写测试?

软件测试与软件开发本身一样古老。现代的服务器应用程序每天都要部署很多次,所以确保一切按预期工作是很重要的。为你的应用程序编写测试,使你相信代码是健全的。

当你重构你的代码时,测试也给你带来信心。在过去的几章中,你已经发展和改变了TIL应用程序。手动测试应用程序的每一个部分是缓慢而费力的,而且这个应用程序很小 为了快速开发新的功能,你要确保现有的功能不被破坏。有一个广泛的测试集,可以让你在修改代码时验证所有的东西仍然可以工作。

测试也可以帮助你设计你的代码。测试驱动开发是一个流行的开发过程,在写代码之前先写测试。这有助于确保你对你的代码有充分的测试覆盖。测试驱动开发也有助于你设计你的代码和API

SwiftPM编写测试

iOS上,Xcode将测试链接到一个特定的测试目标。Xcode配置了一个方案来使用该目标,你可以在Xcode中运行你的测试。Objective-C运行时扫描你的XCTestCase并挑选出名称以test开头的方法。在LinuxSwiftPM上,没有Objective-C运行时间。也没有Xcode项目来记住方案和测试的位置。

Xcode中,打开Package.swift。在targets数组中定义了一个测试目标:

.testTarget(name: "AppTests", dependencies: [
  .target(name: "App"),
  .product(name: "XCTVapor", package: "vapor"),
])

这定义了一个testTarget类型,依赖于AppVaporXCTVapor。测试必须住在Tests/目录下。在这个例子中,就是Tests/AppTests

Xcode创建了TILApp方案并将AppTests作为测试目标添加到该方案中。你可以用Command-UProduct ▸ Test正常运行这些测试:

img

测试用户

编写你的第一个测试

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)
  })
}

在这个测试中,有很多事情要做,下面是详细的情况:

  1. 为测试定义一些预期值:一个用户的名字和用户名。
  2. 创建一个Application,类似于main.swift。这将创建一个完整的Application对象,但不会开始运行该应用程序。注意,你在这里使用的是.testing环境。
  3. 在测试结束时关闭应用程序。这可以确保你正确地关闭数据库连接并清理事件循环。
  4. 为测试配置你的应用程序。这有助于确保你正确配置你的真实应用程序,因为你的测试调用了相同的configure(_:)
  5. 使用应用程序的数据库对象,创建几个用户并将其保存在数据库中。
  6. 使用XCTVapor - Vapor的测试模块 - 向/api/users发送GET请求。使用XCTVapor,你指定一个路径和HTTP方法。XCTVapor还允许你在发送请求前和收到响应后提供闭包运行。
  7. 确保收到的响应包含预期的状态代码。
  8. 将响应体解码为一个User数组。
  9. 确保响应中有正确数量的用户,并且第一个用户与测试开始时创建的用户一致。

接下来,你必须更新你的应用程序的配置以支持测试。打开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)
      })
  })
}

以下是该测试的内容:

  1. 创建一个具有已知值的User对象。
  2. 使用test(_:_:beforeRequest:afterResponse:)API发送一个POST请求。
  3. 在发送请求之前,用创建的用户对请求进行编码。
  4. 将响应体解码为User对象。
  5. 断言来自API的响应与预期值相匹配。
  6. 发出另一个请求,从API中获取所有的用户。
  7. 确保响应只包含你在第一个请求中创建的用户。

运行测试以确保新的测试工作!

接下来,添加下面的测试,从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)
    })
}

以下是该测试的内容:

  1. 在数据库中保存一个已知值的用户。
  2. /api/users/<USER ID>处获取该用户。
  3. 断言这些值与创建用户时提供的值相同。

测试用户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)
    })
}

以下是该测试的内容:

  1. 为缩略语创建一个用户。
  2. 为首字母缩写定义一些预期值。

  3. 使用创建的用户在数据库中创建两个首字母缩写。对第一个首字母缩写使用预期值。

  4. 通过向/api/users/<user ID>/acronyms发送请求,从API获得用户的首字母缩写。
  5. 断言响应返回正确数量的首字母缩写,并且第一个首字母缩写符合预期值。

运行测试,以确保变化的工作!

测试缩略语和类别

打开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查询路线。

运行所有的测试,确保它们都能工作。你应该有一片绿色的测试海洋,每条路由都被测试过了。

img

Linux上测试

在本章的前面,你了解到为什么测试你的应用程序很重要。对于服务器端的Swift,在Linux上的测试尤其重要。例如,当你把你的应用程序部署到Heroku时,你将部署到一个不同于你用于开发的操作系统上。在与部署环境相同的环境中测试你的应用程序是至关重要的。

为什么会这样呢?Linux上的FoundationmacOS上的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,会发生什么?

好吧,你已经在使用DockerPostgreSQL数据库运行Linux了! 所以,你也可以使用DockerLinux环境下运行你的测试。在项目目录中,创建一个名为testing.Dockerfile的新文件。

在文本编辑器中打开该文件并添加以下内容:

# 1
FROM swift:5.2

# 2
WORKDIR /package
# 3
COPY . ./
# 4
CMD ["swift", "test", "--enable-test-discovery"]

以下是Dockerfile的作用:

  1. 使用Swift 5.2的图像。
  2. 设置工作目录为/package
  3. 将当前目录的内容复制到容器中的/package
  4. 将默认命令设置为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

下面是这个的作用:

  1. 指定Docker Compose的版本。
  2. 定义这个应用程序的服务。
  3. TIL应用程序定义一个服务。

  4. 设置对Postgres容器的依赖性,因此Docker Compose首先启动Postgres容器。

  5. 在当前目录下构建testing.Dockerfile--你之前创建的Dockerfile
  6. 注入DATABASE_HOST环境变量。Docker Compose有一个内部DNS解析器。这允许til-app容器以postgres主机名连接到postgres容器。同时设置数据库的端口。
  7. Postgres容器定义一个服务。
  8. 使用标准的Postgres图像。
  9. 为测试数据库设置与本章开始时相同的环境变量。

最后在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

下面是这个的作用:

  1. 使用之前创建的compose文件构建不同的docker容器。
  2. 从先前创建的compose文件旋转不同的容器,并运行测试。--abort-on-container-exit告诉Docker Composetil-app容器停止时停止postgres容器。用于这个测试的postgres容器与你在开发过程中使用的容器不同,也不冲突。

当测试完成后,你会在终端看到所有测试通过的输出:

img

接下来去哪?

在本章中,你学到了如何测试你的Vapor应用程序,以确保它们能正常工作。为你的应用程序编写测试也意味着你可以在Linux上运行这些测试。这使你相信你的应用程序在部署时能正常工作。拥有一个好的测试套件可以让你快速发展和调整你的应用程序。

Vapor的架构对协议有很大的依赖性。这与VaporSwift扩展和可切换服务的使用相结合,使测试变得简单和可扩展。对于大型应用,你甚至可能想引入一个数据抽象层,这样你就不是在用一个真正的数据库进行测试。

这意味着你不必连接到数据库来测试你的主要逻辑,并会加快测试的速度。

定期运行你的测试是很重要的。使用持续集成(CI)系统,如JenkinsGitHub Actions,允许你测试每一次提交。

你还必须保持你的测试是最新的。在未来的章节中,如果行为发生了变化,比如引入了身份验证,你要改变测试以适应这些新功能。