IT-инфраструктура для бизнеса и творчества
Разработка
red_mad_robot

Автоматизация тестирования продуктовой аналитики в мобильных приложениях

Тестирование всех событий продуктовой аналитики перед каждым релизом обычно отнимает время. Но это можно автоматизировать. Даниил Субботин, железный разработчик Redmadrobot, на примере iOS-приложения показывает, как это сделать.

Вы когда-нибудь выпускали релиз, в котором случайно удалили код отправляющий некоторые важные события аналитики? Или забывали покрыть событиями новую фичу? А сколько времени ваши аналитики или тестировщики тратят на ручное тестирование перед каждым релизом? А если это приложение с тысячей событий?

В этой статье расскажу, как автоматизировать тестирование продуктовой аналитики, чтобы избежать проблем и сэкономить время и деньги.

Тестирование аналитики вручную

Когда пользователь совершает действие, то событие сразу отправляется в систему аналитики. И, к сожалению, это никак не отследить, чтобы протестировать. Только если модифицировать код приложения, чтобы вместе с отправкой события сделать дополнительное действие. Какие есть варианты:

  • Можно отправить локальное уведомление (типа Push) с названием и параметрами события. Это неудобно, так как перекрывает интерфейс приложения, а также сложно тестировать цепочку событий из-за того, что каждое новое уведомление перекрывает старые.
  • Добавить отладочный экран, на котором показан список всех отправленных событий. Но это тоже не очень удобно — нужно постоянно переключаться между приложением и этим экраном.
  • Либо события аналитики можно логировать и сразу отслеживать в консоли.
События аналитики в Console.app

Третий вариант — самый удобный: его несложно сделать и он позволяет фильтровать события. Также при тестировании приложения сразу видно, какие события отправляются. И если приложение содержит небольшое количество событий, то этим вариантом можно обойтись, не прибегая к автоматизации.

Это всё способы тестирования события аналитики вручную. Но если в приложении событий много, то такое тестирование будет не быстрым.

Чтобы автоматизировать его, можно воспользоваться UI-тестами. С их помощью можно переходить между экранами, совершать действия и проверять, что определенные события с указанными параметрами отправляются.

Тестирование аналитики UI-тестами

У любого события есть имя, у некоторых бывают еще и параметры. Например, у «успешность авторизации» имя authorization и булевый параметр success.

Вообще, из UI-тестов нельзя узнать, какие события отправило приложение. Когда пользователь совершает действие, то они сразу попадают в систему аналитики. Но в этот момент их можно перехватить и сохранить в место, куда у UI-тестов есть доступ.

На практике существуют два способа передачи данных из приложения в UI-тесты:

  • Можно сохранить текстовые данные в невидимое текстовое поле или в свойство accessibilityLabel невидимой «вьюшки». Но в этом случае меняется иерархия «вьюшек», и это может привести к багам. Кроме того, не получится очистить список отправленных событий из UI-тестов.
  • Или можно сохранить текстовые данные в буфер обмена, к которому у UI-тестов есть доступ. Этот вариант лучше, так как иерархия «вьюшек» не изменяется. Буфер обмена можно очистить из UI-тестов, а еще это проще в реализации.

Когда приложение запущено в режиме UI-тестирования, то можно подменить сервис отправки событий аналитики. Например, вместо AppMetrica подставить свой сервис, который будет отправлять события в буфер обмена. Далее в UI-тестах происходит чтение текстовых данных из буфера, преобразование их в массив событий и проверка.

Так в итоге будет выглядеть UI-тест, проверяющий события аналитики на экране авторизации:

