Автоматизация тестирования iOS-приложений с применением Calabash и Cucumber
В процессе разработки любого приложения наступает момент, когда в связи с ростом функциональности трудозатраты на регрессионное тестирование становятся непомерно велики. Другая причина значительной трудоемкости тестирования iOS-приложений (так же как и любых других мобильных приложений) — разнообразие линейки поддерживаемых устройств и версий ОС, необходимость тестирования в альбомном и портретном режимах, а также при различных условиях соединения с интернетом. Стремление оптимизировать процесс тестирования приводит нас к необходимости его полной или частичной автоматизации.
В этой статье я расскажу о том, как мы автоматизируем тестирование наших приложений (ICQ и Агент Mail.Ru), поделюсь нашими наработками в этой области и упомяну о проблемах, с которыми мы сталкиваемся.
В нашем проекте для автотестов используется связка Calabash + Cucumber. Calabash — это фреймворк для автоматизации функционального тестирования, который, по сути, является драйвером, управляющим работой приложения на девайсе или симуляторе. Cucumber обеспечивает тестовую инфраструктуру (запуск тестов, парсинг сценариев, генерация отчетов).Архитектурно Calabash состоит из двух частей — клиентской и серверной. Серверная часть представляет собой HTTP-сервер, который встраивается в тестируемое приложение и принимает запросы на выполнение тех или иных действий в приложении, клиентская часть написана на Ruby и реализует API для взаимодействия с сервером.
Для описания сценариев в Cucumber используется язык Gherkin и шаблон Given/When/Then, где ключевое слово Given задает начальные условия, When — операцию, Then — конечный результат. Все сценарии помещаются в специальный файл .feature, в котором собраны сценарии, относящиеся к какой-то определенной фиче проекта.
Типичный тестовый сценарий выглядит так:
@Login Feature: Login (IMIOS-4898)
@ICQ @myChat @Agent
@ALL_DEVICES
@en
Scenario Outline: Login with empty uin/password (IMIOS-4898 #6)
Given I go on «LoginScreen»
When I login with username »
Given /^I go on »(.*)»$/ do |screen| @page=page (Kernel.const_get (screen)) unless element_exists (@page.trait) cur_screen=Utils.current_screen # return current screen of application Utils.go_to (cur_screen, @page) # transition from screen1 to screen2 else print («Already on #{screen}\n») end @page.await (: timeout=>10) end
Then /^I login with username »(.*)» and password »(.*)»(?: by (.*))?$/ do |username, password, protocol| if $App==«Agent» protocol||='mrim' steps %Q{Given I go on «PhoneLoginScreen»} @page = @page.go_to_login (protocol) else steps %Q{Given I go on «LoginScreen»} end @page = @page.login (username, password) End
Then /^I view alert with text »(.*)»$/ do |text| @page.check_alert (text) end При прогоне тестов Cucumber берет один шаг сценария и ищет нужную реализацию по регулярному выражению, подставляя параметры из секции Examples, выполняет данную реализацию и переходит к следующему шагу. Если в процессе исполнения сценария не возникло ошибок, то он помечается как PASS, в противном случае — FAILED.Таковы общие принципы исполнения тестовых сценариев. У внимательного читателя уже возник вопрос относительно мифического объекта @page, поэтому мы плавно переходим к рассмотрению шаблона проектирования Page Object.
Page Object — термин, пришедший из сферы UI-тестирования web-приложений. Применительно к iOS иногда встречается название Screen Object. Будем использовать первый вариант как наиболее общеупотребительный.Page Object — это паттерн проектирования, который широко используется в автоматизированном тестировании и позволяет отделять логику выполнения тестов от их реализации. Page Object моделирует экраны (или страницы) тестируемого приложения в качестве объектов. В результате мы получаем набор классов, каждый из которых отвечает за работу с отдельным экраном приложения. Такой подход значительно уменьшает объем повторяющегося кода, потому что одни и те же объекты экранов можно использовать в различных тестах. Основное преимущество Page Object заключается в том, что в случае изменения пользовательского интерфейса можно выполнить исправление только в одном месте, а не исправлять каждый тест, в котором этот интерфейс используется.
Методы экранных классов можно разбить на три логические части:
Locators — возвращают локаторы, по которым Calabash находит нужные элементы интерфейса Actions — реализуют все возможные пользовательские действия на экране Assertions — реализуют проверки на экране Ниже приведен отрывок кода для класса CreateGroupChatScreen: require 'calabash-cucumber/ibase' require_relative 'BaseScreen' class CreateGroupChatScreen < BaseScreen # Locators ******************************************************************** def title "UINavigationBar NavigationTitleView" end def back_button "view:'UINavigationBar' view:'MRButtonWithTintedImage' index:0" end def create_button "view:'UINavigationBar' view:'MRButtonWithTintedImage' index:1" end # ... # Actions ********************************************************************* # touch create button ant return ChatScreen def create mtouch(create_button) page(ChatScreen).await end
# remove last member from members list def remove_selected mtouch (remove_button) self end # remove all members from members list def remove_all_selected until get_selected_members.empty? remove_selected end self end # … # Assertions ****************************************************************** def check_selected_members (expected_members) actual_members=get_selected_members expected_members=expected_members.split (',') unless expected_members.empty? fail («Incorrect members list») unless actual_members.sort.eql?(expected_members.sort) self end def check_title (title) fail («Incorrect title») if get_title!=title self end def check_cl (cl) cl=cl.split (',') fail («Incorrect CL (actual — #{get_cl}, expected — #{cl})») unless get_cl.eql?(cl) self end # … end При реализации экранных классов надо обратить внимание на некоторые аспекты. Локаторы, используемые в приведенном выше примере, являются «хрупкими» и ненадежными, при изменении интерфейса они с большой долей вероятности перестанут работать. Поэтому мы в проекте назначаем каждому UI-элементу accessibilityIdentifier, уникальный в рамках приложения и однозначно идентифицирующий нужный элемент. Локаторы в этом случае приобретают такой вид и больше не боятся изменений UI: def title «view accessibilityIdentifier:'conference_captionLabel'» end def back_button «view accessibilityIdentifier:'conference_backButton'» end def create_button «view accessibilityIdentifier:'conference_createButton'» end Экранные классы наследуются от базового класса BaseScreen, который реализует общую функциональность для всех экранов (ввод в текстовые поля, нажатия с проверкой существования элемента и т.д.).Все методы экранных классов возвращают указатели на Page Object. Если вызов метода не изменяет текущий экран, то возвращается self, в противном случае возвращается указатель на нужный Page Object. Такой подход позволяет записывать шаги сценария в удобном виде, последовательно вызывая нужные методы.
Then /^I invite contact »(.*)»/ do |contact| steps %Q{Given I go on «ContactsScreen»} @page = @page.show_all_contacts if @page.contact_exist?(contact) @page .chat (contact) .chat_info .invite else print («Contact #{contact} not exists») end end Радость от автоматизации тестирования была бы неполной без автоматизации процессов подготовки билда, запуска тестовых сценариев и рассылки тест-репортов.Наши первые попытки запускать автотесты на сервере с TeamCity закончились неудачей, так как Calabash отказывался работать в безмониторной конфигурации. Поэтому вынужденной мерой стал запуск тестов на отдельной машине с использованием Mac OS Automator скриптов.
Основные этапы работы скрипта:
По ID конфигурации (например, ImIOS_ICQ00Develop) запрашивает на TeamCity номер последнего собранного билда и текущий номер версии Делает git checkout из Git-репозитория по тегу teamcity-build-ios-<номер билда из п.1> (этим тегом TeamCity помечает собранные коммиты) Скачивает и линкует к проекту последнюю версию Calabash.framework Выполняет брендинг и запускает сборку проекта, используя xcodebuild. Под брендингом понимается настройка проекта и загрузка нужных ресурсов для дальнейшей сборки конкретного приложения. Сборка производится как для симулятора (-sdk iphonesimulator), так и для девайсов (-sdk iphoneos). Запускает непосредственно тесты вызовом bash-скрипта Запуск теста производится очень просто: cucumber tests/features Нам необходимо обеспечить запуск тестов на всех моделях девайсов (симуляторов) и со всеми поддерживаемыми языками интерфейса. Для запуска тестовых сценариев на конкретном симуляторе необходимо передать параметр DEVICE_TARGET с указанием UDID нужного симулятора. С установкой нужного языка дело обстоит сложнее. На данный момент Calabash-iOSs не поддерживает установку нужного языка на iOS-симуляторе 8 версии. Но если покопаться в недрах iOS Simulator, то можно найти файл .GlobalPreferences.plist, в котором находится параметр AppleLanguages, определяющий язык интерфейса. Задать его значение можно с помощью следующего скрипта, используя гем CFPropertyList. def Utils.set_language_in_iossim8(lang) `find ~/Library/Developer/CoreSimulator/Devices/#{$TargetUDID} -type f -name ».GlobalPreferences.plist»`.split (»\n»).each do |fn| plist = CFPropertyList: List.new (: file => fn) data = CFPropertyList.native_types (plist.value) data[«AppleLanguages»][0]=lang plist.value = CFPropertyList.guess (data) plist.save (fn, CFPropertyList: List: FORMAT_BINARY) end end Также в команде вызова Cucumber указывается список тегов, определяющий, какие именно сценарии необходимо запускать. Стартовый скрипт последовательно выполняет прогоны на каждом из указанных симуляторов и с каждым языком интерфейса, передавая эти данные в виде тегов, а Cucumber выполняет только те тестовые сценарии, которые соответствуют заданным тегам. #simulators list for launch sim=(\ 'iPhone 4s (7.1 Simulator)'\ 'iPhone 5 (7.1 Simulator)'\ 'iPhone 5s (7.1 Simulator)'\ 'iPad 2 (7.1 Simulator)'\ 'iPad Retina (7.1 Simulator)'\ 'iPad Air (7.1 Simulator)'\ 'iPhone 4s (8.0 Simulator)'\ 'iPhone 5 (8.0 Simulator)'\ 'iPhone 5s (8.0 Simulator)'\ 'iPhone 6 (8.0 Simulator)'\ 'iPhone 6 Plus (8.0 Simulator)'\ 'iPad 2 (8.0 Simulator)'\ 'iPad Retina (8.0 Simulator)'\ 'iPad Air (8.0 Simulator'\ ) #languages list for launch lang=(\ en\ cs\ de\ es\ pt\ ru\ tr\ uk\ zh-Hans\ ja\ vi\ )
for i in »${sim[@]}»; do for j in »${lang[@]}»; do case »$i» in … 'iPad Retina (8.0 Simulator)') profile='-t @ALL_DEVICES,@iPadRetina_iOS8' ;; … esac
DEVICE_TARGET='$i' cucumber tests/features -t @$j -t @ ${profile} done done Таким образом, если у нас есть некий сценарий с тегами @iPadRetina_iOS8 @en, то он запустится только на симуляторе iPad Retina с iOS 8 и установленным английским языком.У нас практически все готово к запуску автотестов, но необходимым условием их корректной работы является сброс данных и настроек симулятора, что соответствует hard reset«у девайса. Таким образом мы избавляемся от побочных эффектов предыдущих прогонов и задаем идентичные начальные условия. В меню симулятора есть соответствующий пункт, но по старой доброй традиции нам придется выбрать его без помощи рук и курсора, написав небольшой apple script.
tell application «iOS Simulator» activate end tell tell application «System Events» tell process «iOS Simulator» tell menu bar 1 tell menu bar item «iOS Simulator» tell menu «iOS Simulator» click menu item «Reset Content and Settings…» end tell end tell end tell tell window 1 click button «Reset» end tell end tell end tell Одним из столпов тестирования мобильных приложений является тестирование работы с разными типами и качеством связи. Смартфон практически постоянно находится при владельце — и офисе с быстрой сетью WI-FI, и на даче с еле уловимым 3G. Поэтому в заключение данного параграфа я хочу рассказать о механизме эмуляции качества сети на тестовом стенде. На устройствах с включенным Developer Mode и на Mac OS доступен инструмент Network Link Conditioner, который позволяет задавать качество сети, используя графический интерфейс. Но нам нужна возможность изменять качество сети «на лету» во время автотестов и управлять этим процессом через командную строку. Для этих целей подходит утилита ipfw, входящая в стандартный пакет поставки Mac OS. Приведенные ниже команды устанавливают скорость приема/отправки в 1 Mbit/s, потерю пакетов 10% и задержку в приеме/отправке пакетов 500 мс. $ sudo ipfw add pipe 1 in $ sudo ipfw add pipe 2 out $ sudo ipfw pipe 1 config bw 1Mbit/s plr 0.1 delay 500ms $ sudo ipfw pipe 2 config bw 1Mbit/s plr 0.1 delay 500ms После манипуляций с ipfw необходимо сбросить все установленные настройки. $ sudo ipfw delete pipe 1 $ sudo ipfw delete pipe 2 $ sudo ipfw -f flush По результатам прогона тестовых сценариев формируется отчет следующего вида.
В отчете видно, в каких сценариях возникли ошибки. Для проваленных сценариев снимается скриншот в момент возникновения ошибки — это иногда позволяет сразу определить причину фейла. Если при ручном воспроизведении тестового сценария баг повторяется, он заводится в баг-трекере.
При активном развитии приложения и выходе новых версий перед тестировщиками встает проблема проверки миграции данных со старых версий. Постоянная установка старых версий, наполнение базы данными и обновление до тестируемой версии — очень трудоемкие задачи, которые вызывают уныние даже у самого усидчивого тестировщика. Для облегчения данного процесса мы собираем сендбоксы для каждой версии приложения с уже заполненной (вручную) базой данных и затем многократно используем их для проверки корректности миграции. При таком подходе появляется возможность автоматизировать данный вид тестирования.Загрузка сендбоксов на симулятор не представляет сложности и сводится к копированию файлов в нужную директорию симулятора. Для загрузки же сендбоксов на устройство используется утилита ifuse, которая позволяет смонтировать файловую систему девайса.
$ ifuse --udid #{$Udid} --container #{$Bundle_app} /Volumes/iphone Тестирование миграции происходит следующим образом. На симулятор или девайс устанавливается приложение, затем накатывается нужный сендбокс. Реализация соответствующего шага сценария имеет следующий вид: Given /^upload database from »(.*)»$/ do |path| if simulator? path=File.expand_path (path.gsub (' ','\ ')) sand=`find ~/Library/Developer/CoreSimulator/Devices/#{$Udid} -type d -name »#{$App}.app»`.gsub (»#{$App}.app»,») FileUtils.rm_rf (Dir.glob (»#{sand}Documents/*»)) FileUtils.rm_rf (Dir.glob (»#{sand}Library/*»)) FileUtils.cp_r (»#{path}/Documents/.»,»#{sand}/Documents», : verbose => false) FileUtils.cp_r (»#{path}/Library/.»,»#{sand}/Library», : verbose => false) else path=path.gsub (' ','\ ') system («umount -f /Volumes/iphone») system («rm -rf /Volumes/iphone») system («mkdir /Volumes/iphone») `ifuse --udid #{$Udid} --container #{$Bundle_app} /Volumes/iphone` FileUtils.rm_rf (Dir.glob (»/Volumes/iphone/Documents/*»)) FileUtils.rm_rf (Dir.glob (»/Volumes/iphone/Library/*»)) FileUtils.cp_r (»#{path}/Documents/.»,»/Volumes/iphone/Documents/», : verbose => false) FileUtils.cp_r (»#{path}/Library/.»,»/Volumes/iphone/Library/», : verbose => false) end end После запуска и обновления приложения происходят соответствующие проверки (количество чатов, непрочитанных сообщений, сохранение логина).Автоматизация позволила снизить трудоемкость тестирования миграции. На данный момент требуется увеличение списка проверок и количества проверяемых сендбоксов, что в идеале позволит нам полностью исключить ручную составляющую тестирования.
Тестирование приложения на основании сравнения скриншотов имеет свои преимущества и недостатки. С одной стороны, оно, помимо функциональных багов, выявляет все проблемы с дизайном, такие как, например, «съехавшие» кнопки, с другой — обладает рядом особенностей, ограничивающих полный переход на тестирование по скриншотам. При наличии нескольких приложений и обширного ряда поддерживаемых устройств требуется огромная база эталонных скриншотов под все возможные разрешения экранов. Помимо этого, не всегда легко обеспечить требуемый вид приложения. Например, область экрана, на которой отображаются часы или качество связи, будет меняться на каждом конкретном прогоне.В автотесты добавлен API, позволяющий выполнять проверки скриншотов приложения. Данный функционал реализован с помощью библиотеки ImageMagick и позволяет сравнить указанную область скриншота с эталоном, а также выполнить нечеткий поиск по шаблону.
def Image.compare (image_path, etalon_path, x=0, y=0, w=0, h=0) img = Magick: ImageList.new (File.expand_path (image_path)) et = Magick: ImageList.new (File.expand_path (etalon_path)) img = img.crop (x, y, w, h, true) if w!=0 && h!=0 res=img.signature<=>et.signature return true if res==0 return false end
def Image.search_subimage (image_path, subimage_path, fuzzy='20%') img = Magick: ImageList.new (File.expand_path (image_path)) sub = Magick: ImageList.new (File.expand_path (subimage_path)) img.fuzz=fuzzy sub.fuzz=fuzzy if img.find_similar_region (sub)==nil return false else return true end end Функционал используется для проверки логотипа приложения. Тестирование по скриншотам имеет большой потенциал и его использование будет расширяться. Пользователь ожидает от мобильного приложения не только надежной работы без сбоев и широкого функционала, но и мгновенной реакции на его действия. Быстродействие и время отклика являются важными факторами повышения конкурентоспособности приложения. Поэтому контроль времени выполнения основных операций (таких как запуск приложения, время получения оффлайн-сообщений, отображение контакт-листа и т.д.) — насущная необходимость. Этот процесс связан с рутинными (и многократными) действиями по воспроизведению операций, подлежащих замерам, последующим анализом логов и занесением данных в отчет. Именно поэтому он оказался естественным претендентом на автоматизацию.Автотесты производительности состоят из двух этапов:
Прогон тестовых сценариев из Performance.feature, в котором собраны сценарии по выполнению тех действий, время выполнения которых мы хотим замерить. Каждый сценарий выполняется по 5 раз для минимизации случайных отклонений значений Сбор логов, поиск соответствующих метрик, усреднение значений и формирование отчета Для формирования отчета написана библиотека, реализующая API для быстрого добавления новых значений в отчет. Для работы c html библиотека использует гем nokogiri.
Очевидно, что данный вид тестирования должен проводиться на физических устройствах. Фактически чтобы запустить тест на девайсе, а не на симуляторе, надо изменить UDID и добавить IP-адрес Wi-Fi-соединения.
Главным артефактом тестирования является отчет, по которому можно судить о динамике изменения тех или иных метрик. В случае превышения заданных лимитов происходит «разбор полетов» для выяснения причин ухудшения быстродействия.
Главным направлением дальнейшего развития автотестов является снижение влияния внешних факторов и внедрение «заглушек». Это позволит ускорить выполнение сценариев и исключить влияние серверных ошибок на результаты тестов клиентского приложения.Здесь возможны два варианта, отличающихся местом расположения «заглушки»:
внутри приложения вне приложения Первый вариант является наиболее гибким и удобным, но обладает одним существенным недостатком — это потенциальное влияние тестовых «заглушек» на приложение; другими словами, релизное приложение и тестируемое будут отличаться друг от друга.Второй вариант подразумевает реализацию фейк-сервера, который реализует только протокол взаимодействия и работает локально с клиентом. При таком подходе приложение не испытывает побочных эффектов, однако требуются дополнительные трудозатраты на реализацию фейк-сервера.
Наверняка многим из читающих эту статью есть чем поделиться в области автоматизации тестирования. Предлагаю делиться наработками в этой области в комментариях.