MockCloudKitFramework

一个用于测试 CloudKit 操作的框架。它模拟 CloudKit 类,从而提供一种无缝的方式来测试您的 App 代码中的 CloudKit 操作。

为什么需要它?

CloudKit 是一个用于共享记录的强大框架,但它主要由于隐藏了初始化程序而使得测试策略难以实施,导致无法直接创建 CKContainer 的测试实例。CloudKit 确实提供了一个测试环境,该环境使用您应用程序的 com.apple.developer.icloud-container-environment 权限,但这更像是一个记录沙箱,而不是管理它们的 API。

MockCloudKitFramework 试图填补这个空白。

CloudKit 中两个最重要的类,CKContainer 和 CKDatabase,(不幸地)在功能上被实现为 final 类。 它们可以被子类化,但它们的 init 方法不可访问。 因此,MockCloudKitFramework 无法简单地子类化 CloudKit 类。

更糟糕的是,它们都直接从 NSObject 继承作为它们的公共协议。 因此,IOC 将强制泛型函数对 NSObject 类型开放,从而使得函数可以注入任何从 NSObject 继承的对象。

为了弥补这个差距,MockCloudKitFramework (MCF) 创建了自己的协议,并使用其实现的对象的模拟来扩展 CloudKit。

这是一个使用简单应用程序的电影,该应用程序允许您键入消息并将该消息发布到 iCloud。 该 UI 不知道我们在这里使用的是 MCF 而不是 CloudKit

testing with success conditions

但是,我们可以轻松地告诉 MCF 我们希望事务因某个错误而失败

testing with failure conditions

要求

MockCloudKitFramework 仅针对 iOS 15.0 及更高版本构建和测试。 这是为了利用 CKDatabaseOperation 功能的更简洁的实现。 但是,框架中包含一些来自 CKDatabase 的“遗留”方法(尚未弃用,但 CloudKit 文档指定了要使用的替代 CKDatabaseOperations)。 它们的实现仅仅通过它们的完成处理程序返回一个错误。 我选择不将它们标记为抛出,因为那会导致模拟方法与其 CloudKit 对应项的非抛出签名冲突。 总的来说,我努力完全按照 CloudKit 实现的方式来维护所有方法签名。

概述

MockCloudKitFramework (MCF) 主要为 CKContainer 和 CKDatabase 功能实现模拟操作,但也模拟从 CKDatabaseOperation 继承的操作。 这些构成了 CloudKit 互操作性的很大一部分,但仍然存在一些 CloudKit 功能未被模拟。

注意:不处理 Zone 操作,但更重要的是,CloudKit 为 Swift 5.5 async/await 支持添加的异步 API 操作(截至目前)尚未在 MockCloudKitFramework 中实现。

好了,关于 MockCloudKitFramework 不能或不会做什么就说到这里。 让我们看看它实际做什么。

使用 MockCloudKitFramework

提示:MockCloudKitFramework 旨在尽可能地遵循 CloudKit 的 API。 因此,使用它将像使用 CloudKit 本身一样熟悉。

工作示例

以下是如何使用 MockCloudKitFramework 的一般示例。 有关更多详细信息,请参阅项目文档和随附的 MockCloudKitFrameworkTestProject。

第一步

假设我们有一个视图或视图控制器,其中包含一些调用 CloudKit API 的代码

let cloudContainer: CKContainer = CKContainer.default()
let database: CKDatabase = cloudContainer.publicCloudDatabase

// CKAccountStatus codes are constants that indicate the availability of 
// the user’s iCloud account. Note that ONLY the return of CKAccountStatus.available
// signifies that the user is signed into iCloud. Any other return value indicates an error.
func accountStatus(completion: @escaping (Result<CKAccountStatus, Error>) -> Void) {
    cloudContainer.accountStatus { status, error in
        switch status {
        case .available:
            completion(.success(.available))
        default:
                guard let error = error else {
                    let error = NSError.init(domain: "AccountStatusError", code: 0) as Error
                    completion(.failure(error))
                    return
                }
            completion(.failure(error))
        }
    }
}

这很棒,但是当 CloudKit 返回除 CKAccountStatus.available 以外的任何内容时,我们如何测试? 我们总是可以关闭笔记本电脑上的 wifi,以强制 CloudKit 返回一些其他的 CKAccountStatus(你知道你已经这么做过了)。