func testLoginSuccess() { // Запустить приложение launchApp() // Проверить что отправилось событие показа экрана авторизации analytics.assertContains(name: "open_login_screen") // Успешно залогиниться loginScreen.login(success: true) // Проверить что отправилось событие успешной авторизации analytics.assertContains("authorization", ["success": true]) }

Доработки со стороны приложения

Расскажу о том, как доработать код приложения, чтобы события аналитики отправлялись и в систему аналитики, и в буфер обмена в зависимости от переданных аргументов при запуске приложения.

Базовые сущности

Представим событие аналитики в виде следующей структуры:

public struct MetricEvent: Equatable { public let name: String public let values: [String: AnyHashable]? public init(name: String, values: [String: AnyHashable]? = nil) { self.name = name self.values = values } }

Структура MetricEvent будет использоваться и в коде приложения, и в коде UI-тестов. Поэтому вынесем её в отдельный модуль — MetricExampleCore. Для этого нужно создать новый Target типа Framework.

Событие что-то должно отправлять, поэтому объявим соответствующий протокол:

import MetricExampleCore /// Сервис отправки событий в аналитику public protocol MetricService { func send(event: MetricEvent) }

В первой строчке импортируем модуль, в котором объявили структуру MetricEvent.

Сервисы отправки событий

Этому протоколу будут соответствовать классы, отправляющие события куда-либо. К примеру, класс для отправки событий в AppMetrica:

import Foundation import MetricExampleCore import YandexMobileMetrica open class AppMetricaService: MetricService { public init(configuration: YMMYandexMetricaConfiguration) { YMMYandexMetrica.activate(with: configuration) } open func send(event: MetricEvent) { YMMYandexMetrica.reportEvent(event.name, parameters: event.values, onFailure: nil) } }

В нашем случае нужен класс, который отправляет события в буфер обмена. Создаем его:

import Foundation import MetricExampleCore import UIKit final class MetricServiceForUITests: MetricService { // Массив всех отправленных событий аналитики private var metricEvents: [MetricEvent] = [] func send(event: MetricEvent) { guard ProcessInfo.processInfo.isUITesting, ProcessInfo.processInfo.sendMetricsToPasteboard else { return } if UIPasteboard.general.string == nil || UIPasteboard.general.string?.isEmpty == true { metricEvents = [] } metricEvents.append(event) if let metricsString = try? encodeMetricEvents(metricEvents) { UIPasteboard.general.string = metricsString } } private func encodeMetricEvents(_ events: [MetricEvent]) throws -> String { let arrayOfEvents: [NSDictionary] = events.map { $0.asJSONObject } let data = try JSONSerialization.data(withJSONObject: arrayOfEvents) return String(decoding: data, as: UTF8.self) } }

В методе send можно проверить, что приложение запущено в режиме UI-тестирования и разрешена отправка событий в буфер обмена. Затем в массив всех отправленных событий добавляется новое.

После этого массив представляется в виде текста с использованием метода encodeMetricEvents. Там каждое событие преобразуется в словарь и полученный массив сериализуется. После этого строка сохраняется в буфер обмена.

// MetricEvent.swift ... /// Представляет событие в виде словаря для передачи в JSONSerialization.data(withJSONObject:) public var asJSONObject: NSDictionary { return [ "name": name, "values": values ?? [:] ] } ...

Каждый UIViewController, который будет отправлять события, получит в инициализатор зависимость MetricService.

final class LoginViewController: UIViewController { private let metricService: MetricService init(metricService: MetricService = ServiceLayer.shared.metricService) { self.metricService = metricService super.init(nibName: nil, bundle: nil) } ...

Чтобы не передавать каждый раз вручную эту зависимость, можно использовать паттерн Service Locator и создать класс ServiceLayer. В нем будет создаваться и храниться MetricService, который будет передаваться во все контроллеры.

import Foundation import YandexMobileMetrica final class ServiceLayer { static let shared = ServiceLayer() private(set) lazy var metricService: MetricService = { if ProcessInfo.processInfo.isUITesting { return MetricServiceForUITests() } else { let config = YMMYandexMetricaConfiguration(apiKey: "APP_METRICA_API_KEY") return AppMetricaService(configuration: config) } }() }

Если приложение запущено в режиме UI-тестирования, то для отправки событий используется MetricServiceForUITests. В ином случае AppMetricaService.

Отправка событий

Осталось объявить все события, которые будут отправляться. Для этого нужно написать расширение MetricEvent:

import Foundation import MetricExampleCore extension MetricEvent { /// Пользователь перешел на экран авторизации static var openLogin: MetricEvent { MetricEvent(name: "open_login_screen") } /// Пользователь ввел логин и пароль и инициировал авторизацию. /// /// - Parameter success: Успешность запроса. /// - Returns: Событие метрики. static func authorization(success: Bool) -> MetricEvent { MetricEvent( name: "authorization", values: ["success": success] ) } }

Теперь события можно отправлять:

metricService.send(event: .openLogin) metricService.send(event: .authorization(success: true)) metricService.send(event: .authorization(success: false))

Аргументы запуска

Я уже упоминал такие вещи, как:

ProcessInfo.processInfo.isUITesting ProcessInfo.processInfo.sendMetricsToPasteboard

При запуске UI-тестов на аналитику будут передаваться два аргумента: --UI-TESTING и --SEND-METRICS-TO-PASTEBOARD.

Первый показывает, что приложение запущено в режиме UI-тестирования. Второй — что приложению разрешено отправлять события аналитики в буфер обмена. Чтобы получить доступ к этим аргументам, нужно написать расширение для ProcessInfo:

import Foundation extension ProcessInfo { var isUITesting: Bool { arguments.contains("--UI-TESTING") } var sendMetricsToPasteboard: Bool { arguments.contains("--SEND-METRICS-TO-PASTEBOARD") } }

Доработки со стороны UI-тестов

Теперь расскажу, как на стороне UI-тестов получить список отправленных событий из буфера обмена и проверить их.

Получение списка отправленных событий

Чтобы получить текстовые данные из буфера, используем UIPasteboard.general.string. Затем строку нужно преобразовать в массив событий (MetricEvent). В методе decodeMetricEvents строка преобразуется в объект Data и десериализуется в массив с помощью JSONSerialization:

/// Возвращает список всех событий аналитики произошедших с момента запуска приложения func extractAnalytics() -> [MetricEvent] { let string = UIPasteboard.general.string! if let events = try? decodeMetricEvents(from: string) { return events } else { return [] } } /// Преобразует строку с массивом событий в массив объектов [MetricEvent] private func decodeMetricEvents(from string: String) throws -> [MetricEvent] { guard !string.isEmpty else { return [] } let data = Data(string.utf8) guard let arrayOfEvents: [NSDictionary] = try JSONSerialization.jsonObject(with: data) as? [NSDictionary] else { return [] } return arrayOfEvents.compactMap { MetricEvent(from: $0) } }

Далее массив словарей преобразуется в массив MetricEvent. Для этого у MetricEvent нужно добавить инициализатор из словаря:

/// Пытается создать объект MetricEvent из словаря public init?(from dict: NSDictionary) { guard let eventName = dict["name"] as? String else { return nil } self = MetricEvent( name: eventName, values: dict["values"] as? [String: AnyHashable]) }

Теперь можно получить массив событий [MetricEvent] и проанализировать его.

Если в процессе тестирования понадобится очистить список событий, то тут поможет:

UIPasteboard.general.string = ""

Проверки списка событий

Можно написать несколько вспомогательных методов, которые будут проверять массив событий. Вот один из них: он проверяет наличие события с указанным именем.

/// Проверяет наличие события с указанным именем /// - Parameters: /// - name: Название события /// - count: Количество событий с указанным именем. По умолчанию равно 1. func assertContains( name: String, count: Int = 1) { let records = extractAnalytics() XCTAssertEqual( records.filter { $0.name == name }.count, count, "Событие с именем \(name) не найдено.") }

В итоге получился класс AnalyticsTestBase. Посмотреть его можно на GitHub — AnalyticsTestBase.swift

Создадим класс, наследника XCTestCase, от которого будут наследоваться классы, тестирующие аналитику. Он создает класс AnalyticsTestBase для тестирования аналитики и метод launchApp, запускающий приложение.

import XCTest class TestCaseBase: XCTestCase { var app: XCUIApplication! var analytics: AnalyticsTestBase! override func setUp() { super.setUp() app = XCUIApplication() analytics = AnalyticsTestBase(app: app) } /// Запускает приложение для UI-тестирования с указанными параметрами. func launchApp(with parameters: AppLaunchParameters = AppLaunchParameters()) { app.launchArguments = parameters.launchArguments app.launch() } }

Метод будет принимать AppLaunchParameters (параметры запуска приложения, о которых я говорил выше).

struct AppLaunchParameters { /// Отправлять аналитику в UIPasteboard private let sendMetricsToPasteboard: Bool init(sendMetricsToPasteboard: Bool = false) { self.sendMetricsToPasteboard = sendMetricsToPasteboard } var launchArguments: [String] { var arguments = ["--UI-TESTING"] if sendMetricsToPasteboard { arguments.append("--SEND-METRICS-TO-PASTEBOARD") } return arguments } }

В обычных UI-тестах приложение будет запускаться с параметрами:

AppLaunchParameters(sendMetricsToPasteboard: false)

А в UI-тестах на аналитику:

AppLaunchParameters(sendMetricsToPasteboard: true)

Теперь можно писать тесты на аналитику. Например, это тест на экран входа:

final class LoginAnalyticsTests: TestCaseBase { private let loginScreen = LoginScreen() func testLoginSuccess() { launchApp(with: AppLaunchParameters(sendMetricsToPasteboard: true)) // Проверить что отправилось событие показа экрана входа analytics.assertContains(name: "open_login_screen") // Успешно залогинится loginScreen.login(success: true) // Проверить что отправилось событие успешной авторизации analytics.assertContains("authorization", ["success": true]) } }

LoginScreen — это Page Object, описывающий экран авторизации. Посмотреть его можно на GitHub — LoginScreen.swift

Примеры

Example проект

Это простое приложение, состоящее из двух экранов: вход и меню. События отправляются при заходе на каждый экран, при авторизации и при выборе пункта меню.

Тест, покрывающий все эти события:

import XCTest final class AnalyticsTests: TestCaseBase { private let loginScreen = LoginScreen() private let menuScreen = MenuScreen() // MARK: - Login func testLoginSuccess() { launchApp(with: AppLaunchParameters(sendMetricsToPasteboard: true)) analytics.assertContains(name: "open_login_screen") loginScreen.login(success: true) analytics.assertContains("authorization", ["success": true]) } func testLoginFailed() { launchApp(with: AppLaunchParameters(sendMetricsToPasteboard: true)) analytics.assertContains(name: "open_login_screen") loginScreen.login(success: false) analytics.assertContains("authorization", ["success": false]) } // MARK: - Menu func testOpenMenu() { launchApp(with: AppLaunchParameters(sendMetricsToPasteboard: true)) loginScreen.login(success: true) waitForElement(menuScreen.title) analytics.assertContains(name: "open_menu_screen") } func testMenuSelection() { launchApp(with: AppLaunchParameters(sendMetricsToPasteboard: true)) loginScreen.login(success: true) waitForElement(menuScreen.title) menuScreen.profileCell.tap() analytics.assertContains("menu_item_selected", ["name": "Профиль"]) menuScreen.messagesCell.tap() analytics.assertContains("menu_item_selected", ["name": "Сообщения"]) } }

Реальный проект

Пример UI-тестов на аналитику экрана авторизации из реального проекта — LoginAnalyticsTests.swift

Пример, как мне, разработчику, помогли UI-тесты на аналитику. На одном проекте нужно было произвести рефакторинг и редизайн главного экрана приложения. Экран был сложным, с большим количеством событий аналитики.

На тот момент в проекте я уже настроил тесты. После рефакторинга и редизайна запустил тесты и обнаружил, что некоторые события случайно удалил. Если бы не тесты на аналитику, эти события не попали бы в релиз.

Итоги

Плюсы подхода:

  • Продуктовому аналитику или тестировщику не нужно проверять все события аналитики вручную. А это экономия времени и, соответственно, денег.
  • Если у вас настроен CI, то UI-тесты на аналитику можно запускать по расписанию, например, раз в неделю или по команде из Slack.

Есть и минусы:

  • UI-тесты выполняются относительно долго. Имеет смысл запускать их только в процессе регрессионного тестирования перед каждым релизом.
  • UI-тесты на аналитику смогут написать только те тестировщики, которые имеют опыт написания нативных UI-тестов.

В случае ручного тестирования, при добавлении новых событий, нужно линейно больше времени на тестирование. Автоматизированное тестирования быстрее, но для него нужно подготовить инфраструктуру: это займет некоторое время. Но после этого добавление теста на новое событие будет проходить быстрее.

Поэтому в случае большого проекта есть смысл автоматизировать проверку событий аналитики.

Даниил Субботин
ведущий разработчик iOS Redmadrobot
(function () { let cdnUrl = `https://specialsf378ef5-a.akamaihd.net/SelectelBranding/images/` let previousArticleNumber = null let currentArticleNumber = 0 let platform = 'Desktop' let articles = [ { name: 'camera', url: `${cdnUrl}CameraCat`, text: 'умную камеру для\u00A0наблюдения за\u00A0котиками', link: 'https://vc.ru/selectel/306690', num: 3 }, { name: 'chill', url: `${cdnUrl}ChillCat`, text: 'трекер, который подскажет, когда пора отдохнуть', link: 'https://vc.ru/promo/288561-eye-tracker', num: 1 }, { name: 'cloud', url: `${cdnUrl}CloudCat`, text: 'котика: даёшь ему «пять», а\u00A0он делает бэкап в облако', link: 'https://vc.ru/dev/294799-maneki-neko', num: 2 } ] let buttonCycle = document.querySelector('.button--cycle') let buttonChoose = document.querySelector('.button--choose') let buttonMobile = document.querySelector('.button--mobile') let textField = document.querySelector('.selectel-footer-subtitle') let imageAgent = document.querySelector('.image--agent') let banner = document.querySelector('.selectel-footer') buttonCycle.addEventListener('click', cycleClick) buttonChoose.addEventListener('click', () => sendEvent(`Promo ${articles[currentArticleNumber].num} Left`, 'Click')) buttonMobile.addEventListener('click', () => sendEvent(`Promo ${articles[currentArticleNumber].num} Left`, 'Click')) let media = window.matchMedia("(max-width: 570px)") media.addEventListener('change', matchMedia) function matchMedia() { if (media.matches) { platform = 'Mobile' } else { platform = 'Desktop' } update() } matchMedia() function cycleClick(event) { sendEvent(`Promo ${articles[currentArticleNumber].num} Right`, 'Click') if (event) { event.preventDefault() event.stopPropagation() } window.open('https://vc.ru/tag/selectelDIY', '_blank') //cycle(event) } function cycle(event) { // incrementArticleNumber() textField.innerHTML = generatedText() imageAgent.src = articles[currentArticleNumber].url + platform + '.svg?3' imageAgent.setAttribute("class", "") imageAgent.classList.add('image--agent', articles[currentArticleNumber].name) banner.href = articles[currentArticleNumber].link } function update() { banner.href = articles[currentArticleNumber].link imageAgent.src = articles[currentArticleNumber].url + platform + '.svg' textField.innerHTML = generatedText() } function incrementArticleNumber() { previousArticleNumber = currentArticleNumber if (currentArticleNumber >= articles.length - 1) { currentArticleNumber = 0 } else { currentArticleNumber++ } } const sendEvent = (label, action = 'Click') => { const value = `SelectelDIY — loc: Footer — ${label} — ${action}`; if (window.dataLayer !== undefined) { window.dataLayer.push({ event: 'data_event', data_description: value, }); } }; function generatedText() { let defaultText if (platform === 'Desktop') { defaultText = `Мы тут собрали %text%. Хотите научим?` } else { defaultText = `Мы тут собрали %text%.` } return defaultText.replace('%text%', articles[currentArticleNumber].text) } function getRandom(min, max) { min = Math.ceil(min) max = Math.floor(max) return Math.floor(Math.random() * (max - min + 1)) + min } (function create() { currentArticleNumber = getRandom(0, articles.length - 1) cycle() let page = document.querySelector('.page--entry') if (page) { function insertAfter() { let parents = page.querySelectorAll('[data-id="7"]') let referenceNode = parents[0] referenceNode.parentNode.insertBefore(banner, referenceNode.nextSibling); loaded() } setTimeout(() => insertAfter(), 0) } }()) function loaded() { banner.classList.add('loaded') } loadImages([ `${cdnUrl}CameraCatDesktop.svg`, `${cdnUrl}ChillCatDesktop.svg`, `${cdnUrl}CloudCatDesktop.svg`, `${cdnUrl}CameraCatMobile.svg`, `${cdnUrl}ChillCatMobile.svg`, `${cdnUrl}CloudCatMobile.svg?3`, ]) function loadImages(urls) { return Promise.all(urls.map(function (url) { return new Promise(function (resolve) { var img = document.createElement('img'); img.onload = resolve; img.onerror = resolve; img.src = url; }); })); } }())
0
0 комментариев
Популярные
По порядку
Читать все 0 комментариев
Как команде расставить приоритеты, когда всё кажется важным

Мы в WEEEK часто ошибались с приоритетами — брались не за те задачи, постоянно всё пересматривали, путались и т. д. Отчасти из-за этого хаоса мы работали в реактивном режиме — действовали «по обстановке», а не «по плану». Это довольно обычное дело для стартапа, но наша-то цель — помогать людям и командам приводить свои дела в порядок. Поэтому мы…

@АнтиспамБот — когда ты реальная заноза в з@днице, или как давали отпор «П0шлым_Дев4енкам»

Прошло меньше двух недель с того дня, как в статье на vc.ru я рассказал про @antispamname_bot, предназначенный для борьбы со спамом в никнейме юзеров телеграмма вида «PEАЛЬНЫЕ_ZНAКОМСТVА» и «ПОИСК_PAPTHЕRОV».

Из науки в IT: как создать свой стартап и стать преподавателем

Как перейти в IT из другой сферы? Как разработать курс, которому нет аналогов? Как студенту получить максимум пользы от занятий? Рассказывает преподаватель OTUS Сергей Окатов, руководитель курсов «Kotlin Backend Developer» и «Kotlin Developer. Basic».

Авито возглавила три рейтинга App Annie
Завод по производству идей. Как работают акселераторы, зачем они нужны стартапам и куда идти с идеей прямо сейчас

По данным Startup Genome, 9 из 10 стартапов терпят неудачу. Возможных причин «смерти» много: недостаточно протестированная гипотеза, неподтвержденная юнит-экономика, неверная стратегия или просто неудача в подходе к продажам.

Хочу кухню как у подруги: зачем в Циан сделали поиск квартир по фото

Рассказывает Юлия Зыкова, руководитель команды «Аудитория» в Циан.

"Тестировщик" – это просто
Теория тупости: как немецкий пастор нашёл объяснение мирового порядка

И вскоре отправился в концлагерь.

Что Tele2 предлагает клиентам в «черную пятницу»

На главной распродаже года клиентов компании ждут сразу несколько интересных предложений: скидки на смартфоны, пакеты SMS и безлимитный трафик на YouTube, Яндекс.Карты, Яндекс.Навигатор.

Истории поиска product/market fit от основателей Netflix, Uber, Airbnb и других успешных компаний Статьи редакции
Документы сгорели, а главный свидетель выпал из вертолёта: история золотого месторождения, стоившего акционерам $3 млрд Статьи редакции

Проект разработки «крупнейшего золотого месторождения» компанией Bre-X на острове Борнео оказался мошенничеством на миллиарды долларов, за которое так и не смогли никого наказать.

Команда Bre-X на Борнео, около 1997 года, слева направо: менеджер участка Джером Альто, старший вице-президент Джон Фельдерхоф, менеджер по разведке Майкл де Гузман и геолог Сезар Пуспос Сalgary Herald
null