From 3438b2bfbe593515ca5c095390d860c151852a25 Mon Sep 17 00:00:00 2001 From: Thinh Truong Date: Thu, 23 Apr 2026 14:44:19 +0700 Subject: [PATCH 1/3] [#701] Add foundation for analytics tracking - PART 1 --- .../Modules/Analytics/Sources/Analytics.swift | 76 +++++++++++++++++++ .../Analytics/Sources/AnalyticsEvent.swift | 5 ++ .../Analytics/Sources/AnalyticsProtocol.swift | 51 +++++++++++++ .../Analytics/Sources/AnalyticsTracker.swift | 31 ++++++++ .../Sources/AnalyticsTrackerType.swift | 4 + .../ProjectDescriptionHelpers/Module.swift | 3 + .../Target+Initializing.swift | 1 + 7 files changed, 171 insertions(+) create mode 100644 template/Modules/Analytics/Sources/Analytics.swift create mode 100644 template/Modules/Analytics/Sources/AnalyticsEvent.swift create mode 100644 template/Modules/Analytics/Sources/AnalyticsProtocol.swift create mode 100644 template/Modules/Analytics/Sources/AnalyticsTracker.swift create mode 100644 template/Modules/Analytics/Sources/AnalyticsTrackerType.swift diff --git a/template/Modules/Analytics/Sources/Analytics.swift b/template/Modules/Analytics/Sources/Analytics.swift new file mode 100644 index 00000000..03a735ce --- /dev/null +++ b/template/Modules/Analytics/Sources/Analytics.swift @@ -0,0 +1,76 @@ +public final class Analytics: AnalyticsProtocol { + + public static let shared: AnalyticsProtocol = Analytics() + + private var trackers: [AnalyticsTracker] = [] + + private init() {} + + // MARK: - Setup + + public func configure(trackers: [AnalyticsTracker], additionalParameters: [String: Any]? = nil) { + self.trackers = trackers + trackers.forEach { $0.setUp(additionalParameters: additionalParameters) } + } + + public func addTracker(_ tracker: AnalyticsTracker, additionalParameters: [String: Any]? = nil) { + trackers.append(tracker) + tracker.setUp(additionalParameters: additionalParameters) + } + + // MARK: - Event Tracking + + public func trackEvent(name: String, parameters: [String: Any]?) { + trackEvent(name: name, parameters: parameters, on: .allCases) + } + + public func trackEvent(name: String, parameters: [String: Any]?, on trackerTypes: [AnalyticsTrackerType]) { + let targetTrackers = trackers.filter { trackerTypes.contains($0.type) } + targetTrackers.forEach { $0.trackEvent(name: name, parameters: parameters) } + } + + public func trackEvent(_ event: AnalyticsEvent) { + trackEvent(event, on: .allCases) + } + + public func trackEvent(_ event: AnalyticsEvent, on trackerTypes: [AnalyticsTrackerType]) { + trackEvent(name: event.name, parameters: event.parameters, on: trackerTypes) + } + + // MARK: - Screen Tracking + + public func trackScreen(name: String, screenClass: String?) { + trackScreen(name: name, screenClass: screenClass, on: .allCases) + } + + public func trackScreen(name: String, screenClass: String?, on trackerTypes: [AnalyticsTrackerType]) { + let targetTrackers = trackers.filter { trackerTypes.contains($0.type) } + targetTrackers.forEach { $0.trackScreen(name: name, screenClass: screenClass) } + } + + // MARK: - User Properties + + public func setUserProperty(key: String, value: String) { + setUserProperty(key: key, value: value, on: .allCases) + } + + public func setUserProperty(key: String, value: String, on trackerTypes: [AnalyticsTrackerType]) { + let targetTrackers = trackers.filter { trackerTypes.contains($0.type) } + targetTrackers.forEach { $0.setUserProperty(key: key, value: value) } + } + + public func setUserId(_ userId: String?) { + setUserId(userId, on: .allCases) + } + + public func setUserId(_ userId: String?, on trackerTypes: [AnalyticsTrackerType]) { + let targetTrackers = trackers.filter { trackerTypes.contains($0.type) } + targetTrackers.forEach { $0.setUserId(userId) } + } + + // MARK: - Utility + + public func tracker(for type: AnalyticsTrackerType) -> AnalyticsTracker? { + trackers.first { $0.type == type } + } +} diff --git a/template/Modules/Analytics/Sources/AnalyticsEvent.swift b/template/Modules/Analytics/Sources/AnalyticsEvent.swift new file mode 100644 index 00000000..3d1fbe86 --- /dev/null +++ b/template/Modules/Analytics/Sources/AnalyticsEvent.swift @@ -0,0 +1,5 @@ +public protocol AnalyticsEvent { + + var name: String { get } + var parameters: [String: Any]? { get } +} diff --git a/template/Modules/Analytics/Sources/AnalyticsProtocol.swift b/template/Modules/Analytics/Sources/AnalyticsProtocol.swift new file mode 100644 index 00000000..fc5b6915 --- /dev/null +++ b/template/Modules/Analytics/Sources/AnalyticsProtocol.swift @@ -0,0 +1,51 @@ +public protocol AnalyticsProtocol { + + // MARK: - Setup + + func configure(trackers: [AnalyticsTracker], additionalParameters: [String: Any]?) + func addTracker(_ tracker: AnalyticsTracker, additionalParameters: [String: Any]?) + + // MARK: - Event Tracking + + func trackEvent(name: String, parameters: [String: Any]?) + func trackEvent(name: String, parameters: [String: Any]?, on trackerTypes: [AnalyticsTrackerType]) + func trackEvent(_ event: AnalyticsEvent) + func trackEvent(_ event: AnalyticsEvent, on trackerTypes: [AnalyticsTrackerType]) + + // MARK: - Screen Tracking + + func trackScreen(name: String, screenClass: String?) + func trackScreen(name: String, screenClass: String?, on trackerTypes: [AnalyticsTrackerType]) + + // MARK: - User Properties + + func setUserProperty(key: String, value: String) + func setUserProperty(key: String, value: String, on trackerTypes: [AnalyticsTrackerType]) + func setUserId(_ userId: String?) + func setUserId(_ userId: String?, on trackerTypes: [AnalyticsTrackerType]) + + // MARK: - Utility + + func tracker(for type: AnalyticsTrackerType) -> AnalyticsTracker? +} + +// MARK: - Default Parameters + +public extension AnalyticsProtocol { + + func configure(trackers: [AnalyticsTracker]) { + configure(trackers: trackers, additionalParameters: nil) + } + + func addTracker(_ tracker: AnalyticsTracker) { + addTracker(tracker, additionalParameters: nil) + } + + func trackEvent(name: String) { + trackEvent(name: name, parameters: nil) + } + + func trackScreen(name: String) { + trackScreen(name: name, screenClass: nil) + } +} diff --git a/template/Modules/Analytics/Sources/AnalyticsTracker.swift b/template/Modules/Analytics/Sources/AnalyticsTracker.swift new file mode 100644 index 00000000..355faf4a --- /dev/null +++ b/template/Modules/Analytics/Sources/AnalyticsTracker.swift @@ -0,0 +1,31 @@ +public protocol AnalyticsTracker { + + var type: AnalyticsTrackerType { get } + + func setUp(additionalParameters: [String: Any]?) + func trackEvent(name: String, parameters: [String: Any]?) + func trackScreen(name: String, screenClass: String?) + func setUserProperty(key: String, value: String) + func setUserId(_ userId: String?) +} + +// MARK: - Extensions + +public extension AnalyticsTracker { + + func setUp() { + setUp(additionalParameters: nil) + } + + func trackEvent(name: String) { + trackEvent(name: name, parameters: nil) + } + + func trackEvent(_ event: AnalyticsEvent) { + trackEvent(name: event.name, parameters: event.parameters) + } + + func trackScreen(name: String) { + trackScreen(name: name, screenClass: nil) + } +} diff --git a/template/Modules/Analytics/Sources/AnalyticsTrackerType.swift b/template/Modules/Analytics/Sources/AnalyticsTrackerType.swift new file mode 100644 index 00000000..654a0431 --- /dev/null +++ b/template/Modules/Analytics/Sources/AnalyticsTrackerType.swift @@ -0,0 +1,4 @@ +public enum AnalyticsTrackerType: String, CaseIterable { + + case firebase +} diff --git a/template/Tuist/ProjectDescriptionHelpers/Module.swift b/template/Tuist/ProjectDescriptionHelpers/Module.swift index 38673fe8..1163b608 100644 --- a/template/Tuist/ProjectDescriptionHelpers/Module.swift +++ b/template/Tuist/ProjectDescriptionHelpers/Module.swift @@ -2,12 +2,14 @@ import ProjectDescription public enum Module: CaseIterable { + case analytics case data case domain case model public var name: String { switch self { + case .analytics: "Analytics" case .data: "Data" case .domain: "Domain" case .model: "Model" @@ -17,6 +19,7 @@ public enum Module: CaseIterable { public var dependencies: [TargetDependency] { switch self { case .model: [] + case .analytics: [] case .domain: [ .target(name: Module.model.name) diff --git a/template/Tuist/ProjectDescriptionHelpers/Target+Initializing.swift b/template/Tuist/ProjectDescriptionHelpers/Target+Initializing.swift index 2afa430c..cd0a399d 100644 --- a/template/Tuist/ProjectDescriptionHelpers/Target+Initializing.swift +++ b/template/Tuist/ProjectDescriptionHelpers/Target+Initializing.swift @@ -32,6 +32,7 @@ extension Target { resources: ["\(name)/Resources/**"], dependencies: [ // Internal modules + .target(name: Module.analytics.name), .target(name: Module.data.name), .target(name: Module.domain.name), .target(name: Module.model.name), From 8efa25db29be5783b1541c998def7766262213fc Mon Sep 17 00:00:00 2001 From: Thinh Truong Date: Fri, 24 Apr 2026 11:45:28 +0700 Subject: [PATCH 2/3] [#701] Integrate and unit tests for foundation analytics tracking --- .../Sources/ConsoleAnalyticsTracker.swift | 50 +++++ .../Sources/Events/UserLoginEvent.swift | 20 ++ .../Sources/MockAnalyticsTracker.swift | 64 ++++++ .../Analytics/Tests/Resources/.gitkeep | 0 .../Sources/Events/AnalyticsEventsSpec.swift | 23 ++ .../Tests/Sources/Specs/AnalyticsSpec.swift | 197 ++++++++++++++++++ .../Specs/MockAnalyticsTrackerSpec.swift | 159 ++++++++++++++ .../Dependencies/Container+Application.swift | 5 + .../Modules/Landing/LandingViewModel.swift | 10 + .../Landing/LandingViewModelTests.swift | 92 ++++++-- 10 files changed, 606 insertions(+), 14 deletions(-) create mode 100644 template/Modules/Analytics/Sources/ConsoleAnalyticsTracker.swift create mode 100644 template/Modules/Analytics/Sources/Events/UserLoginEvent.swift create mode 100644 template/Modules/Analytics/Sources/MockAnalyticsTracker.swift create mode 100644 template/Modules/Analytics/Tests/Resources/.gitkeep create mode 100644 template/Modules/Analytics/Tests/Sources/Events/AnalyticsEventsSpec.swift create mode 100644 template/Modules/Analytics/Tests/Sources/Specs/AnalyticsSpec.swift create mode 100644 template/Modules/Analytics/Tests/Sources/Specs/MockAnalyticsTrackerSpec.swift diff --git a/template/Modules/Analytics/Sources/ConsoleAnalyticsTracker.swift b/template/Modules/Analytics/Sources/ConsoleAnalyticsTracker.swift new file mode 100644 index 00000000..36cd1c35 --- /dev/null +++ b/template/Modules/Analytics/Sources/ConsoleAnalyticsTracker.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Console tracker that logs analytics events to the console for debugging +public final class ConsoleAnalyticsTracker: AnalyticsTracker { + + public let type: AnalyticsTrackerType + private let prefix: String + + public init(type: AnalyticsTrackerType, logPrefix: String? = nil) { + self.type = type + self.prefix = logPrefix ?? "[\(type.rawValue.uppercased())]" + } + + // MARK: - AnalyticsTracker Implementation + + public func setUp(additionalParameters: [String: Any]?) { + let paramsString = additionalParameters?.description ?? "none" + print("\(prefix) SETUP - Additional parameters: \(paramsString)") + } + + public func trackEvent(name: String, parameters: [String: Any]?) { + var message = "\(prefix) EVENT - \(name)" + + if let parameters = parameters, !parameters.isEmpty { + let paramsString = parameters.map { "\($0.key): \($0.value)" }.joined(separator: ", ") + message += " | Parameters: {\(paramsString)}" + } + + print(message) + } + + public func trackScreen(name: String, screenClass: String?) { + var message = "\(prefix) SCREEN - \(name)" + + if let screenClass = screenClass { + message += " | Class: \(screenClass)" + } + + print(message) + } + + public func setUserProperty(key: String, value: String) { + print("\(prefix) USER_PROPERTY - \(key): \(value)") + } + + public func setUserId(_ userId: String?) { + let userIdString = userId ?? "null" + print("\(prefix) USER_ID - \(userIdString)") + } +} diff --git a/template/Modules/Analytics/Sources/Events/UserLoginEvent.swift b/template/Modules/Analytics/Sources/Events/UserLoginEvent.swift new file mode 100644 index 00000000..d5e46448 --- /dev/null +++ b/template/Modules/Analytics/Sources/Events/UserLoginEvent.swift @@ -0,0 +1,20 @@ +/// Example analytics event for user login +public struct UserLoginEvent: AnalyticsEvent { + + public let name = "user_login" + + public let loginMethod: String + public let isSuccessful: Bool + + public init(loginMethod: String, isSuccessful: Bool = true) { + self.loginMethod = loginMethod + self.isSuccessful = isSuccessful + } + + public var parameters: [String: Any]? { + return [ + "login_method": loginMethod, + "is_successful": isSuccessful + ] + } +} diff --git a/template/Modules/Analytics/Sources/MockAnalyticsTracker.swift b/template/Modules/Analytics/Sources/MockAnalyticsTracker.swift new file mode 100644 index 00000000..10031ac8 --- /dev/null +++ b/template/Modules/Analytics/Sources/MockAnalyticsTracker.swift @@ -0,0 +1,64 @@ +/// Mock tracker for testing purposes +public final class MockAnalyticsTracker: AnalyticsTracker { + + public let type: AnalyticsTrackerType + + // MARK: - Tracked Data + + public private(set) var isSetUp = false + public private(set) var setupParameters: [String: Any]? + public private(set) var trackedEvents: [(name: String, parameters: [String: Any]?)] = [] + public private(set) var trackedScreens: [(name: String, screenClass: String?)] = [] + public private(set) var userProperties: [String: String] = [:] + public private(set) var userId: String? + + public init(type: AnalyticsTrackerType) { + self.type = type + } + + // MARK: - AnalyticsTracker Implementation + + public func setUp(additionalParameters: [String: Any]?) { + isSetUp = true + setupParameters = additionalParameters + } + + public func trackEvent(name: String, parameters: [String: Any]?) { + trackedEvents.append((name: name, parameters: parameters)) + } + + public func trackScreen(name: String, screenClass: String?) { + trackedScreens.append((name: name, screenClass: screenClass)) + } + + public func setUserProperty(key: String, value: String) { + userProperties[key] = value + } + + public func setUserId(_ userId: String?) { + self.userId = userId + } + + // MARK: - Test Helpers + + public func reset() { + isSetUp = false + setupParameters = nil + trackedEvents.removeAll() + trackedScreens.removeAll() + userProperties.removeAll() + userId = nil + } + + public func hasTrackedEvent(name: String) -> Bool { + trackedEvents.contains { $0.name == name } + } + + public func hasTrackedScreen(name: String) -> Bool { + trackedScreens.contains { $0.name == name } + } + + public func eventCount(for name: String) -> Int { + trackedEvents.filter { $0.name == name }.count + } +} diff --git a/template/Modules/Analytics/Tests/Resources/.gitkeep b/template/Modules/Analytics/Tests/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/template/Modules/Analytics/Tests/Sources/Events/AnalyticsEventsSpec.swift b/template/Modules/Analytics/Tests/Sources/Events/AnalyticsEventsSpec.swift new file mode 100644 index 00000000..9e463773 --- /dev/null +++ b/template/Modules/Analytics/Tests/Sources/Events/AnalyticsEventsSpec.swift @@ -0,0 +1,23 @@ +import Testing +@testable import Analytics + +@Suite("UserLoginEvent Tests") +struct UserLoginEventTests { + + @Test("Create event with correct parameters") + func createEventWithCorrectParameters() { + let event = UserLoginEvent(loginMethod: "email", isSuccessful: true) + + #expect(event.name == "user_login") + #expect(event.parameters?["login_method"] as? String == "email") + #expect(event.parameters?["is_successful"] as? Bool == true) + } + + @Test("Handle failed login") + func handleFailedLogin() { + let event = UserLoginEvent(loginMethod: "social", isSuccessful: false) + + #expect(event.parameters?["is_successful"] as? Bool == false) + #expect(event.parameters?["login_method"] as? String == "social") + } +} diff --git a/template/Modules/Analytics/Tests/Sources/Specs/AnalyticsSpec.swift b/template/Modules/Analytics/Tests/Sources/Specs/AnalyticsSpec.swift new file mode 100644 index 00000000..503e6d4b --- /dev/null +++ b/template/Modules/Analytics/Tests/Sources/Specs/AnalyticsSpec.swift @@ -0,0 +1,197 @@ +import Testing +@testable import Analytics + +@Suite("Analytics Tests") +struct AnalyticsTests { + + // MARK: - Singleton Tests + + @Test("Analytics should be a singleton") + func analyticsSingleton() { + let instance1 = Analytics.shared + let instance2 = Analytics.shared + + #expect(instance1 === instance2) + } + + @Test("Analytics should conform to AnalyticsProtocol") + func analyticsProtocolConformance() { + let analytics = Analytics.shared + #expect(analytics is AnalyticsProtocol) + } + + // MARK: - Configuration Tests + + @Test("Configure trackers with additional parameters") + func configureTrackersWithParameters() { + let sut = Analytics() + let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) + let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + let additionalParams = ["app_version": "1.0.0", "build": "123"] + + sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker], additionalParameters: additionalParams) + + #expect(mockFirebaseTracker.isSetUp) + #expect(mockAppsFlyerTracker.isSetUp) + #expect(mockFirebaseTracker.setupParameters as? [String: String] == additionalParams) + } + + @Test("Add individual trackers") + func addIndividualTracker() { + let sut = Analytics() + let mockTracker = MockAnalyticsTracker(type: .firebase) + let params = ["test": "value"] + + sut.addTracker(mockTracker, additionalParameters: params) + + #expect(mockTracker.isSetUp) + #expect(mockTracker.setupParameters as? [String: String] == params) + } + + // MARK: - Event Tracking Tests + + @Test("Track events on all trackers by default") + func trackEventOnAllTrackers() { + let sut = Analytics() + let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) + let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + + sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.trackEvent(name: "user_login", parameters: ["method": "email"]) + + #expect(mockFirebaseTracker.hasTrackedEvent(name: "user_login")) + #expect(mockAppsFlyerTracker.hasTrackedEvent(name: "user_login")) + + let firebaseEvent = mockFirebaseTracker.trackedEvents.first + #expect(firebaseEvent?.parameters?["method"] as? String == "email") + } + + @Test("Track events on specific trackers") + func trackEventOnSpecificTrackers() { + let sut = Analytics() + let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) + let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + + sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.trackEvent(name: "purchase", parameters: ["amount": 9.99], on: [.firebase]) + + #expect(mockFirebaseTracker.hasTrackedEvent(name: "purchase")) + #expect(!mockAppsFlyerTracker.hasTrackedEvent(name: "purchase")) + } + + @Test("Track events without parameters") + func trackEventWithoutParameters() { + let sut = Analytics() + let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) + let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + + sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.trackEvent(name: "app_open") + + #expect(mockFirebaseTracker.hasTrackedEvent(name: "app_open")) + #expect(mockAppsFlyerTracker.hasTrackedEvent(name: "app_open")) + } + + @Test("Track structured events") + func trackStructuredEvent() { + let sut = Analytics() + let mockTracker = MockAnalyticsTracker(type: .firebase) + + sut.configure(trackers: [mockTracker]) + + let loginEvent = UserLoginEvent(loginMethod: "email", isSuccessful: true) + sut.trackEvent(loginEvent) + + #expect(mockTracker.hasTrackedEvent(name: "user_login")) + let event = mockTracker.trackedEvents.first + #expect(event?.parameters?["login_method"] as? String == "email") + #expect(event?.parameters?["is_successful"] as? Bool == true) + } + + // MARK: - Screen Tracking Tests + + @Test("Track screens on all trackers by default") + func trackScreenOnAllTrackers() { + let sut = Analytics() + let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) + let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + + sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.trackScreen(name: "HomeScreen", screenClass: "HomeViewController") + + #expect(mockFirebaseTracker.hasTrackedScreen(name: "HomeScreen")) + #expect(mockAppsFlyerTracker.hasTrackedScreen(name: "HomeScreen")) + } + + @Test("Track screens on specific trackers") + func trackScreenOnSpecificTrackers() { + let sut = Analytics() + let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) + let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + + sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.trackScreen(name: "ProfileScreen", screenClass: nil, on: [.appsFlyer]) + + #expect(!mockFirebaseTracker.hasTrackedScreen(name: "ProfileScreen")) + #expect(mockAppsFlyerTracker.hasTrackedScreen(name: "ProfileScreen")) + } + + // MARK: - User Properties Tests + + @Test("Set user properties on all trackers by default") + func setUserPropertyOnAllTrackers() { + let sut = Analytics() + let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) + let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + + sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.setUserProperty(key: "subscription_type", value: "premium") + + #expect(mockFirebaseTracker.userProperties["subscription_type"] == "premium") + #expect(mockAppsFlyerTracker.userProperties["subscription_type"] == "premium") + } + + @Test("Set user properties on specific trackers") + func setUserPropertyOnSpecificTrackers() { + let sut = Analytics() + let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) + let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + + sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.setUserProperty(key: "age_group", value: "25-34", on: [.firebase]) + + #expect(mockFirebaseTracker.userProperties["age_group"] == "25-34") + #expect(mockAppsFlyerTracker.userProperties["age_group"] == nil) + } + + @Test("Set user ID on all trackers by default") + func setUserIdOnAllTrackers() { + let sut = Analytics() + let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) + let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + + sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.setUserId("user_12345") + + #expect(mockFirebaseTracker.userId == "user_12345") + #expect(mockAppsFlyerTracker.userId == "user_12345") + } + + // MARK: - Tracker Utility Tests + + @Test("Return specific tracker by type") + func getTrackerByType() { + let sut = Analytics() + let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) + let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + + sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + + let firebaseTracker = sut.tracker(for: .firebase) + let facebookTracker = sut.tracker(for: .facebook) + + #expect(firebaseTracker != nil) + #expect(firebaseTracker?.type == .firebase) + #expect(facebookTracker == nil) + } +} diff --git a/template/Modules/Analytics/Tests/Sources/Specs/MockAnalyticsTrackerSpec.swift b/template/Modules/Analytics/Tests/Sources/Specs/MockAnalyticsTrackerSpec.swift new file mode 100644 index 00000000..c0b89807 --- /dev/null +++ b/template/Modules/Analytics/Tests/Sources/Specs/MockAnalyticsTrackerSpec.swift @@ -0,0 +1,159 @@ +import Testing +@testable import Analytics + +@Suite("MockAnalyticsTracker Tests") +struct MockAnalyticsTrackerTests { + + // MARK: - Initialization Tests + + @Test("MockAnalyticsTracker initializes with correct type") + func initializeWithCorrectType() { + let sut = MockAnalyticsTracker(type: .firebase) + + #expect(sut.type == .firebase) + #expect(!sut.isSetUp) + } + + // MARK: - Setup Tests + + @Test("Track setup call with parameters") + func trackSetupWithParameters() { + let sut = MockAnalyticsTracker(type: .firebase) + let params = ["key": "value"] + + sut.setUp(additionalParameters: params) + + #expect(sut.isSetUp) + #expect(sut.setupParameters as? [String: String] == params) + } + + @Test("Handle setup without parameters") + func handleSetupWithoutParameters() { + let sut = MockAnalyticsTracker(type: .firebase) + + sut.setUp(additionalParameters: nil) + + #expect(sut.isSetUp) + #expect(sut.setupParameters == nil) + } + + // MARK: - Event Tracking Tests + + @Test("Track events with parameters") + func trackEventsWithParameters() { + let sut = MockAnalyticsTracker(type: .firebase) + + sut.trackEvent(name: "test_event", parameters: ["param1": "value1"]) + + #expect(sut.hasTrackedEvent(name: "test_event")) + #expect(sut.eventCount(for: "test_event") == 1) + + let event = sut.trackedEvents.first + #expect(event?.name == "test_event") + #expect(event?.parameters?["param1"] as? String == "value1") + } + + @Test("Track events without parameters") + func trackEventsWithoutParameters() { + let sut = MockAnalyticsTracker(type: .firebase) + + sut.trackEvent(name: "simple_event", parameters: nil) + + #expect(sut.hasTrackedEvent(name: "simple_event")) + #expect(sut.trackedEvents.first?.parameters == nil) + } + + @Test("Track multiple events") + func trackMultipleEvents() { + let sut = MockAnalyticsTracker(type: .firebase) + + sut.trackEvent(name: "event1", parameters: nil) + sut.trackEvent(name: "event2", parameters: nil) + sut.trackEvent(name: "event1", parameters: nil) + + #expect(sut.trackedEvents.count == 3) + #expect(sut.eventCount(for: "event1") == 2) + #expect(sut.eventCount(for: "event2") == 1) + } + + // MARK: - Screen Tracking Tests + + @Test("Track screens with class") + func trackScreensWithClass() { + let sut = MockAnalyticsTracker(type: .firebase) + + sut.trackScreen(name: "HomeScreen", screenClass: "HomeViewController") + + #expect(sut.hasTrackedScreen(name: "HomeScreen")) + + let screen = sut.trackedScreens.first + #expect(screen?.name == "HomeScreen") + #expect(screen?.screenClass == "HomeViewController") + } + + @Test("Track screens without class") + func trackScreensWithoutClass() { + let sut = MockAnalyticsTracker(type: .firebase) + + sut.trackScreen(name: "ProfileScreen", screenClass: nil) + + #expect(sut.hasTrackedScreen(name: "ProfileScreen")) + #expect(sut.trackedScreens.first?.screenClass == nil) + } + + // MARK: - User Properties Tests + + @Test("Set user properties") + func setUserProperties() { + let sut = MockAnalyticsTracker(type: .firebase) + + sut.setUserProperty(key: "subscription", value: "premium") + sut.setUserProperty(key: "age", value: "25") + + #expect(sut.userProperties["subscription"] == "premium") + #expect(sut.userProperties["age"] == "25") + } + + @Test("Set user ID") + func setUserId() { + let sut = MockAnalyticsTracker(type: .firebase) + + sut.setUserId("user_123") + + #expect(sut.userId == "user_123") + } + + @Test("Handle nil user ID") + func handleNilUserId() { + let sut = MockAnalyticsTracker(type: .firebase) + + sut.setUserId("user_123") + sut.setUserId(nil) + + #expect(sut.userId == nil) + } + + // MARK: - Test Helpers Tests + + @Test("Reset all tracked data") + func resetAllTrackedData() { + let sut = MockAnalyticsTracker(type: .firebase) + + // Setup some data + sut.setUp(additionalParameters: ["key": "value"]) + sut.trackEvent(name: "test", parameters: nil) + sut.trackScreen(name: "screen", screenClass: nil) + sut.setUserProperty(key: "prop", value: "val") + sut.setUserId("user") + + // Reset and verify + sut.reset() + + #expect(!sut.isSetUp) + #expect(sut.setupParameters == nil) + #expect(sut.trackedEvents.isEmpty) + #expect(sut.trackedScreens.isEmpty) + #expect(sut.userProperties.isEmpty) + #expect(sut.userId == nil) + } +} diff --git a/template/Tuist/Interfaces/SwiftUI/Sources/Application/Dependencies/Container+Application.swift b/template/Tuist/Interfaces/SwiftUI/Sources/Application/Dependencies/Container+Application.swift index 6fcd4a2c..63fdf433 100644 --- a/template/Tuist/Interfaces/SwiftUI/Sources/Application/Dependencies/Container+Application.swift +++ b/template/Tuist/Interfaces/SwiftUI/Sources/Application/Dependencies/Container+Application.swift @@ -1,9 +1,14 @@ +import Analytics import Data import Domain import FactoryKit extension Container { + var analytics: Factory { + self { Analytics.shared }.singleton + } + var loadStartupConfigUseCase: Factory { self { LoadStartupConfigUseCase(remoteConfigRepository: self.remoteConfigRepository()) } } diff --git a/template/Tuist/Interfaces/SwiftUI/Sources/Presentation/Modules/Landing/LandingViewModel.swift b/template/Tuist/Interfaces/SwiftUI/Sources/Presentation/Modules/Landing/LandingViewModel.swift index 349cb28c..b3cc1f7f 100644 --- a/template/Tuist/Interfaces/SwiftUI/Sources/Presentation/Modules/Landing/LandingViewModel.swift +++ b/template/Tuist/Interfaces/SwiftUI/Sources/Presentation/Modules/Landing/LandingViewModel.swift @@ -1,3 +1,4 @@ +import Analytics import Data import Domain import FactoryKit @@ -19,6 +20,7 @@ final class LandingViewModel: ObservableObject { @Published private(set) var state: State = .loading private(set) var startupConfigLoadResult: StartupConfigLoadResult? + @Injected(\.analytics) private var analytics: AnalyticsProtocol @Injected(\.loadStartupConfigUseCase) private var loadStartupConfigUseCase: any LoadStartupConfigUseCaseProtocol @Injected(\.sessionRepository) private var sessionRepository: any SessionRepositoryProtocol @Injected(\.checkForceUpdateUseCase) private var checkForceUpdateUseCase: any CheckForceUpdateUseCaseProtocol @@ -48,8 +50,16 @@ final class LandingViewModel: ObservableObject { do { try await sessionRepository.save(tokenSet: DemoTokenSet()) state = .signedIn + + // Track successful login event + let loginEvent = UserLoginEvent(loginMethod: "demo", isSuccessful: true) + analytics.trackEvent(loginEvent) } catch { state = .signedOut + + // Track failed login event + let loginEvent = UserLoginEvent(loginMethod: "demo", isSuccessful: false) + analytics.trackEvent(loginEvent) } } diff --git a/template/{PROJECT_NAME}Tests/Sources/Specs/Presentation/Modules/Landing/LandingViewModelTests.swift b/template/{PROJECT_NAME}Tests/Sources/Specs/Presentation/Modules/Landing/LandingViewModelTests.swift index 99f8cdad..b5d2fab8 100644 --- a/template/{PROJECT_NAME}Tests/Sources/Specs/Presentation/Modules/Landing/LandingViewModelTests.swift +++ b/template/{PROJECT_NAME}Tests/Sources/Specs/Presentation/Modules/Landing/LandingViewModelTests.swift @@ -1,8 +1,9 @@ -import Testing +import Analytics import Domain import FactoryKit import Foundation import Model +import Testing @testable import {PROJECT_NAME} @@ -11,7 +12,7 @@ struct LandingViewModelTests { @Test("shows the signed-out flow when no active session exists") func showsTheSignedOutFlowWhenNoActiveSessionExists() async { - await Self.withSUT { _, _, viewModel in + await Self.withSUT { _, _, _, viewModel in await viewModel.restoreSessionIfNeeded() #expect(viewModel.state == .signedOut) @@ -21,7 +22,7 @@ struct LandingViewModelTests { @Test("shows the signed-in flow when an active session exists") func showsTheSignedInFlowWhenAnActiveSessionExists() async { - await Self.withSUT { sessionRepository, _, viewModel in + await Self.withSUT { sessionRepository, _, _, viewModel in await sessionRepository.setHasActiveSession(true) await viewModel.restoreSessionIfNeeded() @@ -33,7 +34,7 @@ struct LandingViewModelTests { @Test("falls back to local defaults before showing the signed-out flow") func fallsBackToLocalDefaultsBeforeShowingTheSignedOutFlow() async { - await Self.withSUT(startupConfigLoadResult: .usedLocalDefaults) { _, loader, viewModel in + await Self.withSUT(startupConfigLoadResult: .usedLocalDefaults) { _, loader, _, viewModel in await viewModel.restoreSessionIfNeeded() #expect(viewModel.state == .signedOut) @@ -44,7 +45,7 @@ struct LandingViewModelTests { @Test("retries restoration after cancellation") func retriesRestorationAfterCancellation() async { - await Self.withSUT(cancelFirstCall: true) { _, loader, viewModel in + await Self.withSUT(cancelFirstCall: true) { _, loader, _, viewModel in await viewModel.restoreSessionIfNeeded() #expect(viewModel.state == .loading) @@ -61,7 +62,7 @@ struct LandingViewModelTests { @Test("loads startup config only once") func loadsStartupConfigOnlyOnce() async { - await Self.withSUT { _, loader, viewModel in + await Self.withSUT { _, loader, _, viewModel in await viewModel.restoreSessionIfNeeded() await viewModel.restoreSessionIfNeeded() @@ -71,7 +72,7 @@ struct LandingViewModelTests { @Test("shows the force update screen when a force update is required") func showsTheForceUpdateScreenWhenAForceUpdateIsRequired() async { - await Self.withSUT(forceUpdateRequired: true) { _, _, viewModel in + await Self.withSUT(forceUpdateRequired: true) { _, _, _, viewModel in await viewModel.restoreSessionIfNeeded() #expect(viewModel.state == .forceUpdateRequired) @@ -81,7 +82,7 @@ struct LandingViewModelTests { @Test("skips the session check when a force update is required") func skipsTheSessionCheckWhenAForceUpdateIsRequired() async { - await Self.withSUT(forceUpdateRequired: true) { sessionRepository, _, viewModel in + await Self.withSUT(forceUpdateRequired: true) { sessionRepository, _, _, viewModel in await viewModel.restoreSessionIfNeeded() #expect(viewModel.state == .forceUpdateRequired) @@ -91,16 +92,28 @@ struct LandingViewModelTests { @Test("activates a demo session and shows the signed-in flow") func activatesADemoSessionAndShowsTheSignedInFlow() async { - await Self.withSUT { _, _, viewModel in + await Self.withSUT { _, _, _, viewModel in await viewModel.continueWithDemoSession() #expect(viewModel.state == .signedIn) } } + @Test("tracks a successful login event when a demo session is activated") + func tracksASuccessfulLoginEventWhenADemoSessionIsActivated() async { + await Self.withSUT { _, _, analytics, viewModel in + await viewModel.continueWithDemoSession() + + #expect(analytics.trackedStructEvents.count == 1) + #expect(analytics.trackedStructEvents[0].name == "user_login") + #expect(analytics.trackedStructEvents[0].parameters?["login_method"] as? String == "demo") + #expect(analytics.trackedStructEvents[0].parameters?["is_successful"] as? Bool == true) + } + } + @Test("keeps showing the signed-out flow when activating demo session fails") func keepsShowingTheSignedOutFlowWhenActivatingDemoSessionFails() async { - await Self.withSUT { sessionRepository, _, viewModel in + await Self.withSUT { sessionRepository, _, _, viewModel in await sessionRepository.setShouldFailActivation(true) await viewModel.continueWithDemoSession() @@ -109,9 +122,23 @@ struct LandingViewModelTests { } } + @Test("tracks a failed login event when a demo session activation fails") + func tracksAFailedLoginEventWhenADemoSessionActivationFails() async { + await Self.withSUT { sessionRepository, _, analytics, viewModel in + await sessionRepository.setShouldFailActivation(true) + + await viewModel.continueWithDemoSession() + + #expect(analytics.trackedStructEvents.count == 1) + #expect(analytics.trackedStructEvents[0].name == "user_login") + #expect(analytics.trackedStructEvents[0].parameters?["login_method"] as? String == "demo") + #expect(analytics.trackedStructEvents[0].parameters?["is_successful"] as? Bool == false) + } + } + @Test("clears the session and shows the signed-out flow") func clearsTheSessionAndShowsTheSignedOutFlow() async { - await Self.withSUT { _, _, viewModel in + await Self.withSUT { _, _, _, viewModel in await viewModel.continueWithDemoSession() await viewModel.signOut() @@ -122,7 +149,7 @@ struct LandingViewModelTests { @Test("keeps showing the signed-in flow when clearing session fails") func keepsShowingTheSignedInFlowWhenClearingSessionFails() async { - await Self.withSUT { sessionRepository, _, viewModel in + await Self.withSUT { sessionRepository, _, _, viewModel in await viewModel.continueWithDemoSession() await sessionRepository.setShouldFailClearSession(true) @@ -137,7 +164,7 @@ struct LandingViewModelTests { startupConfigLoadResult: StartupConfigLoadResult = .refreshed, cancelFirstCall: Bool = false, forceUpdateRequired: Bool = false, - _ test: @MainActor (SessionRepositoryMock, StartupConfigLoaderMock, LandingViewModel) async -> Void + _ test: @MainActor (SessionRepositoryMock, StartupConfigLoaderMock, AnalyticsProtocolMock, LandingViewModel) async -> Void ) async { Container.shared.reset() @@ -146,8 +173,10 @@ struct LandingViewModelTests { result: startupConfigLoadResult, shouldCancelFirstCall: cancelFirstCall ) + let analytics = AnalyticsProtocolMock() Container.shared.loadStartupConfigUseCase.register { startupConfigLoader } Container.shared.sessionRepository.register { sessionRepository } + Container.shared.analytics.register { analytics } let checkForceUpdateUseCase = CheckForceUpdateUseCaseMock(shouldForceUpdate: forceUpdateRequired) Container.shared.checkForceUpdateUseCase.register { checkForceUpdateUseCase } @@ -157,8 +186,43 @@ struct LandingViewModelTests { Container.shared.reset() } - await test(sessionRepository, startupConfigLoader, viewModel) + await test(sessionRepository, startupConfigLoader, analytics, viewModel) + } +} + +private final class AnalyticsProtocolMock: AnalyticsProtocol, @unchecked Sendable { + + private(set) var trackedStructEvents: [AnalyticsEvent] = [] + + func configure(trackers: [AnalyticsTracker], additionalParameters: [String: Any]?) {} + + func addTracker(_ tracker: AnalyticsTracker, additionalParameters: [String: Any]?) {} + + func trackEvent(name: String, parameters: [String: Any]?) {} + + func trackEvent(name: String, parameters: [String: Any]?, on trackerTypes: [AnalyticsTrackerType]) {} + + func trackEvent(_ event: AnalyticsEvent) { + trackedStructEvents.append(event) + } + + func trackEvent(_ event: AnalyticsEvent, on trackerTypes: [AnalyticsTrackerType]) { + trackedStructEvents.append(event) } + + func trackScreen(name: String, screenClass: String?) {} + + func trackScreen(name: String, screenClass: String?, on trackerTypes: [AnalyticsTrackerType]) {} + + func setUserProperty(key: String, value: String) {} + + func setUserProperty(key: String, value: String, on trackerTypes: [AnalyticsTrackerType]) {} + + func setUserId(_ userId: String?) {} + + func setUserId(_ userId: String?, on trackerTypes: [AnalyticsTrackerType]) {} + + func tracker(for type: AnalyticsTrackerType) -> AnalyticsTracker? { nil } } private actor SessionRepositoryMock: SessionRepositoryProtocol { From beb895ce61241ed814ab4f3f945665dd17d447a9 Mon Sep 17 00:00:00 2001 From: Thinh Truong Date: Fri, 24 Apr 2026 14:23:58 +0700 Subject: [PATCH 3/3] [#701] Integrate and unit tests for foundation analytics tracking --- .../Modules/Analytics/Sources/Analytics.swift | 10 +- .../Sources/AnalyticsTrackerType.swift | 2 +- .../Tests/Sources/Specs/AnalyticsSpec.swift | 96 ++++++++----------- .../SwiftUI/Sources/Application/App.swift | 12 +++ 4 files changed, 57 insertions(+), 63 deletions(-) diff --git a/template/Modules/Analytics/Sources/Analytics.swift b/template/Modules/Analytics/Sources/Analytics.swift index 03a735ce..ebd70f1d 100644 --- a/template/Modules/Analytics/Sources/Analytics.swift +++ b/template/Modules/Analytics/Sources/Analytics.swift @@ -21,7 +21,7 @@ public final class Analytics: AnalyticsProtocol { // MARK: - Event Tracking public func trackEvent(name: String, parameters: [String: Any]?) { - trackEvent(name: name, parameters: parameters, on: .allCases) + trackEvent(name: name, parameters: parameters, on: AnalyticsTrackerType.allCases) } public func trackEvent(name: String, parameters: [String: Any]?, on trackerTypes: [AnalyticsTrackerType]) { @@ -30,7 +30,7 @@ public final class Analytics: AnalyticsProtocol { } public func trackEvent(_ event: AnalyticsEvent) { - trackEvent(event, on: .allCases) + trackEvent(event, on: AnalyticsTrackerType.allCases) } public func trackEvent(_ event: AnalyticsEvent, on trackerTypes: [AnalyticsTrackerType]) { @@ -40,7 +40,7 @@ public final class Analytics: AnalyticsProtocol { // MARK: - Screen Tracking public func trackScreen(name: String, screenClass: String?) { - trackScreen(name: name, screenClass: screenClass, on: .allCases) + trackScreen(name: name, screenClass: screenClass, on: AnalyticsTrackerType.allCases) } public func trackScreen(name: String, screenClass: String?, on trackerTypes: [AnalyticsTrackerType]) { @@ -51,7 +51,7 @@ public final class Analytics: AnalyticsProtocol { // MARK: - User Properties public func setUserProperty(key: String, value: String) { - setUserProperty(key: key, value: value, on: .allCases) + setUserProperty(key: key, value: value, on: AnalyticsTrackerType.allCases) } public func setUserProperty(key: String, value: String, on trackerTypes: [AnalyticsTrackerType]) { @@ -60,7 +60,7 @@ public final class Analytics: AnalyticsProtocol { } public func setUserId(_ userId: String?) { - setUserId(userId, on: .allCases) + setUserId(userId, on: AnalyticsTrackerType.allCases) } public func setUserId(_ userId: String?, on trackerTypes: [AnalyticsTrackerType]) { diff --git a/template/Modules/Analytics/Sources/AnalyticsTrackerType.swift b/template/Modules/Analytics/Sources/AnalyticsTrackerType.swift index 654a0431..4d43f417 100644 --- a/template/Modules/Analytics/Sources/AnalyticsTrackerType.swift +++ b/template/Modules/Analytics/Sources/AnalyticsTrackerType.swift @@ -1,4 +1,4 @@ public enum AnalyticsTrackerType: String, CaseIterable { - case firebase + case console } diff --git a/template/Modules/Analytics/Tests/Sources/Specs/AnalyticsSpec.swift b/template/Modules/Analytics/Tests/Sources/Specs/AnalyticsSpec.swift index 503e6d4b..8a0acc63 100644 --- a/template/Modules/Analytics/Tests/Sources/Specs/AnalyticsSpec.swift +++ b/template/Modules/Analytics/Tests/Sources/Specs/AnalyticsSpec.swift @@ -25,21 +25,19 @@ struct AnalyticsTests { @Test("Configure trackers with additional parameters") func configureTrackersWithParameters() { let sut = Analytics() - let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) - let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + let mockConsoleTracker = MockAnalyticsTracker(type: .console) let additionalParams = ["app_version": "1.0.0", "build": "123"] - sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker], additionalParameters: additionalParams) + sut.configure(trackers: [mockConsoleTracker], additionalParameters: additionalParams) - #expect(mockFirebaseTracker.isSetUp) - #expect(mockAppsFlyerTracker.isSetUp) - #expect(mockFirebaseTracker.setupParameters as? [String: String] == additionalParams) + #expect(mockConsoleTracker.isSetUp) + #expect(mockConsoleTracker.setupParameters as? [String: String] == additionalParams) } @Test("Add individual trackers") func addIndividualTracker() { let sut = Analytics() - let mockTracker = MockAnalyticsTracker(type: .firebase) + let mockTracker = MockAnalyticsTracker(type: .console) let params = ["test": "value"] sut.addTracker(mockTracker, additionalParameters: params) @@ -53,39 +51,35 @@ struct AnalyticsTests { @Test("Track events on all trackers by default") func trackEventOnAllTrackers() { let sut = Analytics() - let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) - let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + let mockConsoleTracker = MockAnalyticsTracker(type: .console) - sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.configure(trackers: [mockConsoleTracker]) sut.trackEvent(name: "user_login", parameters: ["method": "email"]) - #expect(mockFirebaseTracker.hasTrackedEvent(name: "user_login")) - #expect(mockAppsFlyerTracker.hasTrackedEvent(name: "user_login")) + #expect(mockConsoleTracker.hasTrackedEvent(name: "user_login")) - let firebaseEvent = mockFirebaseTracker.trackedEvents.first - #expect(firebaseEvent?.parameters?["method"] as? String == "email") + let consoleEvent = mockConsoleTracker.trackedEvents.first + #expect(consoleEvent?.parameters?["method"] as? String == "email") } @Test("Track events on specific trackers") func trackEventOnSpecificTrackers() { let sut = Analytics() - let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) - let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + let mockConsoleTracker = MockAnalyticsTracker(type: .console) - sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) - sut.trackEvent(name: "purchase", parameters: ["amount": 9.99], on: [.firebase]) + sut.configure(trackers: [mockConsoleTracker]) + sut.trackEvent(name: "purchase", parameters: ["amount": 9.99], on: [.console]) - #expect(mockFirebaseTracker.hasTrackedEvent(name: "purchase")) - #expect(!mockAppsFlyerTracker.hasTrackedEvent(name: "purchase")) + #expect(mockConsoleTracker.hasTrackedEvent(name: "purchase")) } @Test("Track events without parameters") func trackEventWithoutParameters() { let sut = Analytics() - let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) + let mockConsoleTracker = MockAnalyticsTracker(type: .console) let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) - sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.configure(trackers: [mockConsoleTracker]) sut.trackEvent(name: "app_open") #expect(mockFirebaseTracker.hasTrackedEvent(name: "app_open")) @@ -95,7 +89,7 @@ struct AnalyticsTests { @Test("Track structured events") func trackStructuredEvent() { let sut = Analytics() - let mockTracker = MockAnalyticsTracker(type: .firebase) + let mockTracker = MockAnalyticsTracker(type: .console) sut.configure(trackers: [mockTracker]) @@ -113,27 +107,24 @@ struct AnalyticsTests { @Test("Track screens on all trackers by default") func trackScreenOnAllTrackers() { let sut = Analytics() - let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) - let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + let mockConsoleTracker = MockAnalyticsTracker(type: .console) - sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.configure(trackers: [mockConsoleTracker]) sut.trackScreen(name: "HomeScreen", screenClass: "HomeViewController") - #expect(mockFirebaseTracker.hasTrackedScreen(name: "HomeScreen")) - #expect(mockAppsFlyerTracker.hasTrackedScreen(name: "HomeScreen")) + #expect(mockConsoleTracker.hasTrackedScreen(name: "HomeScreen")) } @Test("Track screens on specific trackers") func trackScreenOnSpecificTrackers() { let sut = Analytics() - let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) + let mockConsoleTracker = MockAnalyticsTracker(type: .console) let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) - sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.configure(trackers: [mockConsoleTracker]) sut.trackScreen(name: "ProfileScreen", screenClass: nil, on: [.appsFlyer]) - #expect(!mockFirebaseTracker.hasTrackedScreen(name: "ProfileScreen")) - #expect(mockAppsFlyerTracker.hasTrackedScreen(name: "ProfileScreen")) + #expect(!mockConsoleTracker.hasTrackedScreen(name: "ProfileScreen")) } // MARK: - User Properties Tests @@ -141,40 +132,34 @@ struct AnalyticsTests { @Test("Set user properties on all trackers by default") func setUserPropertyOnAllTrackers() { let sut = Analytics() - let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) - let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + let mockConsoleTracker = MockAnalyticsTracker(type: .console) - sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.configure(trackers: [mockConsoleTracker]) sut.setUserProperty(key: "subscription_type", value: "premium") - #expect(mockFirebaseTracker.userProperties["subscription_type"] == "premium") - #expect(mockAppsFlyerTracker.userProperties["subscription_type"] == "premium") + #expect(mockConsoleTracker.userProperties["subscription_type"] == "premium") } @Test("Set user properties on specific trackers") func setUserPropertyOnSpecificTrackers() { let sut = Analytics() - let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) - let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + let mockConsoleTracker = MockAnalyticsTracker(type: .console) - sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) - sut.setUserProperty(key: "age_group", value: "25-34", on: [.firebase]) + sut.configure(trackers: [mockConsoleTracker]) + sut.setUserProperty(key: "age_group", value: "25-34", on: [.console]) - #expect(mockFirebaseTracker.userProperties["age_group"] == "25-34") - #expect(mockAppsFlyerTracker.userProperties["age_group"] == nil) + #expect(mockConsoleTracker.userProperties["age_group"] == "25-34") } @Test("Set user ID on all trackers by default") func setUserIdOnAllTrackers() { let sut = Analytics() - let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) - let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + let mockConsoleTracker = MockAnalyticsTracker(type: .console) - sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.configure(trackers: [mockConsoleTracker]) sut.setUserId("user_12345") - #expect(mockFirebaseTracker.userId == "user_12345") - #expect(mockAppsFlyerTracker.userId == "user_12345") + #expect(mockConsoleTracker.userId == "user_12345") } // MARK: - Tracker Utility Tests @@ -182,16 +167,13 @@ struct AnalyticsTests { @Test("Return specific tracker by type") func getTrackerByType() { let sut = Analytics() - let mockFirebaseTracker = MockAnalyticsTracker(type: .firebase) - let mockAppsFlyerTracker = MockAnalyticsTracker(type: .appsFlyer) + let mockConsoleTracker = MockAnalyticsTracker(type: .console) - sut.configure(trackers: [mockFirebaseTracker, mockAppsFlyerTracker]) + sut.configure(trackers: [mockConsoleTracker]) - let firebaseTracker = sut.tracker(for: .firebase) - let facebookTracker = sut.tracker(for: .facebook) - - #expect(firebaseTracker != nil) - #expect(firebaseTracker?.type == .firebase) - #expect(facebookTracker == nil) + let consoleTracker = sut.tracker(for: .console) + + #expect(consoleTracker != nil) + #expect(consoleTracker?.type == .console) } } diff --git a/template/Tuist/Interfaces/SwiftUI/Sources/Application/App.swift b/template/Tuist/Interfaces/SwiftUI/Sources/Application/App.swift index c35597c7..7e6531ba 100644 --- a/template/Tuist/Interfaces/SwiftUI/Sources/Application/App.swift +++ b/template/Tuist/Interfaces/SwiftUI/Sources/Application/App.swift @@ -1,8 +1,20 @@ +import Analytics import SwiftUI @main struct {PROJECT_NAME}App: App { + init() { + #if DEBUG + Analytics.shared.configure( + trackers: [ConsoleAnalyticsTracker(type: .console)], + additionalParameters: nil + ) + #else + Analytics.shared.configure(trackers: [], additionalParameters: nil) + #endif + } + var body: some Scene { WindowGroup { LandingView()