但这对于测试来说并不是很方便。 而且测试应该是确定性的和快速的。 哦,而且是可自动化的。

测试

由于这里的目标是测试我们的代码,因此让我们将我们在视图中拥有的调用代码放入测试中

import XCTest
import CloudKit
@testable import OurProject // required for access to CloudController

class MockCloudKitTestProjectIntegrationTest: XCTestCase {
    var cloudContainer: CKContainer!

    /// Lookup table for CKAccountStatus codes
    let ckAccountStatuses: [CKAccountStatus] = [
        CKAccountStatus.couldNotDetermine,
        CKAccountStatus.available,
        CKAccountStatus.restricted,
        CKAccountStatus.noAccount,
        CKAccountStatus.temporarilyUnavailable
    ]

    override func setUpWithError() throws {
        try? super.setUpWithError()
        cloudContainer = CKContainer.default()
    }

    // ================================
    // test accountStatus()
    // ================================
    // test that we get errors for all CKAccountStatus except for CKAccountStatus.available and that status is expected message.
    func test_accountStatus() {
        XCTAssertNotNil(cloudContainer)

        for (status, message) in ckAccountStatusMessageMappings {
            let expect = expectation(description: "CKAccountStatus")
                cloudContainer.accountStatus { result in
                switch result {
                case .success:
                    XCTAssertEqual(self.ckAccountStatusMessageMappings[status],
                                   CKAccountStatusMessage.available.rawValue)
                    expect.fulfill()
                case .failure(let error):
                    XCTAssertNotNil(error)
                    XCTAssertEqual(self.ckAccountStatusMessageMappings[status], message)
                    expect.fulfill()
                }
            }
            waitForExpectations(timeout: 1)
        }
    }
}

我们现在的问题是,该函数可以通过单元测试进行测试,但我们受 CloudKit 状态的约束才能使测试通过。 并且我们对 CloudKit 状态几乎没有控制权。 这就是 MockCloudKit 框架的用武之地。

控制器类

让我们在我们的项目中定义以下类 CloudController。 CloudController 本质上是 CloudKit API 的包装器。 它包含两个方法

注意:请注意这里使用了泛型。 CloudController 类被类型化为符合 CloudContainable 协议的对象。

import CloudKit

/// Example Class to handle iCloud related transactions. 
class CloudController<T: CloudContainable> {
    let cloudContainer: T
    let database: T.DatabaseType

    init(container: T, databaseScope: CKDatabase.Scope) {
        self.cloudContainer = container
        self.database = container.database(with: databaseScope)
    }

    func accountStatus(completion: @escaping (Result<CKAccountStatus, Error>) -> Void) {
        cloudContainer.accountStatus { status, error in
            switch status {
            case .available:
                completion(.success(.available))
            default:
                guard let error = error else {
                    let error = NSError.init(
                        domain: "AccountStatusError", 
                        code: 0) as Error
                    completion(.failure(error))
                    return
                }
                completion(.failure(error))
            }
        }
    }

/// Check if a record exists in iCloud.
/// - Parameters:
///   - recordId: the record id to locate
///   - completion: closure to execute on caller
/// - Returns: success(true) when record is located, success(false) when record is 
///   not found, failure if an error occurred.
func checkCloudRecordExists(recordId: CKRecord.ID, 
                            _ completion: @escaping (Result<Bool, Error>) -> Void) {
        let dbOperation = CKFetchRecordsOperation(recordIDs: [recordId])
        dbOperation.recordIDs = [recordId]
        var record: CKRecord?
        dbOperation.desiredKeys = ["recordID"]
        // perRecordResultBlock doesn't get called if the record doesn't exist
        dbOperation.perRecordResultBlock = { _, result in
            // success iff no partial failure
            switch result {
            case .success(let r):
                record = r
            case .failure:
                record = nil
            }
        }
        // fetchRecordsResultBlock always gets called when finished processing.
        dbOperation.fetchRecordsResultBlock = { result in
            // success if no transaction error
            switch result {
            case .success():
                if let _ = record { // record exists and no errors
                    completion(.success(true))
                } else { // record does not exist
                    completion(.success(false))
                }
            case .failure(let error): // either transaction or partial failure occurred
                completion(.failure(error))
            }
        }
        database.add(dbOperation)
    }
}
另一种测试方法

