Написание юнит-тестов для iOS-приложения: советы для сложных случаев

In this article, Alexey Kondakov, a software engineer with over 20 years of experience in organizations such as Motorola, Dell, and other large international digital companies, will discuss the features of working with tests and the rules of their effectiveness. It's not just that they reduce the number of bugs, but also the cost of fixing them, which depends on the stage at which they were caught. According to an IBM study, the cost of fixing bugs after release is 4-5 times higher than fighting them at the design stage. If this practice is so useful, what recommendations can be given to ensure that tests really work when developing applications for iOS?

<a></a>Unit Testing in Swift

Why should we start talking about Unit testing right away, bypassing other types of tests? Unit testing, or modular testing, is a type of testing that allows you to test individual modules, that is, parts of code, individual processes. Michael Cohn, one of the theorists of quality assurance in application development for Apple, said that from the point of view of testing effectiveness, the number of tests of different types and the ratio of this number is important.

Cohn developed a pyramid of automated testing, in which the greatest emphasis is placed on modular tests. Their share in the hierarchy of checks should be no more and no less than 80%. The remaining 20% is accounted for by integration, acceptance, and interface tests. Cohn believed that modular tests act at the level and at the moment when you can catch the maximum number of errors with a minimum of effort. Thus, the question of quality assurance of iOS development is 80% a question of Unit testing. For Unit testing, you can use different libraries that are installed in the corporate environment.

Some of the most popular are XCTest, UITest. Alexey gave an example of a common asserta test, the essence of which is that you run certain arguments through a class or function of "combat" code , and then compare the result, which turned out de facto, with the expected one, which was placed in the retrievedItem variable. The comparison of the two happens here:

XCTAssertEqual(retrievedItem, item)

class checkAuthClass: XCTestCase {

func testPasswordInput() {

var collection = ContentCollection()

let item = Item(id: "an-item", title: "A title")

collection.add(item)

let retrievedItem = collection.item(withID: "an-item")

XCTAssertEqual(retrievedItem, item)

}

}

<a></a>1. Code and Architecture Cleanliness

The concept of clean architectures, clean code is used to characterize the quality of the code base in general. However, tests are one in a million reasons, and perhaps the most important one, why this principle should be adhered to. The presence of chaotic dependency injections , when various elements of the program are not encapsulated properly, but are intertwined and interdependent at different levels, is dangerous. This not only creates situations where a completely unexpected segment of "combat" code may fail after the release. According to Kondakov, such architectures and such code are extremely difficult to test. If your class needs to accept some information as input, then it should be formed outside the class/function. Your independent encapsulated class should not form this information inside itself, but receive it as input. This is necessary, among other things, so that during testing you can take the class you are interested in and pass it any information, being sure that in the context of the "combat" project it will work exactly the same as in the test. Therefore, Alexey advises to adhere to the chosen architectural solutions, for example, if MVP (Model, View, Presenter) was chosen as the development pattern from the very beginning, as is often the case, then you need to maintain the correct connections between these parts of a single project.

Unfortunately, the MVC architecture, which was popular in the past, does not meet these requirements and cannot be recommended for use now. There are now many examples of architectures, from common MMVM, VIPER to rather exotic ones, and it is up to the architects to evaluate their pros and cons and choose the appropriate one. The main thing is that they should allow the separation of responsibilities of classes and methods, supporting code and data injections.

Writing tests can be a big problem for developers, but from my experience I can say that in most cases problems appear where responsibility is not fully shared. By strictly following architectural patterns, writing tests becomes a much easier task than trying to cover chaotically written code with tests. Conclusion: want to write tests quickly and with pleasure? Strictly maintain the cleanliness of the architecture.

<a></a>2. Cover Swift code with protocols

The Swift language has a very useful construct called a "protocol", which roughly corresponds to the interfaces of other programming languages. As Kondakov explained, a protocol is a template that a class that implements it must satisfy and establishes directions and restrictions for subsequent development. This works like poka-yoke (foolproof). You simply cannot create a class that partially satisfies the requirements of the protocol, the compiler will force you to write an implementation, even if it is the most basic one. And at the same time, errors are prevented at the stage when testing is not necessary. It is worth using the functionality of protocols to the maximum to ensure the ability to test the components of your application independently. In particular, it is important to cover the level at which the UI of the iOS application interacts with the "backend" with protocols: databases, network requests. If all this is unified and done correctly, it will not be difficult to create mocking objects for modular testing. Moreover, the tests aimed at them will reflect how their "combat" originals, on which the application depends, will actually work.

