Swift Testing

1,096 阅读8分钟

Cover.png

前言: 本文章介绍 Xcode 16 WWDC 2024 新增功能 Swift Testing,并默认你已经对 XCTest 有了简单的了解。文章中所引用的代码均可在 GitHub 上查看或下载运行测试。

一、在 Xcode 项目中集成 Swift Testing

Swift Testing 已被无缝集成到 Xcode 16 中,成为官方首推的测试框架。在创建新项目时,你可以轻松选择 Swift Testing 作为默认测试框架,如下图所示:

NewProject.png

如果创建项目时没有勾选,则添加 Target 方法与原 XCTest 相同,注意 Testing System 选择为 Swift Testing

Target.png

添加 Unit Test Class 时需选择为 Swift Testing Unit Test

AppendTests.png

二、初步认识

先来看一个非常简单的 test。

import Testing
@testable import SwiftTestingDemo

struct SwiftTestingDemoTests {

    @Test func example() async throws {
        // Write your test here and use APIs like `#expect(...)` to check expected conditions.
        #expect(2 > 1)
    }
}

发现语法上确实和原来的 XCTest 有了很大的不同。具体有哪些变化我先来做个简单的罗列:

  1. 取消了继承 XCTestCase,而且声明的类型是个结构体。
  2. 方法名也不再强制以 test 作为前缀。取而代之的是通过 @Test 来标识为需要测试的 function
  3. 判断不再是通过 XCTAssertXXX,改为了 #expect
  4. setUpWithError()tearDownWithError() 方法没有了。

接下来会对这些改变做一一说明。当然如果你愿意可以先去看下 WWDC Swift Testing 视频介绍,自然也是极好的。

三、详细介绍

将会分为如下 4 个部分来进行分析。

Blocks.png

3.1 Functions

测试方法就是 Swift 方法,只是多了 @Test 修饰。可以是全局方法,也可以被包装在 Struct/Class 中。当然也可以包装在 Enum 中,但并不建议这么做,进一步了解可参照 3.4 部分。

@Test func example() {
    #expect(2 > 1)
}

如果需要也可以被标记为 asyncthrows

@Test func example() async throws {
    #expect(2 > 1)
}

3.2 Exceptions

3.2.1 #expect

#expect 宏非常灵活,可以用来执行预期操作。也支持表达式与运算符。

#expect(1 == 2)

#expect(user.name == "Alice")

#expect(!array.isEmpty)

#expect(numbers.contains(1))

同时也可以处理 Throws 相关。

struct TestEntity {
    enum CalculationError: Swift.Error, Equatable {
        case divisionByZero
    }

    func division(_ a: Int, _ b: Int) throws -> Int {
        guard b != 0 else {
            throw CalculationError.divisionByZero
        }
        return a / b
    }
}

@Test func testThrowErrors() throws {
    let sut = TestEntity()

    #expect(throws: (any Error).self) {
        try sut.division(1, 0)
    }

    #expect(throws: TestEntity.CalculationError.divisionByZero) {
        try sut.division(1, 0)
    }

    #expect {
        try sut.division(1, 0)
    } throws: { error in
        guard let error = error as? TestEntity.CalculationError,
              case .divisionByZero = error else {
            return false
        }
        return true
    }
}

3.2.2 #require

#expect 类似,但需要结合 try 关键字,并且会在表达式失败时抛出错误。如果你期望可以在预期操作失败时提前结束测试,那么这个宏将会是你需要的。

@Test func testIsValid() throws {
    let isValid = true
    let _ = try #require(isValid)// Test failed when `isValid == false`.
    #expect(isValid == true)// Not excuted when `isValid == false`.
}

也可以对可选值解包进行提前处理。当 optionValuenil 时,则会测试失败并提前结束测试。

@Test func testOptionalValue() throws {
    let optionValue: Int? = 0
    let unwrapValue = try #require(optionValue)
    #expect(unwrapValue != nil)

    let array: [Int] = []
    // Warning: Test failure when you open following line comment!!!
//        let _ = try #require(array.first)
}

同样也可以处理 throws 相关。

try #require(throws: (any Error).self) {
    try sut.division(1, 0)
}

3.3 Traits

特征描述部分是 Swift Testing 的一个很重要的点。

  • 可以添加关于测试的描述信息,如 @Test("Custom name")
  • 添加自定义标签,如 @Test(.tags(.critical))
  • 自定义是否运行测试,如 @Test(.enabled/.disabled)...
  • 还可以修改测试的运行方式,如 @Test(.timeLimit(.minutes(3)))@Suite(.serialized)