现在我们已经为泛型设置了 CloudController,让我们重新定义我们的测试,这次使用 MockCloudKitFramework。

注意:请注意在 MockCKContainer 上使用 setError 属性来设置 MCF 的 MockCKContainer 的失败条件。

现在,CloudController 的 accountStatus 方法将仅对 CKAccountStatus.available 返回成功。 成功测试。

    // ================================
    // test accountStatus()
    // ================================
    // test that we get errors for all CKAccountStatus except for 
    // CKAccountStatus.available and that status is expected message.
    func test_accountStatus() {
        XCTAssertNotNil(cloudContainer)

        for (status, message) in ckAccountStatusMessageMappings {
            let expect = expectation(description: "CKAccountStatus")
            // NOTE that we are setting both success (.available) and error statuses 
            // (all others) on MockCKContainer now
            cloudContainer.setAccountStatus = status
            cloudController.accountStatus { result in
                switch result {
                case .success:
                    XCTAssertEqual(self.ckAccountStatusMessageMappings[status],
                                   CKAccountStatusMessage.available.rawValue)
                case .failure(let error):
                    XCTAssertNotNil(error)
                    XCTAssertEqual(self.ckAccountStatusMessageMappings[status], message)
                }
                expect.fulfill()
            }
            waitForExpectations(timeout: 1)
        }
    }
}
测试 CKDatabase 上的 CKFetchRecordsOperation 操作

好的,让我们看看我们还能做什么。

测试 CKFetchRecordsOperation 成功

假设我们想要检查我们的 CloudKit 数据库的公共作用域中是否存在记录? 那么,CloudController 具有 checkCloudRecordExists() 方法,该方法通过 CloudKit 的 CKFetchRecordsOperation 操作来获取记录。 但是我们检查什么记录? MockCloudKitFramework 已经为您准备好了。 使用它,我们可以在 CKDatabase (MockCKDatabase) 的本地(模拟)实例上设置记录,然后再次将我们的模拟 CKContainer 注入到 CloudController 中。

func test_checkCloudRecordExists_success() {
    let expect = expectation(description: "CKDatabase fetch")
    let record = makeCKRecord()
    // First, add the record to MockCKDatabase
    cloudDatabase.addRecords(records: [record])
    // Then check for its existence
    cloudController.checkCloudRecordExists(recordId: record.recordID) { result in
        switch result {
        case .success(let exists):
            XCTAssertTrue(exists)
            expect.fulfill()
        case .failure:
            XCTFail("failure only when error occurs")
        }
    }
    waitForExpectations(timeout: 1)
}

就是这样!! 我们所要做的就是将记录添加到 MockCKDatabase(CloudKit CKContainer 类的 MCF 版本),然后调用 checkCloudRecordExists()。 请注意,使用 CKModifyRecordsOperation 操作首先将记录添加到 MockCKDatabase 中是完全可以的(这是我们在处理 CloudKit 时会做的事情),但是 MockCKDatabase API 允许我们简单地更改数据库。

让我们再次测试它,但这次设置我们希望 CloudKit 失败的错误

// call checkCloudRecordExists() when the record is present but error is set
func test_checkCloudRecordExists_error() {
    let expect = expectation(description: "CKDatabase fetch")
    let record = makeCKRecord()
    cloudDatabase.addRecords(records: [record])
    // set an error on operation
    let nsErr = createNSError(with: CKError.Code.internalError)
    MockCKDatabaseOperation.setError = nsErr
    cloudController.checkCloudRecordExists(recordId: record.recordID) { result in
        switch result {
        case .success:
            XCTFail("should have failed")
            expect.fulfill()
        case .failure(let error):
            XCTAssertEqual(error.createCKError().code.rawValue, nsErr.code)
            expect.fulfill()
        }
    }
    waitForExpectations(timeout: 1)
}

这里唯一的区别是我们创建了一个 NSError 并通过静态 setError 属性将其添加到我们的 MockCKDatabaseOperation 中

let nsErr = createNSError(with: CKError.Code.internalError)
MockCKDatabaseOperation.setError = nsErr

