Memory Leak Detection Using Swift Testing
Adding memory leak detection in your tests is a great practice to catch memory leaks early on. I caught countless crashes from retain…
Memory Leak Detection Using Swift Testing
To follow this tutorial, you need Swift 6.1+ and Xcode 16.3+ Tl;dr? There’s a link to an example repo at the bottom, close to the 👏 button.
Adding memory leak detection in your tests is a great practice to catch memory leaks early on. I caught countless crashes from retain cycles that this would have prevented.
The star of the show is the [TestScoping](https://developer.apple.com/documentation/testing/testscoping) protocol. It allows us to run code before or after each test using its [provideScope](https://developer.apple.com/documentation/testing/testscoping/providescope(for:testcase:performing:)) function.
Here’s how it works:
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @concurrent @Sendable () async throws -> Void
) async throws {
// Code to run before test
try await function()
// Code to run after test
}
Before we implement this, let’s build the LeakTracker class that does the actual leak detection.
The LeakTracker
The idea is simple: store weak references to objects. If an object is properly deallocated after the test, the weak reference becomes nil. If it’s still alive, we have a leak.
private final class LeakTracker: @unchecked Sendable {
private var trackedInstances: [(closure: () -> AnyObject?, sourceLocation: SourceLocation)] = []
func track<T: AnyObject>(_ instance: T, sourceLocation: SourceLocation) {
trackedInstances.append(({ [weak instance] in instance }, sourceLocation))
}
func verifyNoLeaks() throws {
for tracked in trackedInstances {
#expect(
tracked.closure() == nil,
"Instance should have been deallocated. Potential memory leak.",
sourceLocation: tracked.sourceLocation
)
}
trackedInstances.removeAll()
}
}
We store a closure that captures the instance weakly. When verifyNoLeaks() is called, we invoke each closure. If it returns something other than nil, the object wasn’t deallocated.
We also store the SourceLocation so that when a leak is detected, the failure points to the exact line where the object was tracked.
Passing context with @TaskLocal
How do tests access the tracker? We can’t pass it as a parameter. The tracker should be created inside provideScope, which wraps the test, so there’s no way to inject it directly into the test function’s signature.
Instead, we use @TaskLocal. This property wrapper allows us to set a value that any code within the current async task can access without explicit parameter passing:
private final class LeakTracker: @unchecked Sendable {
@TaskLocal static var current = LeakTracker()
// ... rest of the implementation
}
Any code running within the @TaskLocal’s withValue closure can access the tracker via LeakTracker.current.
Exposing the tracker to tests
We can always expose it and have tests call it directly, but I want to protect the tests from knowing how our tracker works. So I wrapped its usage in a global function trackForMemoryLeaks().
func trackForMemoryLeaks(_ instance: AnyObject, fileID: String = #fileID, filePath: String = #filePath, line: Int = #line, column: Int = #column) {
LeakTracker.current.track(instance, sourceLocation: SourceLocation(fileID: fileID, filePath: filePath, line: line, column: column))
}
Wiring it up with TestScoping
Finally it’s time for the star of the show to shine. Create a struct that conforms to TestScoping:
struct MemoryLeakCheckTrait: TestScoping {
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @concurrent @Sendable () async throws -> Void
) async throws {
let tracker = LeakTracker()
try await LeakTracker.$current.withValue(tracker) {
try await function()
}
try tracker.verifyNoLeaks()
}
}
Before the test runs, we create a fresh LeakTracker and set it as the current one using withValue. The test runs inside that closure. After it completes, we call verifyNoLeaks().
Applying Scoping to a Suite
Theoretically, if we want to apply this to a test suite, the MemoryLeakCheckTrait struct should also conform to SuiteTrait. This will allow us to add it as a trait. Example: @Suite(.MemoryLeakCheckTrait())
In practice, as of Swift 6.2, when we catch a memory leak and the #expect fail, all the tests crash. For some reason, a failing #expect or throwing an Error will crash all tests. I believe it’s a bug in the Testing framework.
The solution? We also need to conform to TestTrait and set isRecursive to true. This prevents the tests from crashing and instead, only the test causing the memory leak will fail.
Afterwards, our struct should look like this:
struct MemoryLeakCheckTrait: TestTrait, SuiteTrait, TestScoping {
var isRecursive: Bool = true
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @concurrent @Sendable () async throws -> Void
) async throws {
let tracker = LeakTracker()
try await LeakTracker.$current.withValue(tracker) {
try await function()
}
try tracker.verifyNoLeaks()
}
}
Adding syntactic sugar
Without any changes, you’d apply the trait using @Suite(MemoryLeakCheckTrait()). We can make this cleaner by adding a static property:
extension Trait where Self == MemoryLeakCheckTrait {
static var checkMemoryLeaks: Self { Self() }
}
This will allow us to apply the trait using @Suite(.checkMemoryLeaks) instead.
Using it in tests
To catch memory leaks successfully, you need to do 2 things:
- Add the
.checkMemoryLeakstrait. - Call
trackForMemoryLeaks()for each object we wish to track.
@Suite(.checkMemoryLeaks) // Add the trait
struct MyTests {
@Test func objectsAreDeallocated() async throws {
let parent = Parent()
let child = Child()
parent.child = child
child.parent = parent
// Call trackForMemoryLeaks for each object we wish to track.
trackForMemoryLeaks(parent)
trackForMemoryLeaks(child)
}
}
If child.parent is a strong reference; both objects keep each other alive, and the test fails, you’ll see:
❌ Test objectsAreDeallocated() failed
Instance should have been deallocated. Potential memory leak.
→ MyTests.swift:15
It will point directly to where you tracked the object, making debugging easy.
Conclusion
With about 50 lines of code, you can catch memory leaks automatically in your test suite. The key ingredients:
TestScopingto run verification after each test@TaskLocalto pass the tracker without parameter drilling- Weak references to detect objects that weren’t deallocated
Full example: https://github.com/shawky933/MemoryLeakDetection
메타데이터
- post_id
- ea2ed6a16bb9
- slug
- memory-leak-detection-using-swift-testing-ea2ed6a16bb9
- url
- https://medium.com/@shawky93/memory-leak-detection-using-swift-testing-ea2ed6a16bb9
- canonical_url
- https://medium.com/@shawky93/memory-leak-detection-using-swift-testing-ea2ed6a16bb9
- author_url
- https://medium.com/@shawky93
- status
- ok
- fetched_at
- 2026-08-28 09:15:10