MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Swift XCTest单元测试实践

2021-07-121.3k 阅读

一、XCTest 基础介绍

  1. 什么是 XCTest XCTest 是苹果公司为 iOS、macOS、watchOS 和 tvOS 开发提供的官方单元测试框架。它集成在 Xcode 开发环境中,使得开发者能够方便地为 Swift 或 Objective - C 编写的应用程序编写单元测试用例。单元测试是一种软件测试方法,旨在对软件中的最小可测试单元(通常是一个函数、方法或类)进行验证,确保其功能按照预期工作。

  2. XCTest 的优势

    • 与 Xcode 紧密集成:Xcode 对 XCTest 提供了很好的支持,包括在项目导航器中方便地管理测试文件,以及在编辑器中直接运行和调试测试用例。这种紧密集成提高了开发效率,使得开发者能够快速得到测试结果反馈。
    • 丰富的断言库:XCTest 提供了大量的断言方法,用于验证各种条件。例如,验证两个值是否相等、验证一个布尔值是否为真、验证一个对象是否为 nil 等。这些断言方法使得编写测试用例变得更加简洁和直观。
    • 支持异步测试:在现代应用开发中,很多操作都是异步的,比如网络请求、数据库操作等。XCTest 提供了专门的机制来支持异步测试,确保在异步操作完成后再进行断言验证。

二、创建 XCTest 测试项目

  1. 在 Xcode 中创建测试目标

    • 打开 Xcode 并创建一个新的 Swift 项目(例如,一个 iOS 应用项目)。
    • 在项目导航器中,选择项目文件,然后点击“Editor” -> “Add Target”。
    • 在弹出的对话框中,选择“iOS” -> “Test” -> “Unit Testing Bundle”,然后点击“Next”。
    • 为测试目标命名(例如,MyAppTests),并选择合适的选项,最后点击“Finish”。
  2. 测试文件结构

    • 创建好测试目标后,Xcode 会自动生成一个测试文件,通常命名为 <YourAppName>Tests.swift。在这个文件中,会有一个继承自 XCTestCase 的类,例如:
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 是一个断言方法,用于验证两个值是否相等。

三、基本断言方法

  1. 相等断言
    • 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 验证两个整数是否相等,并提供了一个可选的失败消息。如果断言失败,这个消息会显示在测试报告中,帮助开发者定位问题。
  1. 不等断言
    • XCTAssertNotEqual:用于验证两个值是否不相等。
func testNotEqualAssertions() {
    let num1 = 5
    let num2 = 10
    XCTAssertNotEqual(num1, num2)
}
  1. 布尔值断言
    • XCTAssertTrue:验证一个布尔值是否为 true
    • XCTAssertFalse:验证一个布尔值是否为 false
func testBooleanAssertions() {
    let isTrue = true
    XCTAssertTrue(isTrue)
    let isFalse = false
    XCTAssertFalse(isFalse)
}
  1. 对象存在断言
    • XCTAssertNotNil:验证一个可选类型的对象是否不为 nil
    • XCTAssertNil:验证一个可选类型的对象是否为 nil
func testObjectAssertions() {
    let someString: String? = "Hello"
    XCTAssertNotNil(someString)
    let nilString: String? = nil
    XCTAssertNil(nilString)
}

四、测试类和方法

  1. 测试简单类
    • 假设我们有一个简单的数学计算类 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 方法中,我们释放了这个对象。testAdditiontestSubtraction 方法分别测试了 Calculator 类的 addsubtract 方法。
  1. 测试方法的边界条件
    • 对于 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)
}

五、异步测试

  1. 使用 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 秒)。如果在超时时间内期望没有达成,测试将失败。
  1. 异步测试中的多个期望
    • 有时候,一个异步操作可能会触发多个事件,我们可以使用多个期望。例如,假设 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)
    }
}
  • 在这个例子中,我们创建了两个期望,一个用于表示请求已发送,另一个用于表示响应已成功接收。在异步操作的不同阶段,我们分别达成相应的期望,并最终等待所有期望达成。

