How do you write a unit test that catches a leak?
Predict first
What does this print?
final class Widget {}
weak var w: Widget?
do {
let widget = Widget()
w = widget
}
print(w == nil)
- true
- false
Predict first
What does this print for a cycle?
final class Widget { var me: Widget? }
weak var w: Widget?
do {
let widget = Widget()
widget.me = widget
w = widget
}
print(w == nil)
- true
- false
Mental model. You hand an object to a tester, take your hands away, and check whether it vanishes. If it is still there, someone else is holding it.
The idea is to hold a weak reference, release every strong one, and assert the weak reference became nil. Create the object under test in an inner scope (or an autoreleasepool if it uses Objective-C APIs), take weak var sut = object, let the strong reference go, then XCTAssertNil(sut). A cycle keeps it alive, so the assertion fails and names the leak. In XCTest the common helper registers the check in addTeardownBlock so every test that builds the object also verifies it was freed.
Make the test honest: the object must be built and exercised the way production does (set the delegate, start the timer, subscribe), or the leak path never runs. Beware false results from your own variables: a local let that is still in scope keeps the object alive, so structure the scope deliberately. Pair this with a deinit log when debugging. These tests are cheap, and they stop a regression that a profiler would only find weeks later.
The only strong reference was the local inside the do block. Once it ends, the object is freed and the weak reference is nil: no leak.
The widget holds itself, so its count never drops to zero. The weak reference is still non-nil: the test would fail and report the leak.
Common traps
- Keeping a strong local variable in the test. It keeps the object alive and hides the real result, or causes a false failure.
- Testing an object that never started its timers and subscriptions. The leak path never runs, so the test passes uselessly.
They might follow up with
- Why use
addTeardownBlock? It runs after the test body, once the test’s own references are gone. - Does this find abandoned memory? Only if the owner has actually been released in the test. It targets cycles and unintended long-lived references.
Related questions
- 3.3.1 What is a retain cycle, and how do you find and fix one?
- 3.7.2 How do you find a retain cycle with the Memory Graph debugger?
← How do you use Instruments’ Allocations, Leaks and VM Tracker to… What is memory footprint, and what gets an iOS app killed? →