![](https://habrastorage.org/files/35e/677/6d8/35e6776d894c4e0e9f04d98012b6f5e4.png)
В процессе разработки любого приложения наступает момент, когда в связи с ростом функциональности трудозатраты на регрессионное тестирование становятся непомерно велики. Другая причина значительной трудоемкости тестирования 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 "<username>" and password "<password>"
Then I view alert with text "<text>"
Examples:
| username | password | text |
| | passz | [_auth_wrong_data] |
| 442657876 | | [_auth_wrong_data] |
| | | [_auth_wrong_data] |
Для каждого сценария можно задать набор управляющих тегов. В частности, указываются приложения, для которых надо запустить данный сценарий, список девайсов и язык интерфейса. О том, как передавать список необходимых тегов при запуске прогона, я расскажу ниже.
Очевидно, что простого описания сценария на понятном человеку языке совершенно недостаточно для его использования. На следующем этапе нам необходимо реализовать каждый его шаг, используя Ruby, BDD-шаблон Given-When-Then и регулярные выражения.
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 и с чем его едят
Page Object — термин, пришедший из сферы UI-тестирования web-приложений. Применительно к iOS иногда встречается название Screen Object. Будем использовать первый вариант как наиболее общеупотребительный.
Page Object — это паттерн проектирования, который широко используется в автоматизированном тестировании и позволяет отделять логику выполнения тестов от их реализации. Page Object моделирует экраны (или страницы) тестируемого приложения в качестве объектов. В результате мы получаем набор классов, каждый из которых отвечает за работу с отдельным экраном приложения. Такой подход значительно уменьшает объем повторяющегося кода, потому что одни и те же объекты экранов можно использовать в различных тестах. Основное преимущество Page Object заключается в том, что в случае изменения пользовательского интерфейса можно выполнить исправление только в одном месте, а не исправлять каждый тест, в котором этот интерфейс используется.
![](https://habrastorage.org/files/e7d/daf/019/e7ddaf019b7141d6a20c34e8e9d22091.gif)
Методы экранных классов можно разбить на три логические части:
- 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 скриптов.
![](http://habrastorage.org/files/8a0/d59/476/8a0d59476b894dd9a7866f50907ebb4a.png)
Основные этапы работы скрипта:
- По 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
Функциональное тестирование
По результатам прогона тестовых сценариев формируется отчет следующего вида.
![](http://habrastorage.org/files/ca9/99f/80c/ca999f80c9e24e1788835c5825302f40.png)
В отчете видно, в каких сценариях возникли ошибки. Для проваленных сценариев снимается скриншот в момент возникновения ошибки — это иногда позволяет сразу определить причину фейла. Если при ручном воспроизведении тестового сценария баг повторяется, он заводится в баг-трекере.
Тестирование миграции
При активном развитии приложения и выходе новых версий перед тестировщиками встает проблема проверки миграции данных со старых версий. Постоянная установка старых версий, наполнение базы данными и обновление до тестируемой версии — очень трудоемкие задачи, которые вызывают уныние даже у самого усидчивого тестировщика. Для облегчения данного процесса мы собираем сендбоксы для каждой версии приложения с уже заполненной (вручную) базой данных и затем многократно используем их для проверки корректности миграции. При таком подходе появляется возможность автоматизировать данный вид тестирования.
Загрузка сендбоксов на симулятор не представляет сложности и сводится к копированию файлов в нужную директорию симулятора. Для загрузки же сендбоксов на устройство используется утилита
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 раз для минимизации случайных отклонений значений
- Сбор логов, поиск соответствующих метрик, усреднение значений и формирование отчета
![](http://habrastorage.org/files/5e0/eea/422/5e0eea4226f34478b5568f05d3e31fc9.gif)
Для формирования отчета написана библиотека, реализующая API для быстрого добавления новых значений в отчет. Для работы c html библиотека использует гем nokogiri.
Очевидно, что данный вид тестирования должен проводиться на физических устройствах. Фактически чтобы запустить тест на девайсе, а не на симуляторе, надо изменить UDID и добавить IP-адрес Wi-Fi-соединения.
Главным артефактом тестирования является отчет, по которому можно судить о динамике изменения тех или иных метрик. В случае превышения заданных лимитов происходит «разбор полетов» для выяснения причин ухудшения быстродействия.
Планы и перспективы
Главным направлением дальнейшего развития автотестов является снижение влияния внешних факторов и внедрение «заглушек». Это позволит ускорить выполнение сценариев и исключить влияние серверных ошибок на результаты тестов клиентского приложения.
Здесь возможны два варианта, отличающихся местом расположения «заглушки»:
- внутри приложения
- вне приложения
Первый вариант является наиболее гибким и удобным, но обладает одним существенным недостатком — это потенциальное влияние тестовых «заглушек» на приложение; другими словами, релизное приложение и тестируемое будут отличаться друг от друга.
Второй вариант подразумевает реализацию фейк-сервера, который реализует только протокол взаимодействия и работает локально с клиентом. При таком подходе приложение не испытывает побочных эффектов, однако требуются дополнительные трудозатраты на реализацию фейк-сервера.
Наверняка многим из читающих эту статью есть чем поделиться в области автоматизации тестирования. Предлагаю делиться наработками в этой области в комментариях.