解锁Apple的Swift Testing测试框架

文摘   2024-07-09 07:01   四川  

初探Swift Testing

Swift Testing 是苹果在 WWDC 24 上推出的一个强大的基于宏的测试框架,其使用Swift语言开发的,支持单元测试(Unit Testing)、集成测试(Integration Testing)以及页面测试(UI Testing)。Swift的测试框架是Apple官方提供的测试框架,内置于Xcode中,用于编写和执行测试用例。下面是Swift Testing的一些核心能力:

1. 单元测试(Unit Testing)
  • XCTestCase: 编写单元测试的基础类,所有测试用例应继承自它。
  • setUp() 和 tearDown(): 在每个测试方法执行前后分别调用,用于初始化和清理测试环境。
  • 断言(Assertions): 包括XCTAssertEqual, XCTAssertTrue, XCTAssertNil等,用于验证代码的行为是否符合预期。
  • Mocking 和 Stubbing: 虽然Swift标准库不直接提供Mocking工具,但可以使用第三方库如Nimble、Mockingjay等来模拟依赖项和控制测试环境。
2. 集成测试(Integration Testing)
  • 集成测试关注的是组件之间的交互,可以使用XCTest同样进行编写,但测试范围会更广,可能涉及数据库访问、网络请求等。
  • 利用依赖注入等技术隔离外部依赖,以便于测试。
3. UI Testing
  • XCUITest: Apple提供的用于UI测试的框架,它允许编写脚本来模拟用户与应用的交互。
  • XCUIApplication: 表示被测应用的实例,可以用来启动应用、查找界面元素并执行操作。
  • 录制功能: Xcode提供UI测试的录制功能,可以记录用户的操作并自动生成测试脚本,之后可以根据需要进行调整优化。
  • Accessibility Identifiers: 为了使UI测试更稳定,建议为UI元素设置Accessibility Identifiers,以便于测试脚本中引用。

这篇文章主要介绍如何使用 Swift Testing、如何使用它编写测试用例以及如何将现有的测试迁移到新的框架中。

搭建 Swift Testing环境

使用Xcode 16或更高版本

Swift Testing是内置Xcode 16中的,并且在出厂时就已安装好。如果你正在使用此版本或更高版本的Xcode创建新项目,甚至可以在创建项目时指定要使用Swift Testing作为你的测试框架:
当你创建一个新的测试包时,也可以选择使用 Swift Testing:
你也可以在同一个测试包中同时使用 Swift Testing 和 XCTest 单元测试,无需额外的依赖或设置。

利用SPM

当你使用 Swift 6 工具链并将工具版本设置为 6.0 时,也可以直接使用 Swift 测试功能。不过,在使用 Swift Package Manager 时,你需要注意一个小问题。
如果你在项目的`Podfile`中没有明确声明对Swift Testing的依赖,那么你需要在运行使用新库编写的测试时,通过命令行参数传递一个标志。
swift test --enable-experimental-swift-testing

在Xcode 16或其他平台上

如果你还不太准备将项目迁移到Xcode 16或6.0版本的SPM Swift工具包,因为Swift Testing是开源的,作为Swift包进行分发,因此你可以直接依赖它:
需要注意的是,要在项目中使用 Swift Testing,你需要使用 Swift 6 编译器链。对于 Swift 5.10 有一个临时解决方案,但它将在某个时候被移除,因此不建议使用。

需要迁移的XCTests