Custom Name

@Test("这里可以自定义测试方法名")
func renameTestFunction() {
    var boolValue = false
    #expect(!boolValue)
    
    boolValue = true
    #expect(boolValue)
}

  Test  "这里可以自定义测试方法名" started.
  Test  "这里可以自定义测试方法名" passed after 0.001 seconds.
  Test run with 1 test passed after 0.001 seconds. 

同时会在测试导航栏、控制台、测试结果页展示对应的变更。

CustomName.png

CustomName-TestResult.png

Bug

可以引用相关 Issue 链接。

@Test(.bug("https://github.com/example/"))
func bugExample() throws {
    // ...
}

Tag

extension Tag {
    @Tag static var formatting: Self
    @Tag static var networking: Self
}

@Test(.tags(.formatting))
func tagSampleTest1()  {
    let a = 2
    #expect(a < 3)
}

@Test(.tags(.networking, .formatting))
func tagSampleTest2() throws {
    let a = 2
    try #require(a < 3)
}

在 Xcode 16 中,测试检查器中新增了按标签查看测试的视图。标签是可搜索的,与 Suite 组织类似,可以根据给定的标签启动测试运行。可以在排除或包含给定标签的情况下定义测试计划。

Tag.png

测试报告结果中可以直观显示 tag 标记。

TestResult.png

tag 亦可直接应用在 @Suite 以实现套件内所有测试方法自动继承该 tag。

SuiteTag.png

Enabled & Disabled

可控制仅在满足(不满足)特定条件时执行相应的测试。

/// Modify this value to change test enable state.
let isTestEnabled: Bool = false

@Test(.enabled(if: isTestEnabled)) func testFuncEnabled() {
    // ✘ Test testFuncEnabled() skipped.
}

@Test(.disabled(if: !isTestEnabled)) func testFuncDisabled() {
    // ✘ Test testFuncDisabled() skipped.
}

   Test testFuncEnabled() skipped.
   Test testFuncDisabled() skipped.

可以直接通过 @Test(.disabled("Comment")) 来让测试方法强制跳过,当然也可以选择注释掉或删除。通过这种方式来处理仍可以触发编译,变相验证代码有效性。当然这只是增加了一种方式,采用何种方式,完全取决于你。

@Test(.disabled("Explain the reason for func skipping.")) 
func testFuncWillBeSkipped() {
    let array: [Int] = []
    #expect(array[0] == 0)
}

最终测试结果页上仍会展示出被跳过的方法,但会有跳过的标识来进行区分。

TestResult-Skipped.png

TimeLimit

设置测试的最大运行时间,超时则视为测试失败。

@available(iOS 16.0, *)
@Test(.timeLimit(.minutes(1)))
func testTimeLimit() async throws {
    let sleepTime = 1// Modify it to greate than 60 will be failed.
    try await Task.sleep(for: .seconds(sleepTime))
}

.timeLimt 仅支持以分钟为单位。

Serialized

Swift Testing 默认并行运行测试函数,这将加快测试运行速度,并以随机顺序运行,以帮助识别测试之间的隐藏依赖关系。

如果想按顺序进行测试,即可通过 @Suite(.serialized) 特性来实现。

@Suite(.serialized)
struct SerializedTests {
    
    @Test func serializedSampleTest1() throws {
        let a = 2
        try #require(a < 3)
    }
    
    @Test func serializedSampleTest2() throws {
        let a = 2
        try #require(a < 3)
    }
    
    @Test func serializedSampleTest3() throws {
        let a = 2
        try #require(a < 3)
    }
}

  Suite  SerializedTests started.
  Test serializedSampleTest1() started.
  Test serializedSampleTest1() passed after 0.001 seconds.
  Test serializedSampleTest2() started.
  Test serializedSampleTest2() passed after 0.001 seconds.
  Test serializedSampleTest3() started.
  Test serializedSampleTest3() passed after 0.001 seconds.
  Suite  SerializedTests passed after 0.001 seconds.
  Test run with 3 tests passed after 0.002 seconds. 

3.4 Suites

可以将@Suite添加到 SwiftClass/Sturct/Enum 类型,包含@Test函数或套件的类型将被隐式注释,并鼓励使用结构体来隔离状态。

