← Back to list

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…

Shawky Elhanak · 2026-01-27 14:02 · 15 claps · 3.5 min read
#swift #swift-testing #ios-app-development #software-engineering #programming
Open on Medium ↗
Wiki topics: 💻 · Programming 📱 · Mobile Development

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 .checkMemoryLeaks trait.
  • 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:

  • TestScoping to run verification after each test
  • @TaskLocal to 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