初探Swift Testing
Swift Testing 是苹果在 WWDC 24 上推出的一个强大的基于宏的测试框架,其使用Swift语言开发的,支持单元测试(Unit Testing)、集成测试(Integration Testing)以及页面测试(UI Testing)。Swift的测试框架是Apple官方提供的测试框架,内置于Xcode中,用于编写和执行测试用例。下面是Swift Testing的一些核心能力:
XCTestCase: 编写单元测试的基础类,所有测试用例应继承自它。 setUp() 和 tearDown(): 在每个测试方法执行前后分别调用,用于初始化和清理测试环境。 断言(Assertions): 包括XCTAssertEqual, XCTAssertTrue, XCTAssertNil等,用于验证代码的行为是否符合预期。 Mocking 和 Stubbing: 虽然Swift标准库不直接提供Mocking工具,但可以使用第三方库如Nimble、Mockingjay等来模拟依赖项和控制测试环境。
集成测试关注的是组件之间的交互,可以使用XCTest同样进行编写,但测试范围会更广,可能涉及数据库访问、网络请求等。 利用依赖注入等技术隔离外部依赖,以便于测试。
XCUITest: Apple提供的用于UI测试的框架,它允许编写脚本来模拟用户与应用的交互。 XCUIApplication: 表示被测应用的实例,可以用来启动应用、查找界面元素并执行操作。 录制功能: Xcode提供UI测试的录制功能,可以记录用户的操作并自动生成测试脚本,之后可以根据需要进行调整优化。 Accessibility Identifiers: 为了使UI测试更稳定,建议为UI元素设置Accessibility Identifiers,以便于测试脚本中引用。
这篇文章主要介绍如何使用 Swift Testing、如何使用它编写测试用例以及如何将现有的测试迁移到新的框架中。
搭建 Swift Testing环境
使用Xcode 16或更高版本
利用SPM
swift test --enable-experimental-swift-testing
在Xcode 16或其他平台上
需要迁移的XCTests
import Foundation
struct WifiNetwork {
let ssid: String
let password: String
let 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)
)
}
}
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 in
XCTAssertEqual(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 in
XCTAssertEqual(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 in
XCTAssertEqual(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 in
XCTAssertEqual(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")
}
}
解析器可以解析出包含所有字段的有效WiFi字符串。 当WIFI字符串无效时,解析器会抛出错误。有几个测试检查了应该抛出错误的不同情况。 解析器仅能解析包含所需字段的有效WIFI字符串。
从XCTest到Swift测试
import Testing
定义测试
@Test
func whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {
// ...
}
@Test
func whenParseIsCalledWithEmptyString_thenNoMatchesErrorIsThrown_andMonitoringEventIsSent() {
// ...
}
// ...
套件:组织测试
可以通过给类型添加 Suite 宏来明确地标识出测试类型。这样做会将该类型的所有方法都视为测试,从而无需为每个测试案例添加 @Test。
@Suite
struct WifiParserTests {
func whenParseIsCalledWithAllFields_ThenNetworkIsInitialisedCorrectly() {
// ...
}
// ...
}
通过不使用 Suite 宏对类型进行显式标记,并在每个测试中添加 @Test来隐式标记类型:
struct WifiParserTests {
@Test
func whenParseIsCalledWithAllFields_ThenNetworkIsInitialisedCorrectly() {
// ...
}
// ...
}
功能
自定义名称
@Suite("Parse a wifi string")
struct WifiParserTests {
@Test("Successfully parsing a string with all fields")
func whenParseIsCalledWithAllFields_ThenNetworkIsInitialisedCorrectly() {
// ...
}
// ...
}
测试并行化
@Suite(.serialized)
struct WifiParserTests {
func whenParseIsCalledWithAllFields_ThenNetworkIsInitialisedCorrectly() {
// ...
}
// ...
}
多操作系统
@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)
}
@Suite
@available(iOS 16.0, *)
struct WifiParserTests {
func whenParseIsCalledWithAllFields_ThenNetworkIsInitialisedCorrectly() {
let errorMonitoring = SpyErrorMonitoring()
let sut = WifiParser(monitoring: errorMonitoring)
}
}
使用标签对测试进行分类
import Testing
extension Tag {
@Tag static var parsing: Self
@Tag static var errorReporting: Self
}
@Test(.tags(.parsing))
func whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {
// ...
}
@Suite(.tags(.parsing))
struct WifiParserTests {
@Test
func whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {
// ...
}
@Suite(.tags(.errorReporting))
struct ErrorReportingTests {
@Test
func whenParseIsCalledWithEmptyString_thenNoMatchesErrorIsThrown_andMonitoringEventIsSent() {
// ...
}
}
}
启用/禁用测试
@Test(.disabled("Flaky, needs investigation before reenabling"))
func whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {
// ...
}
@Test(.disabled("Flaky, needs investigation"), .bug("https://linear.app/project/issue/TEST-431/flaky-test"))
func whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {
// ...
}
@Test
func whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {
withKnownIssue {
#expect(false)
}
}
@Test(.enabled(when: Config.isCIRun))
func whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {
// ...
}
超时
@Test(.timeLimit(.minutes(3))) (macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)func whenParseIsCalledWithAllFields_thenNetworkIsInitialisedCorrectly() {// ...}
迁移测试和断言
迁移设置代码
import Testing
extension Tag {
@Tag static var parsing: Self
}
@Suite(.tags(.parsing))
struct WifiParserTests {
let sut: WifiParser
let errorMonitoring: SpyErrorMonitoring
init() {
errorMonitoring = SpyErrorMonitoring()
sut = WifiParser(monitoring: errorMonitoring)
}
}
确认有效的解码
import Testing
@Suite(.tags(.parsing))
struct WifiParserTests {
let sut: WifiParser
let errorMonitoring: SpyErrorMonitoring
init() {
errorMonitoring = SpyErrorMonitoring()
sut = WifiParser(monitoring: errorMonitoring)
}
@Test
func 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")
}
@Test
func 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")
}
}
断言错误
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]) }}
该测试使用了 @Test 和 .tags(.errorReporting) 标签,将其归类为错误报告测试,并继承了来自套件的解析标签。将一个输入参数传递给测试,以便进行参数化测试,并通过 arguments 参数将一组输入值传递给 @Test。该测试将对每个数组元素执行一次,每次将输入参数设置为该特定迭代的数组值。 `B0` 用于断言,当调用输入字符串时,`B1` 方法会抛出类型为 `B2 ` #expect ` 用于断言 ` capturedErrors ` 属性包含一个元素的数组,该元素的类型为 ` WifiParser.Error.noMatches `.
输出
下方扫码关注 软件质量保障,与质量君一起学习成长、共同进步,做一个职场最贵Tester!
往期推荐