六、测试私有方法

  1. 使用 @testable import
    • 在 Swift 中,默认情况下,测试目标无法访问被测试目标的私有成员。但是,我们可以通过 @testable import 语句导入被测试的模块,这样就可以访问模块内的 internal 成员(在 Swift 中,internal 是默认的访问级别,模块内可见)。如果我们想测试私有方法,可以将其访问级别修改为 internal
    • 假设我们有一个类 MyClass 有一个私有方法 privateMethod,我们将其访问级别改为 internal
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 方法。
  1. 使用反射(高级方法)
    • 对于真正的私有方法(访问级别为 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_getInstanceMethodmethod_getImplementation 等函数来获取并调用私有方法。但需要注意的是,这种方法在实际项目中应谨慎使用,因为它可能会导致代码的可维护性和稳定性问题。

七、集成测试与 UI 测试

  1. 集成测试
    • 集成测试关注的是不同模块或组件之间的交互。在 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)
    }
}
  • 在这个集成测试中,我们创建了 NetworkServiceUserService 的实例,并测试 UserServicefetchUser 方法是否能通过 NetworkService 正常获取用户数据。
  1. 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 找到界面上的元素,进行输入和点击操作,然后验证登录成功后的界面元素是否存在。

八、测试覆盖率

  1. 什么是测试覆盖率

    • 测试覆盖率是指测试用例对代码的覆盖程度。它可以帮助开发者了解哪些代码被测试到了,哪些代码还没有被覆盖。较高的测试覆盖率通常意味着代码的质量更高,因为更多的代码路径得到了验证。
    • 在 Xcode 中,我们可以通过查看测试覆盖率报告来了解项目的测试覆盖情况。
  2. 查看测试覆盖率报告

    • 运行测试后,在 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 方法没有被测试覆盖。
  1. 提高测试覆盖率
    • 分析未覆盖的代码:查看测试覆盖率报告,找出未覆盖的代码部分。这可能是因为缺少某些测试用例,或者测试用例没有覆盖到特定的代码路径。
    • 编写新的测试用例:根据未覆盖的代码,编写相应的测试用例。例如,对于上述 MathUtils 类的 multiply 方法,我们可以编写如下测试用例:
class MathUtilsTests: XCTestCase {
    func testMultiply() {
        let mathUtils = MathUtils()
        let result = mathUtils.multiply(3, 5)
        XCTAssertEqual(result, 15)
    }
}
  • 运行测试并再次查看覆盖率报告,确保新的测试用例覆盖了之前未覆盖的代码。通过不断重复这个过程,可以逐步提高项目的测试覆盖率。

九、持续集成中的 XCTest

  1. 持续集成简介

    • 持续集成(CI)是一种软件开发实践,团队成员频繁地将他们的代码集成到共享的仓库中,每次集成都会通过自动化的构建和测试流程进行验证。这有助于尽早发现代码集成过程中的问题,提高软件质量。
    • 常见的 CI 平台有 Jenkins、Travis CI、CircleCI 等,对于 iOS 项目,Xcode Server 也是一个不错的选择。
  2. 在持续集成中运行 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 项目,配置文件可能如下:
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,可以确保每次代码变更都经过了充分的测试,从而提高项目的稳定性和质量。

十、常见问题及解决方法

  1. 测试失败但原因不明确
    • 问题描述:测试用例失败,但 Xcode 给出的错误信息不够详细,难以确定问题所在。
    • 解决方法
      • 添加详细的断言消息:在断言方法中提供详细的失败消息,例如 XCTAssertEqual(result, expectedResult, "The result \(result) is not equal to the expected result \(expectedResult)")。这样在测试失败时,就可以通过消息了解更多关于失败的信息。
      • 使用断点调试:在测试方法中设置断点,运行测试并进入调试模式。通过调试,可以查看变量的值、执行流程等,从而找出问题所在。
  2. 异步测试超时
    • 问题描述:在异步测试中,wait(for:timeout:) 方法超时,导致测试失败。
    • 解决方法
      • 检查异步操作逻辑:确保异步操作在合理的时间内完成。例如,检查网络请求是否设置了合理的超时时间,或者异步任务的处理逻辑是否正确。
      • 延长超时时间:如果异步操作确实需要较长时间,可以适当延长 wait(for:timeout:) 方法的超时时间,但要注意不要设置过长,以免影响测试效率。
  3. 测试目标无法访问被测试目标的成员
    • 问题描述:在测试文件中,无法访问被测试目标的某些成员,提示访问权限不足。
    • 解决方法
      • 修改访问级别:将需要测试的成员的访问级别从 private 修改为 internal,并使用 @testable import 导入被测试模块。
      • 使用反射(如前所述):如果必须测试私有成员,可以使用反射技术,但要谨慎使用,因为它可能带来稳定性和可维护性问题。

通过解决这些常见问题,可以更好地进行 XCTest 单元测试,提高测试的可靠性和效率。