swiftxctestretain-cyclexctestcasestrong-reference-cycle

How to test an object is deallocated?


I have some mock code that performs a retain cycle (or at least in my mind it should). Here it is:

protocol MyService {
    func perform(completion: @escaping () -> Void)
}

class MyServiceImpl: MyService {
    func perform(completion: @escaping () -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: completion)
    }
}

class MyObject {
    let service: MyService
    var didComplete = false

    init(service: MyService) {
        self.service = service
    }

    func doSomething(completion: @escaping () -> Void) {
        service.perform {
            self.didComplete = true
            completion()
        }
    }
}

Notice in the MyObject.doSomething() method we capture self strongly in the service completion. This should result in a retain cycle, since MyObject is holding a reference to service and service holds a reference to MyObject. (Please enlighten me if I'm wrong.

Next, we write our test to catch this memory leak:

final class DemoTests: XCTestCase {

    func test_demo() {
        let service = MyServiceImpl()
        let myObject = MyObject(service: service)
        addTeardownBlock { [weak myObject, weak service] in
            XCTAssertNil(myObject)
            XCTAssertNil(service)
        }
        let exp = expectation(description: "wait for complete")
        
        myObject.doSomething {
            exp.fulfill()
        }
        
        wait(for: [exp], timeout: 1)
        XCTAssertTrue(myObject.didComplete)
    }
}

This test is passing. It shouldn't.

What am I doing wrong, or what is that I don't understand about retain cycles or the XCTest framework?

Thank you for your help!


Solution

  • There is no retain cycle here. The closure indeed captures self strongly, and self also keeps a strong reference to MyServiceImpl. However, when the closure is passed to MyServiceImpl, MyServiceImpl does not keep a strong reference of the closure. It simply passes it to DispatchQueue, which will promptly discard the closure after it's done running. The graph looks like of like this:

    DispatchQueue.main ---> closure ---> MyObject ----> MyServiceImpl
    

    To get a retain cycle, MyService can keep a reference to the closure:

    class MyServiceImpl: MyService {
        
        var closure: (() -> Void)?
        func perform(completion: @escaping () -> Void) {
            closure = completion // note this line
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: completion)
        }
    }
    

    Now there is a retain cycle and your test fails. You can also see this in the memory graph debugger in Xcode.

    enter image description here