В предыдущей статье мы познакомились с пирамидой тестирования и тем, какую пользу несут автоматизированные тесты. Но теория, как правило, отличается от практики. Сегодня мы хотим рассказать о своем опыте тестирования кода приложения, которым пользуются миллионы пользователей iOS. А также о том непростом пути, который пришлось пройти нашей команде для достижения стабильного кода.
Ситуация такова: предположим, разработчикам удалось убедить себя и бизнес в необходимости покрытия кодовой базы тестами. Со временем в проекте стало более десятка тысяч unit- и более тысячи UI-тестов. Такая большая тестовая база породила несколько проблем, о решении которых мы хотим рассказать.
В первой части статьи мы ознакомимся с трудностями, возникающими при работе с чистыми (не интеграционными) unit-тестами, во второй части будут рассмотрены UI-тесты. Чтобы узнать, как мы улучшаем стабильность тестовых прогонов, добро пожаловать под кат.
В идеальном мире при неизменяемом исходном коде unit-тесты должны всегда показывать один и тот же результат независимо от количества и последовательности запусков. А постоянно падающие тесты не должны проходить через барьер Continuous Integration server (CI).
В действительности же можно столкнуться с тем, что один и тот же unit-тест будет показывать то положительный, то отрицательный результат — а значит «мигать». Причина такого поведения кроется в плохой реализации кода теста. Причем такой тест может пройти CI при удачном прогоне, а позже он начнет падать на чужих Pull Request (PR). В подобной ситуации возникает желание отключить этот тест или сыграть в рулетку и запустить прогон CI повторно. Однако такой подход анти-продуктивный, так как подрывает доверие к тестам и загружает CI бессмысленной работой.
Данная проблематика была освещена в этом году на международной конференции WWDC компании Apple:
- В этой сессии рассказывается про параллельное тестирование, анализ покрытия кода отдельного таргета тестами, а также про порядок запуска тестов.
- Тут Apple рассказала про тестирование сетевых запросов, мокирование, тестирование нотификаций и скорость выполнения тестов.
Unit tests
Для борьбы с мигающими тестами воспользуемся следующей последовательностью действий:
0. Оцениваем код теста на качество по базовым критериям: изолированность, корректность моков и т.д. Соблюдаем правило: при мигающем тесте меняем код теста, а не тестируемый код.
Если данный пункт не помог, то далее действуем так:
1. Фиксируем и воспроизводим условия, при которых тест падает;
2. Находим причину, по которой произошло падение;
3. Меняем код теста или тестируемый код;
4. Переходим к первому шагу и проверяем, устранена ли причина падения.
Воспроизводим падение
Самый простой и очевидный вариант — запустить проблемный тест на той же версии iOS и на том же устройстве. Как правило, в этом случае тест выполняется успешно, и появляется мысль: «У меня локально все работает, перезапущу сборку на CI”. Вот только на самом деле проблема не была решена, и тест продолжает падать у кого-то другого.
Поэтому на следующем шаге проверки нужно локально прогнать все unit-тесты приложения, чтобы выявить потенциальное влияние одного теста на другой. Но даже после такой проверки полученный вами результат теста может быть положительным, а проблема остается невыявленной.
Если вся последовательность тестов выполнилась успешно и не удалось зафиксировать ожидаемое падение, можно повторить прогон значительное количество раз.
Для этого в командной строке нужно запустить цикл с xcodebuild:
#! /bin/sh
x=0
while [ $x -le 100 ];
do xcodebuild -configuration Debug -scheme "TargetScheme" -workspace App.wcworkspace -sdk iphonesimulator -destination "platfrom=iOS Simulator, OS=11.3, name=iPhone 7" test >> "report.txt";
x=$(( $x +1 ));
done
Как правило, этого достаточно, чтобы воспроизвести падение и перейти к следующему шагу — выявлению причины зафиксированного падения.
Причины падения и возможные решения
Рассмотрим основные причины мигания unit-тестов, с которыми можно столкнуться в своей работе, инструменты, позволяющие их выявить, и возможные пути решения.
Можно выделить три основные группы причин падения тестов:
Слабая изоляция
Под изоляцией мы понимаем частный случай инкапсуляции, а именно: механизм языка, позволяющий ограничить доступ одних компонентов программы к другим.
Изолированность среды играет важную роль, так как для чистоты проверки ничто не должно воздействовать на тестируемые сущности. Особое внимание стоит уделить тестам, которые нацелены на проверку кода. В них используются сущности с глобальным состоянием, такие как: глобальные переменные, Keychain, Network, CoreData, Singleton, NSUserDefaults и так далее. Именно в этих областях возникает наибольшее количество потенциальных мест для проявления слабой изоляции. Допустим, при создании окружения теста задается глобальное состояние, которое неявно используется в другом тестируемом коде. В этом случае тест, проверяющий тестируемый код, может начать “мигать” — потому что в зависимости от последовательности тестов может возникнуть две ситуации — когда глобальное состояние задано и когда не задано. Зачастую описанные зависимости являются неявными, поэтому можно случайно забыть установить/сбросить подобные глобальные состояния.
Чтобы зависимости были явно видны, можно воспользоваться принципом Dependency Injection (DI), а именно: передавать зависимость через параметры конструктора, либо свойство объекта. Это позволит легко подставить мок-зависимости вместо реального объекта.
Асинхронность вызовов
Все unit-тесты выполняются синхронно. Сложность тестирования асинхронности возникает по причине того, что вызов тестируемого метода в тесте “застывает” в ожидании завершения выполнения скоупа unit-теста. Результатом будет стабильное падение теста.
//act
[self.testService loadImageFromUrl:@"www.google.ru" handler:^(UIImage * _Nullable image, NSError * _Nullable error) {
//assert
OCMVerify([cacheMock imageAtPath:OCMOCK_ANY]);
OCMVerify([cacheMock dateOfFileAtPath:OCMOCK_ANY]);
OCMVerify([imageMock new]);
[imageMock stopMocking];
}];
[self waitInterval:0.2];
Чтобы проверить такой тест, существует несколько подходов:
- Прогон NSRunLoop
- waitForExpectationsWithTimeout
Оба варианта требуют указать аргумент со временем ожидания. Однако нельзя гарантировать, что выбранного интервала будет достаточно. Локально ваш тест пройдет, но на высоко нагруженном CI может не хватить мощностей и он упадет — отсюда появится “мигание”.
Пусть у нас есть некий сервис обработки данных. Мы хотим проверить, что после получения ответа от сервера он передает эти данные на обработку дальше.
Для отправки запросов по сети сервис использует клиента для работы с ней.
Такой тест можно написать асинхронно, используя мок-сервер для гарантии стабильных ответов сети.
@interface Service : NSObject
@property (nonatomic, strong) id<APIClient> apiClient;
@end
@protocol APIClient <NSObject>
- (void)getDataWithCompletion:(void (^)(id responseJSONData))completion;
@end
- (void)testRequestAsync
{
// arrange
__auto_type service = [Service new];
service.apiClient = [APIClient new];
XCTestExpectation *expectation = [self expectationWithDescription:@"Request"];
// act
id receivedData = nil;
[self.service receiveDataWithCompletion:^(id responseJSONData) {
receivedData = responseJSONData;
[expectation fulfill];
}];
[self waitForExpectationsWithTimeout:10 handler:^(NSError * _Nullable error) {
expect(receivedData).notTo.beNil();
expect(error).to.beNil();
}];
}
Но синхронный вариант теста будет более стабильным и позволит избавиться от работы с таймаутами.
Для него нам нужен синхронный мок APIClient
@interface APIClientMock : NSObject <APIClient>
@end
@implementation
- (void)getDataWithCompletion:(void (^)(id responseJSONData))completion
{
__auto_type fakeData = @{ @"key" : @"value" };
if (completion != nil)
{
completion(fakeData);
}
}
@end
Тогда тест будет выглядеть проще, а работать стабильнее
- (void)testRequestSync
{
// arrange
__auto_type service = [Service new];
service.apiClient = [APIClientMock new];
// act
id receivedData = nil;
[self.service receiveDataWithCompletion:^(id responseJSONData) {
receivedData = responseJSONData;
}];
expect(receivedData).notTo.beNil();
expect(error).to.beNil();
}
Работу с асинхронностью можно изолировать путем инкапсулирования в отдельную сущность, которую можно протестировать независимо. Остальную логику необходимо тестировать синхронно. Данный подход позволит избежать большинство подводных камней, привносимых асинхронностью.
Как вариант, в случае с обновлением UI-слоя из background-потока, можно сделать проверку на то, находимся ли мы в главном потоке, и что будет происходить, если мы делаем вызов из теста:
func performUIUpdate(using closure: @escaping () -> Void) {
// If we are already on the main thread, execute the closure directly
if Thread.isMainThread {
closure()
} else {
DispatchQueue.main.async(execute: closure)
}
}
Развернутое объяснение смотрите в статье Д.Санделла.
Тестирование кода, выходящего за пределы вашего контроля
Часто мы забываем о следующих вещах:
- реализация методов может зависеть от локализации применения,
- в SDK есть приватные методы, которые могут вызываться классами фреймворка,
- реализация методов может зависеть от версии SDK
Указанные выше случаи вносят неопределенность при написании и запуске тестов. Чтобы избежать негативных последствий, нужно прогонять тесты на всех локалях, а также на поддерживаемых вашим приложением версиях iOS. Отдельно нужно отметить, что нет никакой необходимости тестировать код, реализация которого скрыта от вас.
На этом мы хотим завершить первую часть статьи об автоматизированном тестировании iOS-приложения Сбербанк Онлайн, посвященную unit-тестированию.
Во второй части статьи мы расскажем о проблемах, возникших при написании 1500 UI-тестов, а также рецептах их преодоления.
Статью писали вместе с regno — Антон Власов, руководитель направления и iOS developer.
Popadanec
Может здесь попадёт куда надо. На андроид при запущенном приложении, если было списание или пополнение вне приложения, не обновляется баланс. Обновляется только пере заходом в приложение.
Что то планируется изменить?
victoriaqb Автор
Уважаемый попаданец, здравствуйте, я уверена, что инженеры Сбербанка уже в курсе описанной проблемы и занимаются ее решением!
kAIST
Аха, много лет уже решают эту проблему.
Пуши же сейчас об операциях приходят, неужели так сложно чекнуть баланс.
victoriaqb Автор
Такое решение требует доработки бек-систем, что является довольно нетривиальной задачей в условиях высокой загруженности серверов.
prolis
Актуализация остатка на счёте в приложении, основной функцией которого является отображение актуального остатка… да ну, зачем?
Popadanec
Оно я так понимаю показывает актуальный остаток только в момент входа, в остальные моменты остаток расчётный/вычисленный и что мешает еще учитывать стандартные смс/пуш, не понятно. Нагрузка подскочит лишь на приложение и то незначительно. Нужные цифры приложение умеет перехватывать из смс(код для подтверждения операций к примеру).
P.S. Ещё бы хотелось узнать причину долгой загрузки на старых устройствах(4.2.2). Время запуска может различаться чуть ли не на порядок, при том что оба приложения до этого были запущены(а при загрузке, нагрузка на ОЗУ и ЦП незначительна). Я вижу разве что искусственное ограничение.
Samoglas
Изначально пришел в тред написать, что в Сбербанке работают хамло.
«Попаданец» как аллюзия на «засранец» и передразнивание фразы «попадёт куда надо». Короче, словно бы Олег Тиньков в твиттере со своими клиентами общается.
Уважаемая victoriaqb!
Ники собеседников крайне желательно не переводить на русский, а писать как есть,
желательнообязательно через copy-paste. Они могут быть смешными, странными и при переводе на русский приобретать другие, нежелательные оттенки смысла, выглядеть оскорбительными, даже если не для самого обладателя ника, то для тех, кто наблюдает общение, как у меня сейчас произошло.Короче, копировать-вставить помогает избежать двусмысленностей и ошибок в написании ника.
Люди зачастую это воспринимают хмуро, так же как ошибки в произнесении их имени-фамилии в оффлайн общении.
Popadanec
Лучше вообще писать Popadanec, тогда сообщение еще и дойдет кому надо в случае отключенного отслеживания комментариев.