You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何对Contacts Framework的CNContactStore进行单元测试?

单元测试fetchContacts方法的完善方案

我第一次做单元测试,要测试自己写的fetchContacts方法(该方法通过CNContactStore获取联系人),已经用Sourcery生成了CNContactStore的Mock类,目前写了部分测试代码,但不知道怎么完善,希望得到技术帮助。

相关代码如下:

待测试代码

private func fetchContacts() {
    var contacts: [Contact] = []
    let keys: [CNKeyDescriptor] = [CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
                                   CNContactPhoneNumbersKey as CNKeyDescriptor]
    let request = CNContactFetchRequest(keysToFetch: keys)
    
    
    do {
        try contactStore.enumerateContacts(with: request) {
            (contact, stop) in
            let name: String = CNContactFormatter.string(from: contact, style: .fullName) ?? contact.nickname
            contacts.append(contentsOf: contact.phoneNumbers.compactMap({ phoneNumber in
                let phoneNumberString: String = phoneNumber.value.stringValue
                return .init(name: name, phoneNumber: phoneNumberString)
            }))
        }
        
        allContacts = contacts
        isContactsFetched = true
        filterContacts()
    }
    catch {
        print("unable to fetch contacts")
    }
}

Sourcery生成的Mock代码

//MARK: - enumerateContacts

var enumerateContactsWithUsingBlockThrowableError: Error?
var enumerateContactsWithUsingBlockCallsCount = 0
var enumerateContactsWithUsingBlockCalled: Bool {
    return enumerateContactsWithUsingBlockCallsCount > 0
}
var enumerateContactsWithUsingBlockReceivedArguments: (fetchRequest: CNContactFetchRequest, block: (CNContact, UnsafeMutablePointer<ObjCBool>) -> Void)?
var enumerateContactsWithUsingBlockReceivedInvocations: [(fetchRequest: CNContactFetchRequest, block: (CNContact, UnsafeMutablePointer<ObjCBool>) -> Void)] = []
var enumerateContactsWithUsingBlockClosure: ((CNContactFetchRequest, @escaping (CNContact, UnsafeMutablePointer<ObjCBool>) -> Void) throws -> Void)?

func enumerateContacts(with fetchRequest: CNContactFetchRequest, usingBlock block: @escaping (CNContact, UnsafeMutablePointer<ObjCBool>) -> Void) throws {
    if let error = enumerateContactsWithUsingBlockThrowableError {
        throw error
    }
    enumerateContactsWithUsingBlockCallsCount += 1
    enumerateContactsWithUsingBlockReceivedArguments = (fetchRequest: fetchRequest, block: block)
    enumerateContactsWithUsingBlockReceivedInvocations.append((fetchRequest: fetchRequest, block: block))
    try enumerateContactsWithUsingBlockClosure?(fetchRequest, block)
}

已编写的测试代码

it("should fetch contacts") {
                        
    let contact = CNContact()
    let stop = UnsafeMutablePointer<ObjCBool>.allocate(capacity: 1)
    stop[0] = true
                        
    // When
    viewModel.onViewDidAppear()
                        
    // Then
    mockContactStore.enumerateContactsWithUsingBlockClosure = { (_, args) in
                                args(contact, stop)
                                
                                
                                
expect(mockContactStore.enumerateContactsWithUsingBlockCallsCount).to(equal(1))
  }
}

完善测试的步骤和代码

1. 修正测试代码执行顺序

当前测试代码顺序存在问题:先调用viewModel.onViewDidAppear(),才设置Mock的enumerateContactsWithUsingBlockClosure,这会导致fetchContacts执行时Mock闭包还未配置,无法触发回调。必须先设置Mock闭包,再调用触发方法。

2. 构造有效的测试用CNContact数据

空的CNContact无法生成真实的姓名和手机号,需要手动创建带属性的测试联系人:

// 封装测试联系人创建方法
func createTestContact(givenName: String, familyName: String, phoneNumbers: [String]) -> CNContact {
    let contact = CNMutableContact()
    contact.givenName = givenName
    contact.familyName = familyName
    contact.phoneNumbers = phoneNumbers.map {
        CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0))
    }
    return contact as CNContact
}

3. 完善正常场景测试

验证fetchContacts执行后,ViewModel的状态是否符合预期:

it("should fetch contacts and update viewModel state") {
    // Given
    let testContact = createTestContact(givenName: "张三", familyName: "李四", phoneNumbers: ["13800138000", "13900139000"])
    let stop = UnsafeMutablePointer<ObjCBool>.allocate(capacity: 1)
    stop[0] = false // 允许枚举执行
    
    // 先配置Mock闭包
    mockContactStore.enumerateContactsWithUsingBlockClosure = { (_, block) in
        block(testContact, stop)
        stop[0] = true // 执行后停止枚举
    }
    
    // When
    viewModel.onViewDidAppear()
    
    // Then
    expect(mockContactStore.enumerateContactsWithUsingBlockCallsCount).to(equal(1))
    // 验证联系人数量:1个联系人对应2个手机号,所以应该生成2条Contact数据
    expect(viewModel.allContacts.count).to(equal(2))
    // 验证第一条联系人数据正确性
    let firstContact = viewModel.allContacts.first
    expect(firstContact?.name).to(equal("张三 李四"))
    expect(firstContact?.phoneNumber).to(equal("13800138000"))
    // 验证状态标记更新
    expect(viewModel.isContactsFetched).to(beTrue())
    
    // 清理内存
    stop.deallocate()
}

4. 测试错误场景

验证当CNContactStore抛出错误时,fetchContacts的错误处理逻辑:

it("should handle fetch contact error") {
    // Given
    let testError = NSError(domain: "ContactError", code: -1, userInfo: [NSLocalizedDescriptionKey: "权限不足"])
    mockContactStore.enumerateContactsWithUsingBlockThrowableError = testError
    
    // When
    viewModel.onViewDidAppear()
    
    // Then
    expect(mockContactStore.enumerateContactsWithUsingBlockCallsCount).to(equal(1))
    // 验证错误场景下状态未更新
    expect(viewModel.isContactsFetched).to(beFalse())
    expect(viewModel.allContacts).to(beEmpty())
}

5. 验证请求参数正确性

额外验证fetchContacts创建的CNContactFetchRequest是否使用了正确的Key:

it("should use correct keys in CNContactFetchRequest") {
    // Given
    var receivedRequest: CNContactFetchRequest?
    mockContactStore.enumerateContactsWithUsingBlockClosure = { (request, block) in
        receivedRequest = request
        // 停止枚举
        let stop = UnsafeMutablePointer<ObjCBool>.allocate(capacity: 1)
        stop[0] = true
        block(CNContact(), stop)
        stop.deallocate()
    }
    
    // When
    viewModel.onViewDidAppear()
    
    // Then
    expect(receivedRequest?.keysToFetch).to(contain(
        CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
        CNContactPhoneNumbersKey as CNKeyDescriptor
    ))
}

内容的提问来源于stack exchange,提问作者ferryawijayanto

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.21 17:51:17