We write a lot of unit tests, developing a
SoundCloud application for iOS. Unit tests look great. They are short, (hopefully) readable, and they give us confidence that the code we write works as expected. But unit tests, as their name implies, cover only one block of code, most often a function or class. So, how to catch the errors that exist in the interactions between classes - errors, such as
memory leaks ?
Memory Leaks / Memory Leaks
Sometimes detecting a memory leak error is quite difficult. There is a possibility of having a strong reference to the delegate, but there are also such errors that are much more difficult to detect. For example, is it obvious that the following code may contain a memory leak?
final class UseCase { weak var delegate: UseCaseDelegate? private let service: Service init(service: Service) { self.service = service } func run() { service.makeRequest(handleResponse) } private func handleResponse(response: ServiceResponse) {
')
Since the
Service is already being implemented, there are no guarantees regarding its behavior. By
passing the handleResponse to the private function that captures
self , we provide the
Service with a strong reference to
UseCase . If the
Service decides to keep this link - and we have no guarantees that this will not happen - in this case there is a memory leak. But with a cursory study of the code, it is not obvious that this can really happen.
There is also a great
post by John Sandell about using unit tests to detect memory leaks for classes. But with the example given above, where it is very easy to skip a memory leak, it is not always clear how it is necessary to write such a unit test. (We, of course, speak here not from the point of view of experience.)
As Guilherme wrote in his
recent post , the new features in the SoundCloud app for iOS are written in accordance with "pure architectural patterns" - most often this is a
VIPER type. Most of these
VIPER modules are built using what we call a
ModuleFactory . This
ModuleFactory takes some input, dependencies, and configuration — and creates a
UIViewController , which is already connected to the rest of the module and can be placed on the navigation stack.
In this
VIPER module there can be several delegates, observers, and escaping closures, each of which can cause the controller to remain in memory after its removal from the navigation stack. When this happens, the memory will grow and the operating system may well decide to terminate the application.
So is it possible to cover as many potential leaks by writing as few unit tests as possible? If not, then it was all a waste of time.
Integration tests
The answer, as one might guess from the title of this post, is yes. And we do this with integration testing. The goal of the integration test is to check how objects interact with each other. Of course,
VIPER modules are groups of objects; memory leaks are a form of interaction that we definitely want to avoid.
Our plan is simple: We are going to use our
ModuleFactory to create an instance of the
VIPER module. Then we will delete the link to the
UIViewController and make sure that all the important parts of the module are destroyed with it.
The first problem we encountered is that, by nature, we cannot easily access any part of the
VIPER module, except for the
UIViewController . The only
public function in our
ModuleFactory is
func make () -> UIViewController . But what if we add another entry point just for our tests? This new method will be declared via
internal , so we can access it only through the
@testable importing -
ModuleFactory framework. It will return links to all the most important parts of the module, which we could then retain for weak links to enter in our test. This ultimately looks like this:
public final class ModuleFactory {
This solves the problem of the lack of direct access to the object data. Obviously, this is not ideal, but it meets our needs, so let's move on to writing a test. It will look like this:
final class ModuleMemoryLeakTests: XCTestCase {
So, we have an easy way to detect memory leaks in the
VIPER module. It is by no means ideal and requires some user work for each new module that we want to test, but this is certainly a lot less work than writing separate unit tests for each possible memory leak. It also helps to identify memory leaks, which we do not even suspect. In fact, after writing several of these tests, we found that we have a test that does not pass, and after some research we found a memory leak in the module. After correction, the test should be repeated.
It also gives us a starting point for writing a more general set of integration tests for modules. In the end, if we just keep a strong link to the
Presenter and replace the
UIViewController with a
mock , then we can fake user input, then call the methods of the presenter, and check the dummy data on the
View .