Igor Kulman

Unit testing view controller memory leaks

· Igor Kulman

After adding a new feature to the iOS app I currently works on I noticed an unexpected memory spike after the app was used for a while. This usually means a memory leak; some object not being deallocated after it is no longed need. This is often caused by using self without unowned / weak or by forgetting to make the delegates weak (tools like SwiftLint can warn you about this case).

In my case the problem was a UIViewController not being deallocated after being removed from the navigation stuck because of a error in a binding. I found the bug using the Instruments in Xcode but it got me ask some questions. What if there are memory leaks in other parts for the app, in flows that are not used so much? Is there a way to somehow automatically test for memory leaks? I found SpecLeaks as the best way to answer those questions.

SpecLeaks

SpecLeaks is a framework build on top of Quick and Nimble that helps you to unit test memory leaks in Swift. You can use it to unit test memory leaks in any kind of objects, I chose to unit test my view controllers because they seemed to be most probable cause of memory leaks in my apps.

SpecLeaks can detect that your are testing a UIViewController and also call viewDidLoad to fully initialize the UIViewController. A simple memory leak test may then look like this

class SomeViewControllerTests: QuickSpec {
    override func spec() {
        describe("SomeViewController") {
            describe("viewDidLoad") {
                let vc = LeakTest {
                    return SomeViewController()
                }
                it("must not leak"){
                    expect(vc).toNot(leak())
                }
            }
        }
    }
}

You can initialize your view controllers using init or get them from story boards, it does not matter. The unit tests will fail for every leaking view UIViewController.

If you think some of your methods may be causing a memory leak when being called at same later point (because they use closures for example), you can also test for that

class SomeViewControllerTests: QuickSpec {
    override func spec() {
        describe("SomeViewController") {
            describe("viewDidLoad") {
                let vc = LeakTest {
                    return SomeViewController()
                }
                it("must not leak"){
                    expect(vc).toNot(leak())
                }
            }
            describe("calling 'suspiciousMethod'") {
                let vc = LeakTest {
                      return SomeViewController()
                }

                let methodCalled : (SomeViewController) -> ()  = { obj in
                  obj.suspiciousMetod()
                }

                expect(vc).toNot(leakWhen(methodCalled))
            }
        }
    }
}

Practical example

In my project I use Swinject for Dependency Injection, so in my unit test I just need to set up the Dependency Injection container with mocks or stubs instead of real services

extension ViewControllerLeakTests {
    func setupDependencies() -> Container {
        let container = Container()

        // services
        container.autoregister(SettingsService.self, initializer: SettingsServiceMock.init).inObjectScope(ObjectScope.container)
        container.autoregister(DataService.self, initializer: DataServiceMock.init).inObjectScope(ObjectScope.container)

        ...
        return container
    }
}

and then I can initialize the view controllers the exact same way as in the main application.

class ViewControllerLeakTests: QuickSpec {
    override func spec() {
        let container = setupDependencies()

        describe("AboutViewController") {
            describe("viewDidLoad") {
                let vc = LeakTest {
                    return container.resolveViewController(AboutViewController.self)
                }
                it("must not leak") {
                    expect(vc).toNot(leak())
                }
            }
        }

        describe("LibrariesViewController") {
            describe("viewDidLoad") {
                let vc = LeakTest {
                    return container.resolveViewController(LibrariesViewController.self)
                }
                it("must not leak") {
                    expect(vc).toNot(leak())
                }
            }
        }
        ...
    }
}        

where resolveViewController is an extension method initializing the UIViewController from the right story board, settings its view model if needed, etc.

To see the implementation details, check out my iOSSampleApp in Github.


See also