Swift中的模拟方法

z6psavjg  于 2023-01-12  发布在  Swift
关注(0)|答案(1)|浏览(157)

我在swift中有以下A类和B类:

protocol ClassBProtocol {
  func doSomething(
    completionHandler: @escaping (
      ClassBProtocol?,
      Error?
    ) -> Void
  )
}

class ClassB: ClassBProtocol
{
  init(key: String) {
      self.key = key
  }

  func doSomething(
    completionHandler: @escaping (
      ClassBProtocol?,
      Error?
    ) -> Void
  ) {
    // Does some network requests and if it was successful does the following:
    completionHandler(ClassB(), nil)
  }
}
public class ClassA: ClassAProtocol {

  static var instance: ClassA? = nil
  static let initQueue = DispatchQueue(label: "queue")
  static let semaphore = DispatchSemaphore(value: 1)

  @objc public static func getSomething(
    withKey key: String,
    completionHandler: @escaping (ClassA?, Error?) -> Void
  ) {
    ClassA.initQueue.async {
      ClassA.semaphore.wait()
      DispatchQueue.main.async {
        if let objectA = ClassA.instance {
          ClassA.semaphore.signal()
          completionHandler(objectA, nil)
          return
        }

        let objectB = ClassB(withKey: key)

        objectB.doSomething { response, error in
          guard let response = response else {
            ClassA.semaphore.signal()
            completionHandler(nil, error)
            return
          }
          let objectA = ClassA()
          ClassA.instance = objectA
          ClassA.semaphore.signal()

          completionHandler(objectA, nil)
        }
      }
    }
  }
}

以下测试用例用于测试ClassA. getSomething()并确保不会发生争用情况:

func testgetSomethingReturnsSameInstance() {
    let expectation1 = self.expectation(description: "getSomething 1 completed")
    let expectation2 = self.expectation(description: "getSomething 2 completed")
    let expectation3 = self.expectation(description: "getSomething 3 completed")

    var client1: ClassA?
    var client2: ClassA?
    var client3: ClassA?

    ClassA.getSomething() { (client, error) in
      client1 = client
      expectation1.fulfill()
    }

    ClassA.getSomething() { (client, error) in
      client2 = client
      expectation2.fulfill()
    }

    ClassA.getSomething() { (client, error) in
      client3 = client
      expectation3.fulfill()
    }

    waitForExpectations(timeout: 10) { (error) in
      XCTAssertEqual(client1, client2)
      XCTAssertEqual(client2, client3)
      XCTAssertEqual(client1, client3)
    }
  }

ClassB中的doSomething发送一个网络请求并返回一个对象,我需要模拟这个方法来完成以下操作:

DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
  completionHandler(response: ClassB(), error: nil)
}

但找不到任何方法。有人能解决这个问题吗?

7kqas0il

7kqas0il1#

如果使用合理的名称,代码会更容易理解。我怀疑ClassB Package 了一个API,并且ClassA保存了一些值(如数据库),您希望从API中只提取一次。因此,让我们相应地重命名ClassBProtocolClassB

protocol API {
  func fetch(completion: @escaping (Result<Data, Error>) -> Void)
}

class LiveAPI: API {
  let key: String

  init(key: String) {
    self.key = key
  }

  func fetch(completion: @escaping (Result<Data, Error>) -> Void) {
    // Do some network requests to get the real `Data` or a real error, and then:
    completion(.success(Data()))

    // or in case of error:
    // completion(.failure(error))
  }
}

然后,让我们将ClassA重命名为Database,并暂停getSomething,只留下以下内容:

public class Database: NSObject {
  private init(data: Data) {
    // construct myself from the raw data
  }
}

现在,如何在第一次请求数据库时只取一次数据库?
Apple工程师的一般建议是避免在发送到调度队列的块中潜在地阻塞操作。在这种情况下,semaphore.wait()是潜在地阻塞操作。
此外,同步代码比异步代码更容易测试,但您已经将所有内容都设置为异步。您的getSomething所做的第一件事是异步调度,并且大量状态(挂起的完成处理程序集)隐藏在Dispatch数据结构中,我们无法访问。
我们不使用semaphoreinitQueue,而是手动地同步跟踪在数据库被提取时需要调用的完成处理程序。

  • 尚未开始提取数据库。
  • 我们已经开始获取数据库,但它仍在下载,并且在下载完成时需要调用一个或多个完成处理程序。
  • 我们已完成数据库的提取,但没有可调用的完成处理程序。

我们将使用enum存储这三个互斥状态,并使用DispatchQueue保护对存储状态的访问:

extension Database {
  // All access to q_fetchState must be on q!
  private static var q_fetchState: FetchState = .unstarted
  private static let q = DispatchQueue(label: "initQueue")

  private typealias Completion = (Result<Database, Error>) -> Void