我们甚至可以测试我们的函数逻辑以处理 部分失败,以确保我们处理记录可能被找到但发生了一些 CKError,因此我们无法确定的情况。 我们需要做的只是将 setRecordErrors 属性设置为应该失败的记录 ID 集(MCF 选择一个随机 CKError 来导致失败)

// test for partial failures
func test_checkCloudRecordExists_partial_error() {
    let expect = expectation(description: "CKDatabase fetch")
    let record = makeCKRecord()
    cloudDatabase.addRecords(records: [record])
    // set an error on Record
    MockCKFetchRecordsOperation.setRecordErrors = [record.recordID]
    cloudController.checkCloudRecordExists(recordId: record.recordID) { result in
        switch result {
        case .success:
            XCTFail("should have failed")
        case .failure(let error):
            let ckError = error.createCKError()
            XCTAssertEqual(ckError.code.rawValue,
                           CKError.partialFailure.rawValue,
                           "The transaction error should always be set to CKError.partialFailure when record errors occur")
            if let partialErr: NSDictionary = error.createCKError().getPartialErrors() {
                let ckErr = partialErr.allValues.first as? CKError
                XCTAssertEqual("CKErrorDomain", ckErr?.toNSError().domain)
                expect.fulfill()
            }
        }
    }
    waitForExpectations(timeout: 1)
}

安装

将 MockCloudKitFramework.framework 导入到您的项目中

通过 Swift Package Manager 添加到您的项目非常简单。 从 XCode 中,只需选择 File -> Add packages... 并指向此存储库。 确保将该项目安装为框架(检查 Project Settings -> General -> My Target -> Frameworks, Libraries, and Embedded Content)。

MockCloudKitTestFramework(提供 MockCloudKitFramework 的单元、集成和 UITesing 示例的 XCode 项目)可以被克隆并作为标准 XCode 项目运行。

设置

设置 MockCloudKitFramework (MCF) 至少可以通过两种方式完成。 第一种方式很简单,但可能对生产环境不安全。 第二种方式需要更多步骤。 这两种方式都要求您使用泛型来传入您通过 IOC(依赖注入)实现的 CloudKit 或 MCF 类。 稍后会详细介绍。

简单的方法

只需按如下方式导入框架

import MockCloudKitFramework

这就是所需要的全部。 但折衷方案是,您必须在导入 CloudKit 的每个地方都导入 MCF(假设您要测试该模块)。 这可能会让一些开发人员反感。 但请记住,所有 MCF 代码(包括这些协议及其扩展)都包装在 #if DEBUG 编译指示中 - 因此在正常运行时不会公开任何内容,仅在测试运行时才会公开。 但是,如果您想避免将测试依赖项导入到生产代码中的风险,请参阅下一节。

(稍微)困难的方法

您可以仅从您的测试类中使用 MCF。 您只需将 MCF 协议及其扩展加载到您的各个目标中(XCode 为每个目标维护单独的环境)。 由您决定何时以及如何公开 MCF 协议和扩展,但推荐的方法是将它们包装在 #if DEBUG 块中,至少要这样。 这将确保它们仅在测试运行期间加载,并且它们将通过编译器从生产代码中删除。

安装 MCF 协议

将以下协议集复制到您项目(非测试)目标中的模块中。 一个好的地方可能是您的根应用程序模块(参见 MockCloudKitTestProject/MockCloudKitTestProjectApp.app 中的示例)