需注意将 @Suite添加到 Enum 类型意义是不大的,因为在 Swift Testing 中,测试套件只能包含无参数的 init(),因此不能在枚举中直接定义测试用例。当然你如果想要在 Enum 中定义 static 测试方法倒也是可以的😄。

四、拓展

4.1 Parameterized tests

struct IceCream {
    enum Flavor {
        case vanilla, chocolate, strawberry, mint, banana, pistachio, peanut

        var containsNuts: Bool {
            switch self {
            case .peanut, .pistachio:
                return true
            default:
                return false
            }
        }
    }
}

@Test(arguments: [IceCream.Flavor.vanilla, .chocolate, .strawberry, .mint, .banana])
func doesNotContainNuts(flavor: IceCream.Flavor) throws {
    try #require(!flavor.containsNuts)
}

参数化测试类似于 for 循环,但做了诸多优化:

  • 并行运行参数,提升测试效率。
  • 当某个参数导致了测试异常时,也会很直观的将其展示出来。
  • 亦可对某个参数进行单独测试。

Arguments.jpg

既然是 arguments,那么肯定是支持多集合与参数对应的,但请考虑是否需要使用 zip 来将测试合并。参照官方示例:

enum Ingredient: CaseIterable {
    case rice, potato, lettuce, egg
}

enum Dish: CaseIterable {
    case onigiri, fries, salad, omelette
}

// Without zip there are 16 test cases (all combination of the 2 sets of 4 elements)
@Test(arguments: Ingredient.allCases, Dish.allCases)
func cook(_ ingredient: Ingredient, into dish: Dish) async throws {
    #expect(ingredient.isFresh)
    let result = try cook(ingredient)
    try #require(result.isDelicious)
    try #require(result == dish)
}

// Zipped to 4 test cases
@Test(arguments: zip(Ingredient.allCases, Dish.allCases))
func cook(_ ingredient: Ingredient, into dish: Dish) async throws {
    #expect(ingredient.isFresh)
    let result = try cook(ingredient)
    try #require(result.isDelicious)
    try #require(result == dish)
}

对应两种测试结果一目了然。

Dishs.png

ExpectDishs.png

4.2 Confirmation

当需要测试某程序运行次数(触发零次或多次)时,推荐包装到 confirmation 中。

class ConfirmationEvent {
    var eventHandler: (() -> Void)?

    func action(count: Int) async {
        (0..<count).forEach { _ in
            eventHandler?()
        }
    }
}

@available(iOS 13.0, *)
@Test func testConfirmation() async throws {
    let confirmationEvent = ConfirmationEvent()
    let n = 10
    await confirmation("Event times.", expectedCount: n) { confirm in
        confirmationEvent.eventHandler = {
            confirm()
        }
        await confirmationEvent.action(count: n)
    }
}

4.3 withKnownIssue

当遇到某个测试无法通过,或暂时无法修复时,可以暂时将其进行 withKnownIssue 包装。

@Test func testWithKnownIssue() throws {
    let sut = TestEntity()

    withKnownIssue {
        let _ = try sut.division(1, 0)
    }
}

当然在运行测试时会提示相应警告来时刻提醒你不运行不等于没问题。[机智]

withKnownIssue.png

五、对照

对比 XCTest 我们可以看到产生的变化还是不小的:

Functions

Compare-Functions.png

  1. 测试方法名不再是以 test 作为起始,而改为了通过 @Test 来标识。
  2. Swift Testing 可以说是专为 Swift 设计,从而摆脱了原 XCTest 的诸多束缚。
  3. 改为并发执行,测试效率提升。

Expectations

Compare-Expectations.png

语法更加简洁,只需考虑要表达什么,而不再需要考虑用哪种方式来表达更贴切。

Suites

Compare-Suites.png

  1. 着重提下原 setUp()/setUpWithError()tearDown()/tearDownWithError() 方法均被丢弃了,使用再熟悉不过的 init()/deinit 方法。
  2. 另外可能 Sub-groups 类型可能不知道指的是什么,举个🌰,一看就懂。

Subgroups.png

Continue After Failed

XCTest 中想要在遇到错误时中断测试,可通过设置 continueAfterFailure = false

Swift Testing 中则是使用 #require 而不是 #expect 即可。

Migrating a test from XCTest

官方文档


五、项目

GitHub

六、引用链接

官方文档

官方文档

WWDC 视频

WWDC 视频

wwdcnotes.com/documentati…

fatbobman.com/zh/posts/ma…