When writing automated tests, it is important to prevent retain cycles. A retain cycle occurs when two objects have a strong reference to each other. This can happen when an object creates a closure that captures another object. If the closure is not released, the two objects will never be deallocated, which can lead to memory leaks.
In production code, there are a few ways to prevent retain cycles:
Weak References
One way is to use weak references. A weak reference is a reference that does not prevent the object from being deallocated. To use a weak reference, you can use the weak
keyword when declaring the variable.
weak var viewController: ViewController?
Unowned references
Another way to prevent retain cycles is to use the unowned
keyword. An unowned
reference is similar to a weak reference, the difference is, it implicitly wraps the weak reference. An unowned
reference cannot be nil, so if the object that the unowned
reference refers to is deallocated, the app will crash.
unowned var collectionView: UICollectionView?
Async and Await keywords
Another way to prevent retain cycles is to use the async
and await
keywords. These keywords allow you to write asynchronous code that does not create retain cycles.
async func doSomething() {
let viewController = ViewController()
let collectionView = UICollectionView()
// This closure does not create a retain cycle because it is captured using a weak reference.
weak var weakViewController = viewController
// This closure does not create a retain cycle because it is captured using an unowned reference.
unowned var unownedCollectionView = collectionView
// This code is asynchronous, so it does not create a retain cycle.
await doSomethingAsync(viewController, collectionView)
}
However, these all can be forgotten while coding, and in PR reviews.
Here, we need to create a habit that will always check the common retain cycle possibilities in Automated tests:
When writing unit tests, to prevent common async bugs that come with retain cycles, we need to cover the memory leaks as well. We need to develop a habit that we need to check the memory leaks with every test so that we will be safe when we create async code blocks. And we need to add this check to every closure in every action that can create a new possible retain cycle.
2 ways we run those assertions after the test is passed:
- to use the
tearDown
method or, addTearDown {}
this block runs after each test, just beforetearDown
is called.
So we can either add assertions to every test, which eventually will be a burden to add to each test and be harder to follow. Or we can come up with an easier solution that we can check the memory leak in every SUT(System Under Test) creation.
Since every test creates a SUT, the best way could be to create a factory method that makes the SUT, then in this factory we can also check the memory leak:
We need to run this assertion after the test has finished checking that SUT was deallocated from memory.
Note: If you are working with multiple objects in the test, make sure you are checking for all the coupled instances as well. Because usually we are focused to think about only the class that we are testing(SUT) and forget how the other collaborators of the class will react once the class has been deallocated.
For example: we have a viewcontroller, inside the viewcontroller there is this async block that has a strong reference to a collection view, even when the viewcontroller is dead, it still gets to be called because of the strong reference and eventually unexpected behaviors happen.
For addTearDown {} in the Apple documentation it says:
Registers a block to be run at the end of a test.
Teardown blocks are executed after the current test method has returned but before -tearDown is invoked.
Registered blocks are run on the main thread but can be registered from any thread. They are guaranteed to run only once, in LIFO order, and are executed serially. You may register blocks during -setUp, but you may not register blocks during -tearDown or from other teardown blocks.
addTearDown
The addTearDown
block runs after each test, just before tearDown
is called. This gives you a chance to check for memory leaks.
In the addTearDown
block, you can use the XCTAssertNil
assertion to check for memory leaks. The XCTAssertNil
assertion asserts that the object passed to it is nil. If the object is not nil, the assertion will fail.
By using the addTearDown
block, you can ensure that your automated tests do not create memory leaks.
Here is an example of how to use the addTearDown
block to prevent a retain cycle:
func trackForMemoryLeaks() {
let viewController = ViewController()
let collectionView = UICollectionView()
weak var weakViewController = viewController
unowned var unownedCollectionView = collectionView
doSomethingAsync(viewController, collectionView)
// Add a tearDown block to check for memory leaks.
addTearDown {
XCTAssertNil(weakViewController)
XCTAssertNil(unownedCollectionView)
}
}
By using the addTearDown block, you can ensure that your automated tests do not create memory leaks.
It is important to check for memory leaks in all unit tests, not just those that use asynchronous code. This is because asynchronous code can create retain cycles that are not obvious.
We can also use the addTearDown block to check for memory leaks in other types of code, such as code that uses delegation or notifications.
By using the addTearDown block, we can help to ensure that our code is free of memory leaks.
Code Examples In Real Life:
- https://github.com/rozeridilar/Example-App-Data-Races-iOS/blob/main/Tests/Example-Data-Races-In-Image-LoadingTests/Helpers/XCTestCase%2BMemoryLeakTracking.swift#L11
Usage: https://github.com/rozeridilar/Example-App-Data-Races-iOS/blob/main/Tests/Example-Data-Races-In-Image-LoadingTests/ImageCacheTests.swift#L44
- The Minimum You Should Do To Prevent Memory Leaks in Swift https://www.essentialdeveloper.com/articles/the-minimum-you-should-do-to-prevent-memory-leaks-in-swift
Leave a comment