Swift XCTest单元测试实践
2021-07-121.3k 阅读
一、XCTest 基础介绍
-
什么是 XCTest XCTest 是苹果公司为 iOS、macOS、watchOS 和 tvOS 开发提供的官方单元测试框架。它集成在 Xcode 开发环境中,使得开发者能够方便地为 Swift 或 Objective - C 编写的应用程序编写单元测试用例。单元测试是一种软件测试方法,旨在对软件中的最小可测试单元(通常是一个函数、方法或类)进行验证,确保其功能按照预期工作。
-
XCTest 的优势
- 与 Xcode 紧密集成:Xcode 对 XCTest 提供了很好的支持,包括在项目导航器中方便地管理测试文件,以及在编辑器中直接运行和调试测试用例。这种紧密集成提高了开发效率,使得开发者能够快速得到测试结果反馈。
- 丰富的断言库:XCTest 提供了大量的断言方法,用于验证各种条件。例如,验证两个值是否相等、验证一个布尔值是否为真、验证一个对象是否为
nil
等。这些断言方法使得编写测试用例变得更加简洁和直观。 - 支持异步测试:在现代应用开发中,很多操作都是异步的,比如网络请求、数据库操作等。XCTest 提供了专门的机制来支持异步测试,确保在异步操作完成后再进行断言验证。
二、创建 XCTest 测试项目
-
在 Xcode 中创建测试目标
- 打开 Xcode 并创建一个新的 Swift 项目(例如,一个 iOS 应用项目)。
- 在项目导航器中,选择项目文件,然后点击“Editor” -> “Add Target”。
- 在弹出的对话框中,选择“iOS” -> “Test” -> “Unit Testing Bundle”,然后点击“Next”。
- 为测试目标命名(例如,
MyAppTests
),并选择合适的选项,最后点击“Finish”。
-
测试文件结构
- 创建好测试目标后,Xcode 会自动生成一个测试文件,通常命名为
<YourAppName>Tests.swift
。在这个文件中,会有一个继承自XCTestCase
的类,例如:
- 创建好测试目标后,Xcode 会自动生成一个测试文件,通常命名为
import XCTest
class MyAppTests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
XCTAssertEqual(1 + 1, 2)
}
}
setUpWithError
方法在每个测试方法执行前调用,可以用于初始化测试所需的资源,比如创建对象、设置环境变量等。tearDownWithError
方法在每个测试方法执行后调用,用于清理资源,比如释放内存、关闭文件等。testExample
是一个示例测试方法,所有测试方法都必须以test
开头,这样 Xcode 才能识别并运行它们。XCTAssertEqual
是一个断言方法,用于验证两个值是否相等。
三、基本断言方法
- 相等断言
- XCTAssertEqual:用于验证两个值是否相等。它有多个重载版本,可以用于比较不同类型的值,例如整数、浮点数、字符串、数组等。
func testEqualAssertions() {
let num1 = 5
let num2 = 5
XCTAssertEqual(num1, num2, "The two numbers should be equal")
let str1 = "Hello"
let str2 = "Hello"
XCTAssertEqual(str1, str2)
let arr1 = [1, 2, 3]
let arr2 = [1, 2, 3]
XCTAssertEqual(arr1, arr2)
}
- 在上述代码中,第一个
XCTAssertEqual
验证两个整数是否相等,并提供了一个可选的失败消息。如果断言失败,这个消息会显示在测试报告中,帮助开发者定位问题。
- 不等断言
- XCTAssertNotEqual:用于验证两个值是否不相等。
func testNotEqualAssertions() {
let num1 = 5
let num2 = 10
XCTAssertNotEqual(num1, num2)
}
- 布尔值断言
- XCTAssertTrue:验证一个布尔值是否为
true
。 - XCTAssertFalse:验证一个布尔值是否为
false
。
- XCTAssertTrue:验证一个布尔值是否为
func testBooleanAssertions() {
let isTrue = true
XCTAssertTrue(isTrue)
let isFalse = false
XCTAssertFalse(isFalse)
}
- 对象存在断言
- XCTAssertNotNil:验证一个可选类型的对象是否不为
nil
。 - XCTAssertNil:验证一个可选类型的对象是否为
nil
。
- XCTAssertNotNil:验证一个可选类型的对象是否不为
func testObjectAssertions() {
let someString: String? = "Hello"
XCTAssertNotNil(someString)
let nilString: String? = nil
XCTAssertNil(nilString)
}
四、测试类和方法
- 测试简单类
- 假设我们有一个简单的数学计算类
Calculator
:
- 假设我们有一个简单的数学计算类
class Calculator {
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
func subtract(_ a: Int, _ b: Int) -> Int {
return a - b
}
}
- 我们可以为
Calculator
类编写如下测试用例:
class CalculatorTests: XCTestCase {
var calculator: Calculator!
override func setUpWithError() throws {
calculator = Calculator()
}
override func tearDownWithError() throws {
calculator = nil
}
func testAddition() {
let result = calculator.add(3, 5)
XCTAssertEqual(result, 8)
}
func testSubtraction() {
let result = calculator.subtract(8, 3)
XCTAssertEqual(result, 5)
}
}
- 在
setUpWithError
方法中,我们初始化了Calculator
对象,在tearDownWithError
方法中,我们释放了这个对象。testAddition
和testSubtraction
方法分别测试了Calculator
类的add
和subtract
方法。
- 测试方法的边界条件
- 对于
Calculator
类的add
方法,我们可以测试一些边界条件,比如传入0
、负数等:
- 对于
func testAdditionWithZero() {
let result = calculator.add(0, 5)
XCTAssertEqual(result, 5)
}
func testAdditionWithNegativeNumbers() {
let result = calculator.add(-3, -5)
XCTAssertEqual(result, -8)
}
五、异步测试
- 使用 XCTestExpectation
- 在处理异步操作时,比如网络请求,我们可以使用
XCTestExpectation
。假设我们有一个简单的网络服务类NetworkService
,它有一个异步获取数据的方法:
- 在处理异步操作时,比如网络请求,我们可以使用
class NetworkService {
func fetchData(completion: @escaping (String?) -> Void) {
// 模拟网络延迟
DispatchQueue.main.asyncAfter(deadline:.now() + 2) {
completion("Some data")
}
}
}
- 我们可以为
fetchData
方法编写如下异步测试用例:
class NetworkServiceTests: XCTestCase {
var networkService: NetworkService!
override func setUpWithError() throws {
networkService = NetworkService()
}
override func tearDownWithError() throws {
networkService = nil
}
func testFetchData() {
let expectation = XCTestExpectation(description: "Fetch data completion")
networkService.fetchData { data in
XCTAssertNotNil(data)
expectation.fulfill()
}
wait(for: [expectation], timeout: 5)
}
}
- 在
testFetchData
方法中,我们创建了一个XCTestExpectation
对象,并给它一个描述。在异步操作的完成闭包中,我们进行断言验证,并调用expectation.fulfill()
表示期望已达成。最后,通过wait(for:timeout:)
方法等待期望达成,设置一个合理的超时时间(这里是 5 秒)。如果在超时时间内期望没有达成,测试将失败。
- 异步测试中的多个期望
- 有时候,一个异步操作可能会触发多个事件,我们可以使用多个期望。例如,假设
NetworkService
有一个方法会先发送一个请求,然后接收响应并进行处理,我们可以这样测试:
- 有时候,一个异步操作可能会触发多个事件,我们可以使用多个期望。例如,假设
class NetworkServiceTests: XCTestCase {
var networkService: NetworkService!
override func setUpWithError() throws {
networkService = NetworkService()
}
override func tearDownWithError() throws {
networkService = nil
}
func testComplexAsyncOperation() {
let requestSentExpectation = XCTestExpectation(description: "Request sent")
let responseReceivedExpectation = XCTestExpectation(description: "Response received")
networkService.performComplexOperation { success in
if success {
responseReceivedExpectation.fulfill()
} else {
XCTFail("Operation failed")
}
}
// 模拟请求发送逻辑,这里简单假设在一定时间后认为请求已发送
DispatchQueue.main.asyncAfter(deadline:.now() + 1) {
requestSentExpectation.fulfill()
}
wait(for: [requestSentExpectation, responseReceivedExpectation], timeout: 5)
}
}
- 在这个例子中,我们创建了两个期望,一个用于表示请求已发送,另一个用于表示响应已成功接收。在异步操作的不同阶段,我们分别达成相应的期望,并最终等待所有期望达成。
六、测试私有方法
- 使用
@testable import
- 在 Swift 中,默认情况下,测试目标无法访问被测试目标的私有成员。但是,我们可以通过
@testable import
语句导入被测试的模块,这样就可以访问模块内的internal
成员(在 Swift 中,internal
是默认的访问级别,模块内可见)。如果我们想测试私有方法,可以将其访问级别修改为internal
。 - 假设我们有一个类
MyClass
有一个私有方法privateMethod
,我们将其访问级别改为internal
:
- 在 Swift 中,默认情况下,测试目标无法访问被测试目标的私有成员。但是,我们可以通过
class MyClass {
internal func privateMethod() -> String {
return "This is a private method"
}
}
- 在测试文件中:
@testable import YourAppModule
class MyClassTests: XCTestCase {
func testPrivateMethod() {
let myClass = MyClass()
let result = myClass.privateMethod()
XCTAssertEqual(result, "This is a private method")
}
}
- 通过
@testable import YourAppModule
,我们导入了包含MyClass
的模块,从而可以在测试中访问其internal
方法。
- 使用反射(高级方法)
- 对于真正的私有方法(访问级别为
private
),我们可以使用反射来获取并调用它们。不过,这种方法比较复杂且不推荐在一般情况下使用,因为它依赖于 Swift 的内部实现细节,可能在不同版本的 Swift 中不稳定。 - 以下是一个简单的示例,假设
MyClass
有一个真正的私有方法privateMethod
:
- 对于真正的私有方法(访问级别为
class MyClass {
private func privateMethod() -> String {
return "This is a truly private method"
}
}
- 在测试文件中:
import Foundation
import XCTest
@testable import YourAppModule
class MyClassTests: XCTestCase {
func testPrivateMethodReflection() throws {
let myClass = MyClass()
let methodName = #selector(MyClass.privateMethod)
guard let method = class_getInstanceMethod(type(of: myClass), methodName) else {
XCTFail("Method not found")
return
}
var result: NSString?
let imp = method_getImplementation(method)
let funcType = imp?.assumingMemoryBound(to: @convention(c)(AnyObject, Selector) -> NSString.self)
if let funcType = funcType {
result = funcType(myClass, methodName)
}
XCTAssertEqual(result as? String, "This is a truly private method")
}
}
- 在这个示例中,我们使用了
class_getInstanceMethod
和method_getImplementation
等函数来获取并调用私有方法。但需要注意的是,这种方法在实际项目中应谨慎使用,因为它可能会导致代码的可维护性和稳定性问题。
七、集成测试与 UI 测试
- 集成测试
- 集成测试关注的是不同模块或组件之间的交互。在 XCTest 中,我们可以编写集成测试来验证多个类或模块协同工作是否正常。
- 例如,假设我们有一个
UserService
类依赖于NetworkService
来获取用户数据:
class UserService {
let networkService: NetworkService
init(networkService: NetworkService) {
self.networkService = networkService
}
func fetchUser(completion: @escaping (User?) -> Void) {
networkService.fetchData { data in
guard let data = data else {
completion(nil)
return
}
// 假设这里将 data 解析为 User 对象
let user = User(data: data)
completion(user)
}
}
}
class User {
let name: String
init(data: String) {
self.name = data
}
}
- 我们可以编写如下集成测试用例:
class UserServiceIntegrationTests: XCTestCase {
func testFetchUser() {
let networkService = NetworkService()
let userService = UserService(networkService: networkService)
let expectation = XCTestExpectation(description: "Fetch user completion")
userService.fetchUser { user in
XCTAssertNotNil(user)
expectation.fulfill()
}
wait(for: [expectation], timeout: 5)
}
}
- 在这个集成测试中,我们创建了
NetworkService
和UserService
的实例,并测试UserService
的fetchUser
方法是否能通过NetworkService
正常获取用户数据。
- UI 测试
- XCTest 也提供了对 UI 测试的支持。UI 测试用于验证应用程序用户界面的交互和行为。
- 要创建 UI 测试目标,在 Xcode 中,选择项目文件,点击“Editor” -> “Add Target”,然后选择“iOS” -> “UI Testing Bundle”。
- 假设我们有一个简单的登录界面,有用户名和密码输入框以及登录按钮。我们可以编写如下 UI 测试用例:
import XCTest
class LoginUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
let app = XCUIApplication()
app.launch()
}
func testLogin() {
let app = XCUIApplication()
let usernameTextField = app.textFields["usernameTextField"]
let passwordTextField = app.secureTextFields["passwordTextField"]
let loginButton = app.buttons["loginButton"]
usernameTextField.tap()
usernameTextField.typeText("testuser")
passwordTextField.tap()
passwordTextField.typeText("testpassword")
loginButton.tap()
// 假设登录成功后会显示一个欢迎标签
let welcomeLabel = app.staticTexts["welcomeLabel"]
XCTAssertTrue(welcomeLabel.exists)
}
}
- 在
setUpWithError
方法中,我们启动了应用程序,并设置continueAfterFailure = false
,这样一旦某个操作失败,测试就会停止。在testLogin
方法中,我们通过XCUIApplication
找到界面上的元素,进行输入和点击操作,然后验证登录成功后的界面元素是否存在。
八、测试覆盖率
-
什么是测试覆盖率
- 测试覆盖率是指测试用例对代码的覆盖程度。它可以帮助开发者了解哪些代码被测试到了,哪些代码还没有被覆盖。较高的测试覆盖率通常意味着代码的质量更高,因为更多的代码路径得到了验证。
- 在 Xcode 中,我们可以通过查看测试覆盖率报告来了解项目的测试覆盖情况。
-
查看测试覆盖率报告
- 运行测试后,在 Xcode 的报告导航器中,选择测试报告。
- 点击报告中的“Coverage”选项卡,Xcode 会显示每个文件的代码覆盖率百分比。
- 可以进一步展开文件,查看具体方法和代码行的覆盖情况。未覆盖的代码行会以红色显示,已覆盖的代码行会以绿色显示。
- 例如,如果我们有一个
MathUtils
类:
class MathUtils {
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
func multiply(_ a: Int, _ b: Int) -> Int {
return a * b
}
}
- 并且我们为
add
方法编写了测试用例,但没有为multiply
方法编写测试用例,那么在测试覆盖率报告中,add
方法的代码行是绿色的,而multiply
方法的代码行是红色的,表明multiply
方法没有被测试覆盖。
- 提高测试覆盖率
- 分析未覆盖的代码:查看测试覆盖率报告,找出未覆盖的代码部分。这可能是因为缺少某些测试用例,或者测试用例没有覆盖到特定的代码路径。
- 编写新的测试用例:根据未覆盖的代码,编写相应的测试用例。例如,对于上述
MathUtils
类的multiply
方法,我们可以编写如下测试用例:
class MathUtilsTests: XCTestCase {
func testMultiply() {
let mathUtils = MathUtils()
let result = mathUtils.multiply(3, 5)
XCTAssertEqual(result, 15)
}
}
- 运行测试并再次查看覆盖率报告,确保新的测试用例覆盖了之前未覆盖的代码。通过不断重复这个过程,可以逐步提高项目的测试覆盖率。
九、持续集成中的 XCTest
-
持续集成简介
- 持续集成(CI)是一种软件开发实践,团队成员频繁地将他们的代码集成到共享的仓库中,每次集成都会通过自动化的构建和测试流程进行验证。这有助于尽早发现代码集成过程中的问题,提高软件质量。
- 常见的 CI 平台有 Jenkins、Travis CI、CircleCI 等,对于 iOS 项目,Xcode Server 也是一个不错的选择。
-
在持续集成中运行 XCTest
- 使用 Xcode Server:
- 配置 Xcode Server:在 Mac 上安装并配置 Xcode Server,确保它可以访问项目的代码仓库。
- 创建集成计划:在 Xcode Server 中,为项目创建一个集成计划。在计划设置中,指定要运行的测试目标(即 XCTest 测试目标)。
- 触发集成:每次有代码推送或合并到仓库时,Xcode Server 会自动触发集成计划,运行 XCTest 测试。如果测试失败,开发团队会收到通知,以便及时修复问题。
- 使用第三方 CI 平台(以 Travis CI 为例):
- 安装 Travis CI 客户端:在本地开发环境中安装 Travis CI 客户端工具。
- 配置
.travis.yml
文件:在项目根目录下创建一个.travis.yml
文件,用于配置 Travis CI 的行为。对于 iOS 项目,配置文件可能如下:
- 使用 Xcode Server:
language: objective - c
osx_image: xcode12.5
script:
- set - o pipefail && xcodebuild clean build test - project YourApp.xcodeproj - scheme YourApp - destination 'platform = iOS Simulator,OS = 14.5,name = iPhone 12 Pro' | xcpretty - s
- 在上述配置中,我们指定了使用 Objective - C 语言(因为 XCTest 也支持 Objective - C,Swift 项目同样适用),使用的 Xcode 版本为 12.5。`script` 部分定义了要执行的命令,这里是使用 `xcodebuild` 命令进行项目的清理、构建和测试,并通过 `xcpretty` 工具美化测试输出。
- 推送代码到仓库:将项目代码和 `.travis.yml` 文件推送到代码仓库。Travis CI 会检测到新的推送,并根据配置文件运行 XCTest 测试。如果测试失败,会在 Travis CI 的界面上显示详细的错误信息,方便开发者定位问题。
通过在持续集成中运行 XCTest,可以确保每次代码变更都经过了充分的测试,从而提高项目的稳定性和质量。
十、常见问题及解决方法
- 测试失败但原因不明确
- 问题描述:测试用例失败,但 Xcode 给出的错误信息不够详细,难以确定问题所在。
- 解决方法:
- 添加详细的断言消息:在断言方法中提供详细的失败消息,例如
XCTAssertEqual(result, expectedResult, "The result \(result) is not equal to the expected result \(expectedResult)")
。这样在测试失败时,就可以通过消息了解更多关于失败的信息。 - 使用断点调试:在测试方法中设置断点,运行测试并进入调试模式。通过调试,可以查看变量的值、执行流程等,从而找出问题所在。
- 添加详细的断言消息:在断言方法中提供详细的失败消息,例如
- 异步测试超时
- 问题描述:在异步测试中,
wait(for:timeout:)
方法超时,导致测试失败。 - 解决方法:
- 检查异步操作逻辑:确保异步操作在合理的时间内完成。例如,检查网络请求是否设置了合理的超时时间,或者异步任务的处理逻辑是否正确。
- 延长超时时间:如果异步操作确实需要较长时间,可以适当延长
wait(for:timeout:)
方法的超时时间,但要注意不要设置过长,以免影响测试效率。
- 问题描述:在异步测试中,
- 测试目标无法访问被测试目标的成员
- 问题描述:在测试文件中,无法访问被测试目标的某些成员,提示访问权限不足。
- 解决方法:
- 修改访问级别:将需要测试的成员的访问级别从
private
修改为internal
,并使用@testable import
导入被测试模块。 - 使用反射(如前所述):如果必须测试私有成员,可以使用反射技术,但要谨慎使用,因为它可能带来稳定性和可维护性问题。
- 修改访问级别:将需要测试的成员的访问级别从
通过解决这些常见问题,可以更好地进行 XCTest 单元测试,提高测试的可靠性和效率。