测试无法测试的几乎苹果api实时搜索示例

First of all let's make it clear about what a live search is. In a short definition, we could say it is a search which enables continuous input keeping always the most current one as the searched term.

首先,让我们弄清楚什么是实时搜索。 简而言之,我们可以说这是一种搜索,它使连续输入始终保持最新的搜索词为搜索词。

It's totally aligned with some user experience principles as the user will not get blocked during changing his decision regarding what's being searched, even when the request is already running (spoiler).

这完全符合一些用户体验原则,因为即使在请求已在运行(扰流器)的情况下,用户在更改搜索内容的决定时也不会受到阻碍。

As it looks like, what really differs a live search is to cancelling the previous request (the running one), replacing it by a new one containing the brand new input term.

看起来,实时搜索的真正区别是取消先前的请求(正在运行的请求),然后用包含全新输入项的新请求替换它。

Testing it took me some good hours in order to understand how we test a cancel method of a URLSessionDataTask when its indeed being called during some flow or even better (or worst), how its almost inaccessible due some imposed visibility constrains by Apple.

测试花费了我一些时间,以了解我们如何测试URLSessionDataTaskcancel方法,当它在某个流程中甚至在更好(或更糟)的情况下确实被调用时,由于苹果的某些可见性约束而几乎无法访问。

So here its the point about what we want to test.

所以这就是我们要测试的重点。

func cancelAllTasks() {
  session.getAllTasks { tasks in
    tasks.forEach { $0.cancel() }
  }
}

Seems a bit weird this harmless method being the main actor of the current article but everyone can have their 15 minutes of fame and that guy currently have a lot to share with us.

这种无害的方法似乎是本文的主要参与者,这似乎有些怪异,但是每个人都可以享有15分钟的成名时间,而这个家伙目前可以与我们分享很多东西。

Let's dive about how to guarantee that each returned task got its own cancel method called, adding some unit tests to it.

让我们深入探讨如何确保每个返回的任务都有自己的cancel方法,并为其添加一些单元测试。

第一种方法-易失性 (First approach — theVolatile)

A first and intuitive approach would be basically:

第一种直观的方法基本上是:

func test_cancellAllTasks_givenExistsActiveTasks_shouldCancellAllOfThem() {
  let sut = Network( parameters ... )
        
  sut.session.dataTask(with: URL(fileURLWithPath: "lorem/ipsum")).resume()
  sut.session.dataTask(with: URL(fileURLWithPath: "foo/bar")).resume()
  
  ...
}
  • Assert the sut's session dataTasks count matches the number of created dataTasks;

    断言sut的会话dataTasks计数与创建的dataTasks数量匹配;

  • Call the sut method in order to cancel all previous created tasks;

    调用sut方法以取消所有先前创建的任务;

func test_cancellAllTasks_givenExistsActiveTasks_shouldCancellAllOfThem() {
  ...


  let getTasksExpectation = XCTestExpectation(description: "getTasksExpectation")
        
  var activeTasksCount: Int?
        
  sut.session.getAllTasks { tasks in
    activeTasksCount = tasks.count
    getTasksExpectation.fulfill()
  }
        
  wait(for: [getTasksExpectation], timeout: 0.2)
        
  XCTAssertEqual(activeTasksCount, 2)


  sut.cancelAllTasks()
}
  • Assert the sut’s session dataTasks count matches zero since we've cancelled all tasks;

    断言sut的会话数据任务计数匹配零,因为我们已经取消了所有任务;

func test_cancellAllTasks_givenExistsActiveTasks_shouldCancellAllOfThem() {
  ...


  let cancelTasksExpectation = XCTestExpectation(description: "cancelTasksExpectation")
        
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    sut.session.getAllTasks { tasks in
      activeTasksCount = tasks.count
      cancelTasksExpectation.fulfill()
    }
  }
  
  wait(for: [cancelTasksExpectation], timeout: 0.2)
        
  XCTAssertEqual(activeTasksCount, 0)
}

对当前方法的一些担忧 (Some concerns about the current approach)

As we can observe, the current approach is running tests over real world context (without test doubles). I mean, we have no control about the data being moved over each method, we're just expecting everything works as it should.

正如我们所看到的,当前的方法是在现实环境中运行测试(没有测试加倍)。 我的意思是,我们无法控制通过每种方法移动的数据,我们只是期望一切都能正常进行。

Having no control about how data flows over our tests and beyond that tying it to escaping blocks, we are just landing on volatile context, providing all tolls to face flaky tests on a CI however stable it may looks like locally.

由于无法控制数据在测试中的流动方式,而且无法将其与逃逸的区块联系在一起,因此,我们只是在不稳定的环境中着陆,提供了所有费用以面对CI上的不稳定测试,无论它在本地看起来多么稳定。