# if DEBUG
// ========================================
// MockCloudKitFramework Protocols
// ========================================
/// Protocol for CKFetchRecordsOperation interoperability
public protocol CKFetchRecordsOperational: DatabaseOperational {
    var recordIDs: [CKRecord.ID]? { get set }
    var desiredKeys: [CKRecord.FieldKey]? { get set }
    // `CKDatabaseOperation`s:
    /// The closure to execute with progress information for individual records
    var perRecordProgressBlock: ((CKRecord.ID, Double) -> Void)? { get set }
    /// The closure to execute after CloudKit modifies all of the records
    var fetchRecordsResultBlock: ((Result<Void, Error>) -> Void)? { get set }
    /// The closure to execute once for every fetched record
    var perRecordResultBlock: ((CKRecord.ID, Result<CKRecord, Error>) -> Void)? { get set }
}
/// Protocol for CKQueryOperation interoperability
public protocol CKQueryOperational: DatabaseOperational {
    var query: CKQuery? { get set }
    var desiredKeys: [CKRecord.FieldKey]? { get set }
    // `CKDatabaseOperation`s:
    /// The closure to execute after CloudKit modifies all of the records
    var queryResultBlock: ((_ operationResult: Result<CKQueryOperation.Cursor?, Error>) -> Void)? { get set }
    /// The closure to execute once for every fetched record
    var recordMatchedBlock: ((_ recordID: CKRecord.ID, _ recordResult: Result<CKRecord, Error>) -> Void)? { get set }
}
/// Protocol for CKModifyRecordsOperation interoperability
public protocol CKModifyRecordsOperational: DatabaseOperational {
    var recordsToSave: [CKRecord]? { get set }
    var recordIDsToDelete: [CKRecord.ID]? { get set }
    var savePolicy: CKModifyRecordsOperation.RecordSavePolicy { get set }
    // `CKDatabaseOperation`s:
    /// The closure to execute with progress information for individual records
    var perRecordProgressBlock: ((CKRecord, Double) -> Void)? { get set }
    /// The closure to execute after CloudKit modifies all of the records
    var modifyRecordsResultBlock: ((_ operationResult: Result<Void, Error>) -> Void)? { get set }
    /// The closure to execute once for every deleted record
    var perRecordDeleteBlock: ((_ recordID: CKRecord.ID, _ deleteResult: Result<Void, Error>) -> Void)? { get set }
    /// The closure to execute once for every saved record
    var perRecordSaveBlock: ((_ recordID: CKRecord.ID, _ saveResult: Result<CKRecord, Error>) -> Void)? { get set }
}
/// Shadow protocol to bridge CKDatabaseOperationProtocol.OperationType ==> CKContainerProtocol.DatabaseType.OperationType
public protocol AnyCKDatabaseProtocol {
    /// - Receives a parameter of Concrete Type `Any`
    func add(_ operation: Any)
}
/// Protocol for CKDatabase interoperability
/// Uses `AnyCKDatabaseProtocol` shadow protocol for type conversion. This acts as a bridge between CloudStorable
/// and the operations that extend DatabaseOperational to a common OperationType.
public protocol CloudStorable: AnyCKDatabaseProtocol {
    associatedtype OperationType: DatabaseOperational
    /// Keep track of last executed query for testing purposes
    var lastExecuted: MockCKDatabaseOperation? { get set }
    /// - Receives a parameter of Concrete Type that is a `DatabaseOperational`
    func add(_ operation: OperationType)
}
/// Default extension to conform to `DatabaseOperational` by using `AnyCKDatabaseProtocol` for type erasure
extension CloudStorable {
    public func add(_ operation: Any) {
        // ensure that we partition CloudKit operations from MCF ones
        if let operation = operation as? OperationType {
            add(operation)
        } else {
            // convert CKDatabaseOperation types to MockCKDatabaseOperation (but never the opposite)
            let mockDB = self as! MockCloudKitFramework.MockCKDatabase
            if let ckDatabaseOperation = operation as? CKFetchRecordsOperation {
                let mockOp = ckDatabaseOperation.getMock(database: mockDB)
                add(mockOp)
            } else if let ckDatabaseOperation = operation as? CKQueryOperation {
                let mockOp = ckDatabaseOperation.getMock(database: mockDB)
                 add(mockOp)
            } else if let ckDatabaseOperation = operation as? CKModifyRecordsOperation {
                let mockOp = ckDatabaseOperation.getMock(database: mockDB)
                 add(mockOp)
            } else {
                fatalError("Unknown operation attempted to convert to its mock counterpart: \(operation)")
            }
        }
    }
}
/// Used only for NSObject conformance so that we can use Key-Value Coding
public protocol DatabaseOperational: NSObject {
    associatedtype DatabaseType: CloudStorable
    var database: DatabaseType? { get set }
    /// The operation's configuration - inherited from `CKOperation`
    var configuration: CKOperation.Configuration! { get set }
    /// The custom completion block. Always the last block to be called. inherited from `Operation`
    var completionBlock: (() -> Void)? { get set }
}
extension MockCKDatabaseOperation {
    public typealias DatabaseType = MockCKDatabase
}
/// Protocol for CKContainer interoperability
public protocol CloudContainable {
    associatedtype DatabaseType: CloudStorable
    var containerIdentifier: String? { get }
    func database(with databaseScope: CKDatabase.Scope) -> DatabaseType
    func accountStatus(completionHandler: @escaping (CKAccountStatus, Error?) -> Void)
    func fetchUserRecordID(completionHandler: @escaping (CKRecord.ID?, Error?) -> Void)
}
#endif
协议扩展

