Automation of testing product analytics in mobile applications

Testing all product analytics events before each release is usually time consuming. This can be automated. I show you exactly how, using the example of an iOS application.

- , ? ? ? ?

, , .

, . , , , . , . :

  1. ( Push) . , , - , .

  2. , . โ€” .

  3. .

Analytics events in 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-.

, , . , : . .




All Articles