我们假设在应用程序中有一段代码,它使用正则表达式解析一个字符串并返回一个 Swift 类型:```swift func parseString(string: String) -> Int { let regex = try! NSRegularExpression(pattern:"\\d+", options:.CaseInsensitive) let range = string.rangeOfString(regex.pattern, options:[]) return Int(string[range.location...range.location + range.length]) } ``` 上面的代码定义了一个名为 `parseString` 的函数,它接受一个字符串参数,并返回一个整数。该函数使用 Swift 的 `NSRegularExpression` 类解析字符串,以查找数字字符串。然后,它使用字符串的 `rangeOfString` 方法查找匹配的子字符串,并返回一个范围对象。最后,它使用字符串的下标访问语法来从匹配的子字符串中提取整数值。
WifiParser.swift
import Foundation
struct WifiNetwork {let ssid: Stringlet password: Stringlet security: String?let hidden: String?}
protocol ErrorMonitoring {func monitor(_ error: Error)}
struct WifiParser {enum Error: Swift.Error {case noMatches }
private let monitoring: ErrorMonitoring
init(monitoring: ErrorMonitoring) {self.monitoring = monitoring }
func parse(wifi: String) throws -> WifiNetwork {let regex = /WIFI:S:(?<ssid>[^;]+);(?:T:(?<security>[^;]*);)?P:(?<password>[^;]+);(?:H:(?<hidden>[^;]*);)?;/
guard let result = try? regex.wholeMatch(in: wifi) else {let error = Error.noMatches monitoring.monitor(error)throw error }
return WifiNetwork( ssid: String(result.ssid), password: String(result.password), security: result.security.map(String.init), hidden: result.hidden.map(String.init) ) }}
为了确保代码能够正常运行,我们编写了一些测试代码(使用  XCTest ):
WifiParserTests.swift
import XCTest
final class WifiParserTests: XCTestCase {var sut: WifiParser!var errorMonitoring: SpyErrorMonitoring!
override func setUp() { errorMonitoring = SpyErrorMonitoring() sut = WifiParser(monitoring: errorMonitoring) }
func testWhenParseIsCalledWithAllFieldsThenNetworkIsInitialisedCorrectly() throws {let wifi = "WIFI:S:superwificonnection;T:WPA;P:strongpassword;H:YES;;"
let network = try sut.parse(wifi: wifi)
XCTAssertEqual(network.security, "WPA")XCTAssertEqual(network.hidden, "YES")XCTAssertEqual(network.ssid, "superwificonnection")XCTAssertEqual(network.password, "strongpassword") }
func testWhenParseIsCalledWithEmptyStringThenNoMatchesErrorIsThrownAndMonitoringEventIsSent() {XCTAssertThrowsError(try sut.parse(wifi: "")) { error inXCTAssertEqual(error as? WifiParser.Error, .noMatches) }
XCTAssertEqual(errorMonitoring.capturedErrors.compactMap { $0 as? WifiParser.Error }, [.noMatches]) }
func testWhenParseIsCalledWithPasswordAndNoNameFieldsStringThenNoMatchesErrorIsThrownAndMonitoringEventIsSent() {XCTAssertThrowsError(try sut.parse(wifi: "WIFI:T:WPA;P:strongpassword;H:YES;;")) { error inXCTAssertEqual(error as? WifiParser.Error, .noMatches) }
XCTAssertEqual(errorMonitoring.capturedErrors.compactMap { $0 as? WifiParser.Error }, [.noMatches]) }
func testWhenParseIsCalledWithNameAndNoPasswordFieldsStringThenNoMatchesErrorIsThrownAndMonitoringEventIsSent() {XCTAssertThrowsError(try sut.parse(wifi: "WIFI:S:superwificonnection;T:WPA;H:YES;;")) { error inXCTAssertEqual(error as? WifiParser.Error, .noMatches) }
XCTAssertEqual(errorMonitoring.capturedErrors.compactMap { $0 as? WifiParser.Error }, [.noMatches]) }
func testWhenParseIsCalledWithNoPasswordOrNameFieldsStringThenNoMatchesErrorIsThrownAndMonitoringEventIsSent() {XCTAssertThrowsError(try sut.parse(wifi: "WIFI:T:WPA;H:YES;;")) { error inXCTAssertEqual(error as? WifiParser.Error, .noMatches) }
XCTAssertEqual(errorMonitoring.capturedErrors.compactMap { $0 as? WifiParser.Error }, [.noMatches]) }
func testwhenParseIsCalledWithStringContainingOnlyRequiredFieldsThenCorrectValuesAreReturned() throws {let wifi = "WIFI:S:superwificonnection;P:strongpassword;;"
let network = try sut.parse(wifi: wifi)
XCTAssertNil(network.hidden)XCTAssertNil(network.security)XCTAssertEqual(network.ssid, "superwificonnection")XCTAssertEqual(network.password, "strongpassword") }}
上述测试确保:
  1. 解析器可以解析出包含所有字段的有效WiFi字符串。
  2. 当WIFI字符串无效时,解析器会抛出错误。有几个测试检查了应该抛出错误的不同情况。
  3. 解析器仅能解析包含所需字段的有效WIFI字符串。

从XCTest到Swift测试

现在让我们来看看如何使用 Swift Testing 重写这些测试,并比较它与 XCTest 的区别。要使用 Swift Testing 编写测试,你需要导入  Testing  模块:
WifiParserTests.swift
import Testing

定义测试