<a></a>3. "Mocks" and best practices

"Mocks" are classes, functions, and other components that simulate the operation of their real prototypes from "production" and are able to inject the test data you need at the right time. Copies can be a complete analogue of the original, or they can be a simplified version of it. There are five types of "mocks", but we will not dwell on this now. This is a completely different story. Let's just say that "mocks" are created to safely simulate external and internal dependencies on them, that is, to pass some data to an object or function and see how the "mock" processes it. Often, the "front-end" part of the application for Apple, written in Swift, interacts through network requests (HTTP) with the API in C or Java, you can simulate receiving a Swift json object as if it comes from the API. Pass it to the object and perform a unit test on some basis

{

"glossary":

"title":

"example glossary",

"GlossDiv":

{

"title": "S",

"GlossList": {

"GlossEntry": {

"ID": "SGML",

"SortAs": "SGML",

"GlossTerm": "Standard Generalized Markup Language",

"Acronym": "SGML",

"Abbrev": "ISO 8879:1986",

"GlossDef": {

"para": "A specification, used to create markup languages such as DocBook.",

"GlossSeeAlso": ["GML", "XML"]

},

"GlossSee": "markup"

}

}

}

}

}

From Alexey's experience, when using "mocks" of objects "in battle", there are three points:

organize the merging of mock classes that individual developers create to test their project components into mock class libraries. This will save time by not reinventing the wheel for the hundredth time and using proven solutions. Example: imagine that a network request is being written to check one of the classes. With a high probability, we can say that such a request will be needed in dozens of tests that will be developed in the near future. Developers should first check the library to see if what they need has already been written, and only then spend time writing their own "mock";

<a></a>4. Если руководство требует покрыть тестами user interface (UI)

use dependency inversion libraries in the project. I have already talked about the need for a good project architecture for effective tests. To make creating "mocks" based on your real objects effective, use dependency connection libraries. For example, SWinject. Like protocols, it will allow you to ensure dependency inversion, that is, to transfer outside the class and method what would otherwise be formed inside it, through the constructor or calling an object inside the method. It is necessary to ensure that any class and method in your "combat" version of the project only processes information, and does not receive/create it inside. If this is not implemented, mocking-testing will be greatly complicated. It will also not be possible to effectively take some method separately and simulate its operation inside the project, creating "fakes" and "stubs" - an imitation of real relationships in the project.

<a></a>4. If management requires covering the user interface (UI) with tests

The above-mentioned conclusions of Michael Cohn, according to which interface testing should not be given much attention, make it clear that it is better to focus on modular testing. In addition, unit tests for checking interfaces are considered too long to write. Nevertheless, there may be requests from clients that will force management to demand full coverage of interface classes with unit tests. According to Alexey, it is best if a decision is made to refine modular tests so that problems do not manifest themselves in UI interfaces. But if you already have to do interface testing, then you need to know that not every framework designed for Unit tests is suitable for this. I will not dwell on testing SwiftUI, but will focus more on the possibility of testing UIKit-based code.

Так как с модульными тестами проверить внешний вид того, что видит пользователь невозможно, то тестировать нам надо стили и роуты UIViewController и UIView. В качестве примера того, как можно делать это неправильно, приведу несколько конкретных примеров:

In a typical iOS application on Swift architecture MVP, the so-called controllers - mediator classes manage the interaction of classes that access the database and View classes that show the user screens and interface elements. The main one among the controllers is UIViewController. This class is inherited from the standard UIViewController and its use is recommended in the official documentation for iOS developers from Apple. A typical interface testing strategy is end-to-end testing and screenshot testing. Screenshots are good for testing the visual component, but say nothing about the content of the page. End-to-end tests (XCUI-driven-tests) are too far from the code they test, so they can miss a lot. In case of an error it will be extremely difficult to track the problem. Both types of tests are very expensive from the point of view of development economics, because the UI of the application is constantly changing. You can test interfaces at the level of controllers and other UI kit elements using unit tests. However, modular testing of controller classes that output the View of the application can be worse than useless if you are not testing the right thing. So what should you test?

