104,27
Рейтинг
Redmadrobot
№1 в разработке цифровых решений для бизнеса
12 октября

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

Блог компании RedmadrobotРазработка под iOSРазработка мобильных приложенийSwiftАналитика мобильных приложений

Тестирование всех событий продуктовой аналитики перед каждым релизом обычно отнимает много времени. Это можно автоматизировать. Показываю, как именно, на примере iOS-приложения.

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

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

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

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

  1. Можно отправить локальное уведомление (типа Push) с названием и параметрами события. Это неудобно, так как перекрывает интерфейс приложения, а также сложно тестировать цепочку событий из-за того, что каждое новое уведомление перекрывает старые.

  2. Добавить отладочный экран, на котором показан список всех отправленных событий. Но это тоже не очень удобно — нужно постоянно переключаться между приложением и этим экраном.

  3. Либо события аналитики можно логировать и сразу отслеживать в консоли.

События аналитики в Console.app
События аналитики в 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 проект

iOS-проект, где используется автоматизированное тестирование аналитики UI-тестами.

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

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

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-тесты на аналитику. На одном проекте нужно было произвести рефакторинг и редизайн главного экрана приложения. Экран был сложным, с большим количеством событий аналитики. На тот момент в проекте я уже настроил тесты. После рефакторинга и редизайна запустил тесты и обнаружил, что некоторые события случайно удалил. Если бы не тесты на аналитику, эти события не попали бы в релиз.

Итоги

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

  1. Продуктовому аналитику или тестировщику не нужно проверять все события аналитики вручную. А это экономия времени и, соответственно, денег.

  2. Если у вас настроен CI, то UI-тесты на аналитику можно запускать по расписанию, например, раз в неделю или по команде из Slack.

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

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

  2. UI-тесты на аналитику смогут написать только те тестировщики, которые имеют опыт написания нативных UI-тестов.

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

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

Теги:iosтестированиеавтоматизацияui-тестыаналитикаredmadrobotмобильное приложениеui-тестированиеаналитика мобильных приложений
Хабы: Блог компании Redmadrobot Разработка под iOS Разработка мобильных приложений Swift Аналитика мобильных приложений
+11
2,3k 24
Комментарии 10
Похожие публикации
Лучшие публикации за сутки