与之前的版本相比,对单元测试的定义也有所改变。你不再需要在函数前缀上使用` test `,而是可以将` @Test `函数附加到任何你想让其成为测试的函数上。
WifiParserTests.swift
@Testfunc whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {// ...}
@Testfunc whenParseIsCalledWithEmptyString_thenNoMatchesErrorIsThrown_andMonitoringEventIsSent() {// ...}
// ...

套件:组织测试

在这种新的测试方法中,你可以在如何组织测试方面拥有更多的自由度。你可以将所有测试都声明为全局函数,也可以根据目的将它们封装在 struct 、 actor 或 final class 中。
在 Swift 测试中,这些类型被称为“套件”。类型可以以以下两种方式被标记为套件:
  • 可以通过给类型添加 Suite 宏来明确地标识出测试类型。这样做会将该类型的所有方法都视为测试,从而无需为每个测试案例添加 @Test。
WifiParserTests.swift
@Suitestruct WifiParserTests {func whenParseIsCalledWithAllFields_ThenNetworkIsInitialisedCorrectly() {// ... }// ...}
我尝试了这种方法,但无法让Xcode显示出测试结果。我只能在每个测试中明确地设置 @Test,才能看到测试结果。我已经提交了反馈,让苹果公司知道这个问题。
  • 通过不使用 Suite 宏对类型进行显式标记,并在每个测试中添加 @Test来隐式标记类型:
WifiParserTests.swift
struct WifiParserTests { @Testfunc whenParseIsCalledWithAllFields_ThenNetworkIsInitialisedCorrectly() {// ... }// ...}

功能

特性是自定义 Swift Testing 测试的一种方式。它们可以用于向测试添加元数据(如标记或名称),并确定如何以及何时运行这些测试。
在WWDC24“探索Swift测试”的演讲中,有几种内置的特性可供你用于自定义测试,接下来我将逐一为你介绍:

自定义名称

你可以通过向各自对应的宏传递一个字符串来重写测试和套件在测试导航器或测试报告中的显示方式和名称:
WifiParserTests.swift
@Suite("Parse a wifi string")struct WifiParserTests { @Test("Successfully parsing a string with all fields")func whenParseIsCalledWithAllFields_ThenNetworkIsInitialisedCorrectly() {// ... }// ...}
下次你在测试导航器中查看测试时,你会看到你设置的自定义名称:

测试并行化

套房默认情况下是并行执行测试的,而模块则是按顺序执行测试的。虽然并行执行测试是较好的选择,但在一些遗留代码库中,由于测试之间的共享状态,你可能会遇到一些错误。如果你需要按顺序执行测试,可以将 .serialized 标记传递给 @Suite 宏:
WifiParserTests.swift
@Suite(.serialized)struct WifiParserTests {func whenParseIsCalledWithAllFields_ThenNetworkIsInitialisedCorrectly() {// ... }// ...}

多操作系统

假设你的代码有多个实现版本,仅在特定的平台或操作系统版本上可用。
例如,由于实施了 WifiParser ,并且使用了iOS 16中的BareSlashRegexLiterals新特性,因此该功能仅在iOS 16或更高版本中可用。
WifiParserTests.swift
@available(iOS 16.0, *)struct WifiParser {enum Error: Swift.Error {case noMatches }
private let monitoring: ErrorMonitoring
init(monitoring: ErrorMonitoring) {self.monitoring = monitoring }
func parse(wifi: String) throws -> WifiNetwork {let regex = /WIFI:S:(?<ssid>[^;]+);(?:T:(?<security>[^;]*);)?P:(?<password>[^;]+);(?:H:(?<hidden>[^;]*);)?;/
guard let result = try? regex.wholeMatch(in: wifi) else {let error = Error.noMatches monitoring.monitor(error)throw error }
return WifiNetwork( ssid: String(result.ssid), password: String(result.password), security: result.security.map(String.init), hidden: result.hidden.map(String.init) ) }}
与处理生产代码类似,你也可以为测试代码指定相同的操作系统要求:
@Test@available(iOS 16.0, *)func whenParseIsCalledWithAllFields_ThenNetworkIsInitialisedCorrectly() {let errorMonitoring = SpyErrorMonitoring()let sut = WifiParser(monitoring: errorMonitoring)}
同样适用于套件:WifiParserTests.swift
@Suite@available(iOS 16.0, *)struct WifiParserTests {func whenParseIsCalledWithAllFields_ThenNetworkIsInitialisedCorrectly() {let errorMonitoring = SpyErrorMonitoring()let sut = WifiParser(monitoring: errorMonitoring) }}

使用标签对测试进行分类

Swift Testing带来的一个令人兴奋的功能是标签的概念。标签是一种特性,你可以用它来对测试进行分类并有选择地运行它们。
要开始对测试进行分类,你需要做的第一件事就是定义一个标签。
Tags.swift
import Testing
extension Tag { @Tag static var parsing: Self @Tag static var errorReporting: Self}
一旦定义了标签,就可以将其应用于想要分类的测试:
WifiParserTests.swift
@Test(.tags(.parsing)) func whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {// ...}
如果给一个套件应用了一个标签,该套件中的所有测试都将继承该标签。你甚至可以嵌套套件,使标签沿着层次结构传播,以进行更精确的分类:
WifiParserTests.swift
@Suite(.tags(.parsing))struct WifiParserTests { @Testfunc whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {// ... }
@Suite(.tags(.errorReporting))struct ErrorReportingTests { @Testfunc whenParseIsCalledWithEmptyString_thenNoMatchesErrorIsThrown_andMonitoringEventIsSent() {// ... } }}
现在,再次查看测试导航时,你可以通过标签筛选测试:

启用/禁用测试

有时候,由于测试不稳定或者存在尚未准备好解决的问题,导致测试开始失败。在这种情况下,可以通过在测试中添加 .disabled 标签来禁用测试:
WifiParserTests.swift
@Test(.disabled("Flaky, needs investigation before reenabling")) func whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {// ...}
被禁用的测试或测试套件仍然会在测试导航中显示出来,但会以灰色显示。每当你运行测试时,禁用的测试将被跳过,并且会在测试钻石上显示一个漂亮的指示器,以指示此类测试:
你甚至可以将 .disabled 标签与 .bug 特性一起使用,将测试与缺陷跟踪软件中的问题关联起来:
WifiParserTests.swift
@Test(.disabled("Flaky, needs investigation"), .bug("https://linear.app/project/issue/TEST-431/flaky-test")) func whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {// ...}
这个特性并不仅限于 .disabled 标签。你可以在任何测试或测试套件中使用它,将测试与缺陷跟踪软件问题关联起来。
还有另一种替代方案,即我更喜欢在跳过已知的失败或尚未准备好集成到你的套件中的测试时使用的 .disabled 特性。你可以使用 withKnownIssue 方法跳过单元测试中的失败:
WifiParserTests.swift
@Testfunc whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() { withKnownIssue { #expect(false) }}
使用` withKnownIssue `的最大好处是,它会在运行测试的同时记录预期的失败结果。如果测试开始通过,它将被标记并作为失败报告出来,这样你就可以移除` withKnownIssue `块了。
你还可以根据特定条件启用测试。例如,你可以定义一个配置,只有当一个特定的环境变量被设置时才启用测试:
WifiParserTests.swift
@Test(.enabled(when: Config.isCIRun)) func whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {// ...}

超时

单元测试的一个常见问题是,如果代码或测试本身存在问题,它们可能会无限期运行下去。为了避免这种情况发生,Swift Testing允许你使用 .timeout 特性为测试设置超时时间:
WifiParserTests.swift
@Test(.timeLimit(.minutes(3)))@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)func whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {// ...}
这个特性仅限于特定的操作系统版本,对所有平台都有约束,并且只允许你以分钟为单位指定超时时间,因为秒级 API 已废弃:

迁移测试和断言

学习如何在 Swift 测试中编写断言的最好方法是将代码从现有的项目迁移过来。在接下来的几个部分中,我将向你展示如何将  WifiParserTests  项目中的测试迁移到新的 Swift 测试框架  Suite  中。

迁移设置代码

我们需要做的第一件事是将设置代码从 setUp 方法迁移到 XCTestCase 中:
WifiParserTests.swift
import Testing
extension Tag { @Tag static var parsing: Self}
@Suite(.tags(.parsing))struct WifiParserTests {let sut: WifiParserlet errorMonitoring: SpyErrorMonitoring
init() { errorMonitoring = SpyErrorMonitoring() sut = WifiParser(monitoring: errorMonitoring) }}
如上所示,该代码的表达能力和可读性比XCTest要强得多。由于测试套件在每次执行单个测试之前都会创建,因此测试之间不存在共享状态。此外,我们使用初始化器来设置测试套件,从而无需使用可变变量和隐式解包的可选值。
结构或类的` deinit `是 Swift 测试的等效物,类似于 XCTest 中的 ` teardown ` 方法。

确认有效的解码

一旦设置完成,将“正常路径”测试迁移就很简单了。唯一需要做的就是在测试中添加` @Test `,并将` XCTAssert* `调用替换为` #expect `宏:
WifiParserTests.swift
import Testing
@Suite(.tags(.parsing))struct WifiParserTests {let sut: WifiParserlet errorMonitoring: SpyErrorMonitoring
init() { errorMonitoring = SpyErrorMonitoring() sut = WifiParser(monitoring: errorMonitoring) }
@Testfunc whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() throws {let wifi = "WIFI:S:superwificonnection;T:WPA;P:strongpassword;H:YES;;"
let network = try sut.parse(wifi: wifi)
let security = try #require(network.security)let hidden = try #require(network.hidden)
#expect(security == "WPA") #expect(hidden == "YES") #expect(network.ssid == "superwificonnection") #expect(network.password == "strongpassword") }
@Testfunc whenParseIsCalledWithStringContainingOnlyRequiredFields_thenCorrectValuesAreReturned() throws {let wifi = "WIFI:S:superwificonnection;P:strongpassword;;"
let network = try sut.parse(wifi: wifi)
#expect(network.hidden == nil) #expect(network.security == nil) #expect(network.ssid == "superwificonnection") #expect(network.password == "strongpassword") }}
我还想提一下上面的另一个变化。为了解包 parse 方法的可选属性,我使用了 #require 宏。这个宏与XCTest中的 try XCTUnwrap 方法类似,如果可以的话,它会返回你传递的可选值的解包版本。

断言错误

在本文中,我之前展示的用于测试解析器所有可能错误情况的代码相当冗长,其中包含很多重复的逻辑。使用Swift的单元测试,我们可以利用参数化测试来减少我们需要编写的代码量并共享逻辑:
WifiParserTests.swift
import Testing@Suite(.tags(.parsing))struct WifiParserTests {// ...// 1    @Test(.tags(.errorReporting), arguments: ["","WIFI:T:WPA;P:strongpassword;H:YES;;","WIFI:S:superwificonnection;T:WPA;H:YES;;","WIFI:T:WPA;H:YES;;",    ]) func whenParseIsCalledWithAStringThatCanNotBeDecoded_thenNoMatchesErrorIsThrown_andMonitoringEventIsSent(input: String) throws {// 2        #expect(throws: WifiParser.Error.noMatches) { try sut.parse(wifi: "") }// 3        #expect(errorMonitoring.capturedErrors.compactMap { $0 as? WifiParser.Error } == [.noMatches])    }}
让我们来分解一下上面测试的内容,以了解发生了什么:
  1. 该测试使用了 @Test 和 .tags(.errorReporting) 标签,将其归类为错误报告测试,并继承了来自套件的解析标签。将一个输入参数传递给测试,以便进行参数化测试,并通过 arguments 参数将一组输入值传递给 @Test。该测试将对每个数组元素执行一次,每次将输入参数设置为该特定迭代的数组值。
  2. `B0` 用于断言,当调用输入字符串时,`B1` 方法会抛出类型为 `B2
  3. ` #expect ` 用于断言 ` capturedErrors ` 属性包含一个元素的数组,该元素的类型为 ` WifiParser.Error.noMatches `.

输出

无论何时运行测试,你都会在控制台看到一个更易于理解的输出结果:
对未通过测试的诊断报告也进行了显著的改进,以一种更加易读的方式展示信息,明确指出哪些部分未通过以及具体原因:
附官方文档:https://developer.apple.com/documentation/Testing
往期系列文章
阿里微服务质量保障系列:微服务知多少
阿里微服务质量保障系列:研发流程知多少
阿里微服务质量保障系列:研发环境知多少
阿里微服务质量保障系列:阿里变更三板斧
阿里微服务质量保障系列:故障演练
阿里微服务质量保障系列:研发模式&发布策略
阿里微服务质量保障系列:性能监控
阿里微服务质量保障系列:性能监控最佳实践
阿里微服务质量保障系列:基于全链路的测试分析实践
- END -

下方扫码关注 软件质量保障,与质量君一起学习成长、共同进步,做一个职场最贵Tester!

往期推荐

聊聊工作中的自我管理和向上管理

经验分享|测试工程师转型测试开发历程

聊聊UI自动化的PageObject设计模式

细读《阿里测试之道》

我在阿里做测开

软件质量保障
所寫即所思|一个阿里质量人对测试技术的思考。
 最新文章