第二种方法-令人失望 (Second approach — theDisappointing)

We're striving to achieve the beautiful test stability.

我们正在努力实现出色的测试稳定性。

An ideia would be creating a test double to a URLSession in order to stubbing session’s getAllTasks() method.

一个IDEIA将双要创建一个测试到URLSession以磕碰会话的 getAllTask​​s()方法。

final class URLSessionSpy: URLSession {    
  var tasksToBeReturned: [URLSessionTask] = []


  override func getAllTasks(completionHandler: @escaping ([URLSessionTask]) -> Void) {
    completionHandler(tasksToBeReturned)
  }
}

We could create a double to URLSessionDataTask as well and spying dataTask cancel method, in order to guarantee we’ve definitely called it.

我们也可以为URLSessionDataTask创建一个double并监视dataTask cancel方法,以确保我们确实调用了它。

final class URLSessionDataTaskSpy: URLSessionDataTask {
  private(set) var cancelCalled: Bool = false
    
  override func cancel() {
    cancelCalled = true
  }
}

Since we have doubles, testing tasks count gets unnecessary due the test double which enables us to assert exactly what we want, the call of cancel method. Look how simpler the test turns out.

由于我们有双打,因此由于测试双打而无需进行测试任务计数,因为测试双打使我们能够确切地声明我们想要的东西,即调用cancel方法。 看看测试变得多么简单。

func test_cancellAllTasks_givenExistsActiveTasks_shouldCancellAllOfThem() {
  let session = URLSessionSpy()
  let sut = Network(session: session)
        
  let loremTask = URLSessionDataTaskSpy()
  let ipsumTask = URLSessionDataTaskSpy()
  session.tasksToBeReturned = [loremTask, ipsumTask]        


  sut.cancelAllTasks()
        
  XCTAssertEqual(ipsumTask.cancelCalled, true)
  XCTAssertEqual(loremTask.cancelCalled, true)
}

Great! Isn't it?

大! 是不是

Running the test does not give us any failure over both asserts but actually the test at all. Yes, confusing. Let's check the log.

运行测试不会给我们两个断言带来任何失败,但实际上根本不会给我们带来任何失败。 是的,令人困惑。 让我们检查一下日志。

Task <D6E1DFDB-D7FA-4CCE-B75E-A76223D61FB3>.<5> finished with error [-1001] Error Domain=NSURLErrorDomain Code=-1001 “(null)” UserInfo={_NSURLErrorRelatedURLSessionTaskErrorKey=(“LocalDataTask <D6E1DFDB-D7FA-4CCE-B75E-A76223D61FB3>.<5>”), _NSURLErrorFailingURLSessionTaskErrorKey=LocalDataTask <D6E1DFDB-D7FA-4CCE-B75E-A76223D61FB3>.<5>}

Some points the highlight from it error. The code -1001 represents a timeout error. The error in a complete vision, represents a bad creation of the URLSession, which without a configuration, tends to get the faced scenario.

有些地方指出了错误的重点。 代码-1001表示超时错误。 完整的设想中的错误代表URLSession的错误创建,如果不进行配置,则很容易遇到这种情况。

If the problem is the configuration not being passed, let's just override the URLSession init on our double and pass it. Jumping to the class declaration, we got it:

如果问题是未通过配置,则让我们在double上覆盖URLSession初始化并将其传递。 跳转到类声明,我们得到了:

@available(iOS 7.0, *)
open class URLSession : NSObject {


  open class var shared: URLSession { get }
    
  public /*not inherited*/ init(configuration: URLSessionConfiguration)


  public /*not inherited*/ init(configuration: URLSessionConfiguration, delegate: URLSessionDelegate?, delegateQueue queue: OperationQueue?)
    
  open var delegateQueue: OperationQueue { get }


  open var delegate: URLSessionDelegate? { get }


  @NSCopying open var configuration: URLSessionConfiguration { get }
  ...
}

Seems we have a comment blowing up on our face, making our lifes harder, but we wont give up. As our plan was to override the init, we can conclude our goal got changed 😂

似乎我们的评论在脸上浮现,使我们的生活更加艰难,但我们不会放弃。 由于我们的计划是覆盖init,因此可以得出结论,我们的目标已更改😂

URLSession have public init and not open, making it not overridable, and beyond that, the configuration is a get only property. Tied hands.

URLSession具有公共 init而不是open ,因此它不可重写,并且除此之外,该配置是get only属性。 绑手。

第三种方法-解决方案 (Third approach — theSolution)

Alright, in one side we have a volatile approach where we faces a bunch of escaping blocks without control of what's being passed in the bowels of the code, on other hand we have control of that but at same time punching a wall of nails due a internal URLSession error.

好吧,一方面,我们采用了一种易变的方法,即面对一堆逃逸的块而无法控制代码肠中传递的内容,另一方面,我们可以控制它,但同时由于内部URLSession错误。