Since it is impossible to check the appearance of what the user sees with modular tests, we need to test the styles and routes of UIViewController and UIView. As an example of how you can do this incorrectly, I will give a few specific examples:

● it is incorrect to test the background color of an element in UIView;

● it is incorrect to check the text color, font and size in UILabel;

● it is incorrect to check Auto layout and restrictions.

Alexey clarifies that instead you need to focus on the content. Here are the right questions to ask yourself when preparing modular tests for the interface:

● is the correct number of grid cells formed in the interface?

● is the UILabel text correct?

● is the button enabled?

<a></a>6. Mockingbird и модульное тестирование Swift

Controllers that manage output can be tested if they are isolated from their dependencies. There was information about DI - dependency injections - above. During testing, real dependencies are replaced with imitation. To make it easy to conduct such tests, controllers must be passive. This means that they only route the user in response to events and show screens. No pulling data from the model directly into the controller! No updating themselves from the model! This will make writing full-fledged "mocks" impossible.

<a></a>6. Mockingbird and Swift modular testing

Kondakov also notes that this article would be incomplete if we did not mention useful frameworks. On the one hand, this facilitates the isolation of the element under study from the dependencies of code and architecture cleanliness, on the other hand, libraries can speed up the testing process, many solutions in which are embedded "under the hood" and do not require "reinventing the wheel". Such a library as Mockingbird in Swift is indispensable, because it allows you to create "mocks", stubs, stubs, which simulate the real dependencies of the object under study, with a couple of lines of code.

let view = ArticleViewBuilder()

Sometimes it happens that the necessary aspect of the object's work cannot be checked using libraries. In this case, you can resort to the capabilities of the language itself. First of all, to the "class builder". Write down the necessary experiment, and then check the work using the build() function. Alexey attributes the advantage of this method to the fact that it is not limited to the narrow functionality of the framework and you can do everything with it that you can do in the Swift language in general. Here is an example of a "builder":

let view = ArticleViewBuilder()

.withTitle(article.title)

.withSubtitle(article.subtitle)

.withImage(article.image)

<a></a>Unit-тестирование в Objective C

You can use the "builder" for modular testing to check the "backend" language of many iOS applications. We are talking about Objective C.

<a></a>Unit Testing in Objective C

The low-level part of applications for Apple, which works with data, is more often written in Objective C than you might expect. In particular, this language is used for this purpose in many corporations in which a significant part of the legacy code is still written in it. How to subject the part of the project that is written in this language to modular tests? Let's figure it out.

<a></a>1. OCMock and swizzling: built-in language features

Using the OCMock object and the swizzling method to create "mocks" from the Objective C built-in library requires more code to write the test, but gives significantly more options in solving the problems of a particular text than other libraries. For example, if the class is given to an external library, then it becomes extremely difficult to simulate its dependencies without OCMock. With the swizzling method, the impossible becomes possible.

● если OCMock ограничен в вашем окружении (блокируется чем-либо) или Вам нужно “переопределить” init-метод, корректная работа с которым OCMock не гарантируется - используйте swizzing. Это более громоздкий метод, но иногда только он позволяет достигнуть нужного результата;

Those who are used to Swift may not look towards the OCMock object when testing objects in Objective C, meanwhile, it is in this language that it becomes a real alternative to the functionality of other libraries, since it is in Objective C that the swizzling method is not limited in any way and it can be used to the maximum. Alexey Kondakov gave several recommendations when testing in Objective C using the OCMock object:

<a></a>3. Mockito и OCMock: что выбрать?

● if you are testing some project components related to C libraries, do not forget to make macros such as: MOCK_DEF, MOCK_REF, MOCK_SET, MOCK_UNSET. Thanks to this, you will have a ready-made variable with a pointer to a function that can be conveniently and quickly used inside the unit tests themselves: you will be able to assign a pointer to your mock function to this variable, and implement the necessary mock behavior from it.

<a></a>3. Mockito and OCMock: what to choose?

Just like in Swift, in Objective C you have a popular alternative to OCMock in the form of the external Mockito library. If you compare them, it quickly turns out that OCMock has a much larger set of methods and functions. For its part, Mockito is very light and fast. It is up to the developers to decide which features of the framework are most relevant to them.