然后将以下协议扩展复制到同一模块中。 这使用 MCF 的一组通用协议来扩展 CloudKit

# if DEBUG
// ========================================
// MARK: CloudKit MCF protocol extensions
// ========================================
// These extensions make CloudKit comply with MCF protocols
extension CKContainer: CloudContainable {}
extension CKDatabase: CloudStorable {
    // only for state tracking in mock operations
    public var lastExecuted: MockCKDatabaseOperation? {
        get {
            return nil
        }
        set(newValue) {
            // nothing to do
        }
    }
}
extension CKDatabaseOperation: DatabaseOperational {
    public typealias DatabaseType = CKDatabase
}
extension CKFetchRecordsOperation: CKFetchRecordsOperational {}
extension CKQueryOperation: CKQueryOperational {}
extension CKModifyRecordsOperation: CKModifyRecordsOperational {}
// ====================== CloudKit MCF protocol extensions
#endif
设置测试目标

您的项目和测试目标不共享环境,因此您需要做的就是将 MCF 导入到您的测试类中

import MockCloudKitFramework
设置 IOC

调用 CloudKit 的类、结构和方法必须实现为泛型。 更准确地说,如果您检查协议和协议扩展集,您将看到以下 CloudKit 类(及其 MCF 模拟对应项)必须被类型化为它们指定的协议

协议 CloudKit 类名 MCF 类名
CloudContainable CKContainer MockCKContainer
CloudStorable CKDatabase MockCKDatabase
DatabaseOperational CKDatabaseOperation MockCKDatabaseOperation
CKModifyRecordsOperational CKModifyRecordsOperation MockCKModifyRecordsOperation
CKFetchRecordsOperational CKFetchRecordsOperation MockCKFetchRecordsOperation
CKQueryOperational CKQueryOperation MockCKQueryOperation

因此,您可能有一个泛型类,它通过它们的公共协议 CloudContainable 接受 CKContainer 或 MockCKContainer

class CloudController<Container: CloudContainable>: ObservableObject {
    let cloudContainer: Container
    let database: Container.DatabaseType

    init(container: Container, databaseScope: CKDatabase.Scope) {
        self.cloudContainer = container
        self.database = container.database(with: databaseScope)
    }

提示:对于任何方法,根本不需要更改任何内容! MCF 在后台将 CloudKit 操作转换为 MCF 操作。

话虽如此,你总是可以将一个操作注入到泛型方法中(可能是因为 MCF 不支持给定操作的某些功能)。在这里,我们可以传入 CKFetchRecordsOperation 或 MCF MockCKFetchRecordsOperation - 它们都符合 CKFetchRecordsOperational 协议。

func doSomething<O: CKFetchRecordsOperational> (
    cKFetchRecordsOperation: O,
    _ completion: @escaping (Bool) -> Void) {
        var dbOperation = cKFetchRecordsOperation

        // fetchRecordsResultBlock always gets called when finished processing.
        dbOperation.fetchRecordsResultBlock = { result in
            if let _ = record {
                completion(true)
            } else {
                completion(false)
            }
        }

        database.add(dbOperation)
    }
}

更多示例

请参阅 MockCloudKitTestProject,它是与 MockCloudKitFramework 相关的测试项目,其中包含多个关于如何在你的项目中使用 MockCloudKitFramework 的单元和集成测试示例(附带文档)。

归属

以下资源为我构建 MCF 提供了必要的背景知识:

这篇文章启发了我关于如何模拟 CloudKit 对象的思考:

这些资源帮助我整理了 CloudKit 错误的处理方式:

将 DocC 归档文件转换为静态网站的实用程序