iosswiftnotificationcenter

How to setup test removing a block-based observer via an object's deinit?


Say I have this code:

class Foo {

    private let nc: NotificationCenter
    
    init(nc: NotificationCenter = .default) {
        self.nc = nc
        
        nc.addObserver(forName: Notification.Name.Foo, object: nil, queue: .main) { _ in
            Task { [weak self] in
                self?.doSomething()
            }
        }
    }

    deinit {
        nc.removeObserver(self, name: Notification.Name.Foo, object: nil)
    }

    @objc
    private func doSomething() async {
        // triggers some async code
    }
}

I want to write a unit test to ensure this observer is removed when the class is deallocated.

The Apple docs specify:

If you used addObserver(forName:object:queue:using:) to create your observer, you should call this method or removeObserver(_:name:object:) before the system deallocates any object that addObserver(forName:object:queue:using:) specifies.

I've mocked a NotificationCenter:

class MockNotificationCenter: NotificationCenter {

    var notifications: [NSNotification.Name?] = []

    override func addObserver(
        forName name: NSNotification.Name?,
        object obj: Any?,
        queue: OperationQueue?,
        using block: @escaping (Notification) -> Void) -> NSObjectProtocol
    {
        notifications.append(name)
        return super.addObserver(forName: name, object: obj, queue: queue, using: block)
    }

    override func removeObserver(
        _ observer: Any, 
        name aName: NSNotification.Name?, 
        object anObject: Any?
    ) {
        notifications = notifications.filter { $0 != aName }
        super.removeObserver(observer, name: aName, object: anObject)
    }

And here's my unit test:

    func testDeinit_DoesRemoveObserver() {
        // Given
        var sut: Foo?
        let mockNC = MockNotificationCenter()
        // When
        sut = Foo(notificationCenter: mockNC)
        // Then
        XCTAssertEqual(mockNC.notifications.count, 1) // Succeeds
        // When
        sut = nil
        // Then
        XCTAssertEqual(mockNC.notifications.count, 0) // Fails
    }

XCTAssertEqual failed: ("1") is not equal to ("0")

How would I fix this to assert that an observer has been removed from the notification center upon deallocation of my observing object?


Solution

  • The problem is there is a retain cycle in your Foo class. In the addObserver closure, you're capturing the Foo class instance itself. It should be:

    nc.addObserver(forName: Notification.Name.Foo, object: nil, queue: .main) { [weak self] _ in
        Task {
            self?.doSomething()
        }
    }
    

    At the time sut was assigned to nil, the deinit and also removeObserver didn't get called, and the notification array still remains one element.