Automação de análise de produto de teste em aplicativos móveis

Testar todos os eventos de análise de produto antes de cada lançamento costuma ser demorado. Isso pode ser automatizado. Mostro exatamente como, usando o exemplo de um aplicativo iOS.

- , ? ? ? ?

, , .

, . , , , . , . :

  1. ( Push) . , , - , .

  2. , . — .

  3. .

Eventos analíticos em 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