  private enum FetchState {
    case unstarted
    case started([Completion])
    case done(Result<Database, Error>)
  }
}

当请求数据库时,我们检查状态并采取适当的行动:

extension Database {
  @objc
  public static func getDatabase(
    apiKey key: String,
    completion objc_completion: @escaping (Database?, Error?) -> Void
  ) {
    let completion: Completion = {
      switch $0 {
      case .failure(let error): objc_completion(nil, error)
      case .success(let database): objc_completion(database, nil)
      }
    }

    q.sync {
      switch q_fetchState {
      case .unstarted:
        q_fetchState = .started([completion])
        DispatchQueue.main.async {
          let api = LiveAPI(key: key)
          api.fetch { result in
            let result = result.map { Database(data: $0) }

            let completions = q.sync {
              guard case .started(let completions) = q_fetchState else {
                preconditionFailure()
              }
              q_fetchState = .done(result)
              return completions
            }

            for completion in completions {
              completion(result)
            }
          }
        }

      case .started(let array):
        q_fetchState = .started(array + [completion])

      case .done(let result):
        DispatchQueue.main.async {
          completion(result)
        }
      }
    }
  }
}

请注意,在q下没有执行阻塞操作,因此使用q.sync代替q.async是安全和高效的,稍后我们将看到它使函数更易于测试。
好吧,现在回到你真正的问题,我解释为:既然我们已经有了一个API协议,我们想让getDatabase泛型化一个符合API的类型,并让它接受该类型的一个示例:

extension Database {
  static func getDatabase<A: API>(
    api: A,
    completion: @escaping (Result<Database, Error>) -> Void
  ) {
    q.sync {
      switch q_fetchState {
      case .unstarted:
        q_fetchState = .started([completion])
        DispatchQueue.main.async {
          api.fetch { result in
            let result = result.map { Database(data: $0) }

            let completions = q.sync {
              guard case .started(let completions) = q_fetchState else {
                preconditionFailure()
              }
              q_fetchState = .done(result)
              return completions
            }

            for completion in completions {
              completion(result)
            }
          }
        }

      case .started(let array):
        q_fetchState = .started(array + [completion])

      case .done(let result):
        DispatchQueue.main.async {
          completion(result)
        }
      }
    }
  }
}

这些更改意味着该方法不再与Objective-C兼容。因此,让我们添加一个带有旧的Objective-C兼容签名的重载:

@objc
  public static func getDatabase(
    apiKey key: String,
    completion: @escaping (Database?, Error?) -> Void
  ) {
    return getDatabase(api: LiveAPI(key: key)) {
      switch $0 {
      case .failure(let error): completion(nil, error)
      case .success(let database): completion(database, nil)
      }
    }
  }
}

现在我们准备编写一个API的模拟实现,根据您发布的代码,它看起来如下所示:

struct BadTestAPI: API {
  let result: Result<Data, Error>

  func fetch(completion: @escaping (Result<Data, Error>) -> Void) {
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
      completion(result)
    }
  }
}

但我不喜欢这种实现,至少有三个原因:

  • 它硬编码了0.5秒的延迟。讨厌。我们希望测试用例运行得尽可能快!
  • 要验证fetch只被调用一次并不容易。
  • 当它调用完成处理程序时,我们不能更精确地控制它。

相反,让我们这样编写模拟实现:

class TestAPI: API {
  let ex = XCTestExpectation(description: "api.fetch called")
  var completion: ((Result<Data, Error>) -> Void)? = nil

  func fetch(completion: @escaping (Result<Data, Error>) -> Void) {
    XCTAssertNil(self.completion)
    self.completion = completion
    ex.fulfill()
  }
}

现在我们可以编写测试用例来使用这个实现:

final class TestDatabase: XCTestCase {
  func testGetDatabaseReturnsSameInstance() {
    class Record {
      let ex = XCTestExpectation()
      var database: Database? = nil
    }

    let api = TestAPI()
    let records = [Record(), Record(), Record()]

    XCTAssertNil(api.completion)

    for record in records {
      Database.getDatabase(api: api) {
        XCTAssertNil(record.database)
        record.database = try! $0.get()
        record.ex.fulfill()
      }
    }

    self.wait(for: [api.ex], timeout: 10)

    for record in records {
      XCTAssertNil(record.database)
    }

    api.completion!(.success(Data()))

    wait(for: records.map(\.ex), timeout: 10)

    XCTAssertNotNil(records[0].database)
    for record in records.dropFirst() {
      XCTAssertEqual(record.database, records[0].database)
    }
  }
}

以下是本测试用例验证的一些内容:

  • Database.getDatabase之前不调用api.fetch
  • api.fetch只被调用一次。
  • 没有完成处理程序被调用一次以上。
  • getDatabase完成处理程序在api.fetch完成处理程序之后调用。

相关问题