What would we do?

我们会做什么?

My suggestion and final implementation to the current problem is to use method swizzling. Basically we'll keep using the "real world" URLSession but replacing its method implementation we want to have control, by a fake one.

我对当前问题的建议和最终实现是使用方法混淆。 基本上,我们将继续使用“真实世界” URLSession,但用伪造的URLSession代替我们希望控制的方法实现。

YES! We are mixing a bit of the two previous scenarios in order to achieve a controlled context and finally test our dataTask cancel method.

是! 为了实现受控的上下文,我们将前面两种情况混合使用,最后测试了dataTask cancel方法

Initially we create an URLSession extension in order to add a static method responsible to swizzle the original and fake implementation.

最初,我们创建一个URLSession扩展,以添加一个负责混淆原始和伪造实现的静态方法。

extension URLSession {
  class func initSwizzle() {
    // TODO
  }
}

Method swizzling consists on get each instance method (original and fake), and call a function responsible to exchange both implementations, all of that on runtime. To do that we just need to pass the type of each object holding the implementation and the selectors indicating the respective methods like below.

方法混乱包括获取每个实例方法(原始方法和伪方法),并调用负责交换两个实现的函数,所有这些都在运行时进行。 为此,我们只需要传递包含实现的每个对象的类型以及指示如下各个方法的选择器。

extension URLSession {
  class func initSwizzle() throws {
    typealias ResultBlock = (@escaping ([URLSessionTask]) -> Void) -> Void
        
    let original = #selector((URLSession.getAllTasks(completionHandler:)) as (URLSession) -> ResultBlock)
    let swizzled = #selector((FakeURLSession._getAllTasks(completionHandler:)) as (FakeURLSession) -> ResultBlock)


    guard
      let originalMethod = class_getInstanceMethod(URLSession.self, original),
      let swizzledMethod = class_getInstanceMethod(FakeURLSession.self, swizzled)
      else { return }


    method_exchangeImplementations(originalMethod, swizzledMethod)
  }
}

The FakeURLSession is basically a stub class which provides us control of what's being returned on getAllTasks() method, completing immediately as it is called.

FakeURLSession基本上是一个存根类,它使我们可以控制getAllTask​​s()方法返回的内容,并在调用时立即完成。

class FakeURLSession: URLSession {
  static var tasksToBeReturned: [URLSessionTask] = []


  @objc func _getAllTasks(completionHandler: @escaping ([URLSessionTask]) -> Void) {
    completionHandler(Self.tasksToBeReturned)
  }
}

By that, our test will get pretty similar to the second approach, I've just added a new assert before the sut method call in order to check our spied value is indeed changing.

这样,我们的测试将与第二种方法非常相似,我只是在sut之前添加了一个新断言 为了检查我们的间谍值,方法调用的确在变化。

func test_cancellAllTasks_givenExistsActiveTasks_shouldCancellAllOfThem() {
  URLSession.initSwizzle()


  let sut = Network.shared
  let taskLorem = URLSessionTaskSpy()
  let taskIpsum = URLSessionTaskSpy()


  FakeURLSession.tasksToBeReturned = [taskLorem, taskIpsum]


  XCTAssertFalse(taskLorem.cancelCalled)
  XCTAssertFalse(taskIpsum.cancelCalled)
        
  sut.cancelAllTasks()


  XCTAssertTrue(taskLorem.cancelCalled)
  XCTAssertTrue(taskIpsum.cancelCalled)
}

Yaay! We are just on control again, defining what is being returned on getAllTasks() with no need to create custom async control and also matching final values of what we currently have as test goal.

耶! 我们再次处于控制状态,定义了getAllTask​​s()返回的内容, 无需创建自定义异步控件,也无需匹配当前作为测试目标的最终值。

注意事项 (Considerations)

There are many subjects on that post which we could get dived deeply but in other point make it longer as it already got. Anyway I have some great articles which would be of your interest.

该职位上有很多主题,我们可以深入探讨,但另一方面,请延长已有的主题。 无论如何,我有一些很棒的文章将对您感兴趣。

NSHipster — Method Swizzling

NSHipster —方法混乱

NSHint — Testing Camera on the simulator

NSHint —在模拟器上测试相机

Inside PSDPDFKit — Swizzling in Swift

PSDPDFKit内部—在Swift中泛滥成灾

Special thanks to my amazing team mates Henrique Galo and Giovane Possebon for the great review!

特别感谢我出色的队友Henrique GaloGiovane Possebon的出色评价!

Huge thanks for the reading, hope it could help you in some way ❤

非常感谢您的阅读,希望能对您有所帮助❤

翻译自: https://medium.com/@victormagalhes_50160/testing-untestable-almost-apples-api-live-search-example-f41478407d13

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值