Skip to content

Commit a20f9b7

Browse files
Merge pull request #8806 from woocommerce/bugfix/8782-cache-logged-out-ab-tests
[ABTesting] Cache variations of logged out context experiments
2 parents fc7dc93 + d6df1c6 commit a20f9b7

File tree

16 files changed

+312
-30
lines changed

16 files changed

+312
-30
lines changed

Experiments/Experiments.xcodeproj/project.pbxproj

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
BC10218D75FEA979BDA1E68C /* Pods_Experiments.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33CEC0C5283FD4C9EF8C6A3C /* Pods_Experiments.framework */; };
1717
C8E16F0EE6954B58A1C402F0 /* Pods_ExperimentsTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AAC7C082DD376957B4676401 /* Pods_ExperimentsTests.framework */; };
1818
CC53FB48275E426900C4CA4F /* ABTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC53FB47275E426900C4CA4F /* ABTest.swift */; };
19+
EE2EDFDF29879331004E702B /* ABTestVariationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2EDFDE29879331004E702B /* ABTestVariationProvider.swift */; };
20+
EEC8C0ED298A92F10047B4CB /* CachedABTestVariationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC8C0EC298A92F10047B4CB /* CachedABTestVariationProvider.swift */; };
21+
EEC8C0EF298A939C0047B4CB /* VariationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC8C0EE298A939C0047B4CB /* VariationCache.swift */; };
22+
EEC8C0F1298B5AFB0047B4CB /* VariationCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC8C0F0298B5AFB0047B4CB /* VariationCacheTests.swift */; };
23+
EEC8C0F3298B5F950047B4CB /* CachedABTestVariationProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC8C0F2298B5F950047B4CB /* CachedABTestVariationProviderTests.swift */; };
1924
/* End PBXBuildFile section */
2025

2126
/* Begin PBXContainerItemProxy section */
@@ -47,6 +52,11 @@
4752
AAC7C082DD376957B4676401 /* Pods_ExperimentsTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ExperimentsTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
4853
AF72D9DB7771E7A5105C88B0 /* Pods-Experiments.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Experiments.release.xcconfig"; path = "Target Support Files/Pods-Experiments/Pods-Experiments.release.xcconfig"; sourceTree = "<group>"; };
4954
CC53FB47275E426900C4CA4F /* ABTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ABTest.swift; sourceTree = "<group>"; };
55+
EE2EDFDE29879331004E702B /* ABTestVariationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ABTestVariationProvider.swift; sourceTree = "<group>"; };
56+
EEC8C0EC298A92F10047B4CB /* CachedABTestVariationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedABTestVariationProvider.swift; sourceTree = "<group>"; };
57+
EEC8C0EE298A939C0047B4CB /* VariationCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariationCache.swift; sourceTree = "<group>"; };
58+
EEC8C0F0298B5AFB0047B4CB /* VariationCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariationCacheTests.swift; sourceTree = "<group>"; };
59+
EEC8C0F2298B5F950047B4CB /* CachedABTestVariationProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedABTestVariationProviderTests.swift; sourceTree = "<group>"; };
5060
/* End PBXFileReference section */
5161

5262
/* Begin PBXFrameworksBuildPhase section */
@@ -100,6 +110,9 @@
100110
0270C0A427069B8900FC799F /* DefaultFeatureFlagService.swift */,
101111
0270C0A627069BA500FC799F /* BuildConfiguration.swift */,
102112
CC53FB47275E426900C4CA4F /* ABTest.swift */,
113+
EE2EDFDE29879331004E702B /* ABTestVariationProvider.swift */,
114+
EEC8C0EC298A92F10047B4CB /* CachedABTestVariationProvider.swift */,
115+
EEC8C0EE298A939C0047B4CB /* VariationCache.swift */,
103116
);
104117
path = Experiments;
105118
sourceTree = "<group>";
@@ -108,6 +121,8 @@
108121
isa = PBXGroup;
109122
children = (
110123
0270C09127069A8900FC799F /* Info.plist */,
124+
EEC8C0F0298B5AFB0047B4CB /* VariationCacheTests.swift */,
125+
EEC8C0F2298B5F950047B4CB /* CachedABTestVariationProviderTests.swift */,
111126
);
112127
path = ExperimentsTests;
113128
sourceTree = "<group>";
@@ -203,6 +218,7 @@
203218
};
204219
0270C08927069A8900FC799F = {
205220
CreatedOnToolsVersion = 12.5.1;
221+
LastSwiftMigration = 1420;
206222
};
207223
};
208224
};
@@ -311,10 +327,13 @@
311327
isa = PBXSourcesBuildPhase;
312328
buildActionMask = 2147483647;
313329
files = (
330+
EEC8C0EF298A939C0047B4CB /* VariationCache.swift in Sources */,
314331
0270C0A327069B7800FC799F /* FeatureFlagService.swift in Sources */,
315332
0270C0A527069B8900FC799F /* DefaultFeatureFlagService.swift in Sources */,
316333
0270C09C27069AE700FC799F /* FeatureFlag.swift in Sources */,
334+
EE2EDFDF29879331004E702B /* ABTestVariationProvider.swift in Sources */,
317335
0270C0A727069BA500FC799F /* BuildConfiguration.swift in Sources */,
336+
EEC8C0ED298A92F10047B4CB /* CachedABTestVariationProvider.swift in Sources */,
318337
CC53FB48275E426900C4CA4F /* ABTest.swift in Sources */,
319338
);
320339
runOnlyForDeploymentPostprocessing = 0;
@@ -323,6 +342,8 @@
323342
isa = PBXSourcesBuildPhase;
324343
buildActionMask = 2147483647;
325344
files = (
345+
EEC8C0F1298B5AFB0047B4CB /* VariationCacheTests.swift in Sources */,
346+
EEC8C0F3298B5F950047B4CB /* CachedABTestVariationProviderTests.swift in Sources */,
326347
);
327348
runOnlyForDeploymentPostprocessing = 0;
328349
};
@@ -517,6 +538,7 @@
517538
baseConfigurationReference = 3022E2766134CE2735C73FC6 /* Pods-ExperimentsTests.debug.xcconfig */;
518539
buildSettings = {
519540
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "${inherited}";
541+
CLANG_ENABLE_MODULES = YES;
520542
CODE_SIGN_STYLE = Automatic;
521543
INFOPLIST_FILE = ExperimentsTests/Info.plist;
522544
LD_RUNPATH_SEARCH_PATHS = (
@@ -526,6 +548,7 @@
526548
);
527549
PRODUCT_BUNDLE_IDENTIFIER = com.automattic.ExperimentsTests;
528550
PRODUCT_NAME = "$(TARGET_NAME)";
551+
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
529552
SWIFT_VERSION = 5.0;
530553
TARGETED_DEVICE_FAMILY = "1,2";
531554
};
@@ -536,6 +559,7 @@
536559
baseConfigurationReference = 7C831644164B49828A485590 /* Pods-ExperimentsTests.release.xcconfig */;
537560
buildSettings = {
538561
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "${inherited}";
562+
CLANG_ENABLE_MODULES = YES;
539563
CODE_SIGN_STYLE = Automatic;
540564
INFOPLIST_FILE = ExperimentsTests/Info.plist;
541565
LD_RUNPATH_SEARCH_PATHS = (
@@ -640,6 +664,7 @@
640664
baseConfigurationReference = 3F9DB5FBFF7A42EFBCB746F3 /* Pods-ExperimentsTests.release-alpha.xcconfig */;
641665
buildSettings = {
642666
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "${inherited}";
667+
CLANG_ENABLE_MODULES = YES;
643668
CODE_SIGN_STYLE = Automatic;
644669
INFOPLIST_FILE = ExperimentsTests/Info.plist;
645670
LD_RUNPATH_SEARCH_PATHS = (

Experiments/Experiments/ABTest.swift

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ import AutomatticTracks
22

33
/// ABTest adds A/B testing experiments and runs the tests based on their variations from the ExPlat service.
44
///
5-
public enum ABTest: String, CaseIterable {
6-
/// Throwaway case, to prevent a compiler error:
7-
/// `An enum with no cases cannot declare a raw type`
8-
case null
5+
public enum ABTest: String, Codable, CaseIterable {
6+
/// Mocks for unit testing
7+
case mockLoggedIn, mockLoggedOut
98

109
/// A/A test to make sure there is no bias in the logged out state.
1110
/// Experiment ref: pbxNRc-1QS-p2
@@ -20,8 +19,9 @@ public enum ABTest: String, CaseIterable {
2019
case applicationPasswordAuthentication = "woocommerceios_login_rest_api_project_202301_v2"
2120

2221
/// Returns a variation for the given experiment
23-
public var variation: Variation {
24-
ExPlat.shared?.experiment(rawValue) ?? .control
22+
///
23+
public var variation: Variation? {
24+
ExPlat.shared?.experiment(rawValue)
2525
}
2626

2727
/// Returns the context for the given experiment.
@@ -33,18 +33,27 @@ public enum ABTest: String, CaseIterable {
3333
return .loggedIn
3434
case .aaTestLoggedOut, .applicationPasswordAuthentication:
3535
return .loggedOut
36-
case .null:
37-
return .none
36+
// Mocks
37+
case .mockLoggedIn:
38+
return .loggedIn
39+
case .mockLoggedOut:
40+
return .loggedOut
3841
}
3942
}
43+
44+
// Returns only the genuine ABTest cases. (After removing unit test mocks)
45+
//
46+
static var genuineCases: [ABTest] {
47+
ABTest.allCases.filter { [.mockLoggedIn, .mockLoggedOut].contains($0) == false }
48+
}
4049
}
4150

4251
public extension ABTest {
4352
/// Start the AB Testing platform if any experiment exists for the provided context
4453
///
4554
@MainActor
4655
static func start(for context: ExperimentContext) async {
47-
let experiments = ABTest.allCases.filter { $0.context == context }
56+
let experiments = ABTest.genuineCases.filter { $0.context == context }
4857

4958
await withCheckedContinuation { continuation in
5059
guard !experiments.isEmpty else {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import AutomatticTracks
2+
3+
/// For getting the variation of a `ABTest`
4+
public protocol ABTestVariationProvider {
5+
/// Returns the `Variation` for the provided `ABTest`
6+
func variation(for abTest: ABTest) -> Variation
7+
}
8+
9+
/// Default implementation of `ABTestVariationProvider`
10+
public struct DefaultABTestVariationProvider: ABTestVariationProvider {
11+
public init() { }
12+
13+
public func variation(for abTest: ABTest) -> Variation {
14+
abTest.variation ?? .control
15+
}
16+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import AutomatticTracks
2+
3+
/// Cache based implementation of `ABTestVariationProvider`
4+
///
5+
public struct CachedABTestVariationProvider: ABTestVariationProvider {
6+
7+
private let cache: VariationCache
8+
9+
public init(cache: VariationCache = VariationCache(userDefaults: .standard)) {
10+
self.cache = cache
11+
}
12+
13+
public func variation(for abTest: ABTest) -> Variation {
14+
// We cache only logged out ABTests as they are assigned based on `anonId` by `ExPlat`.
15+
// There will be one value assigned to one device and it won't change.
16+
//
17+
guard abTest.context == .loggedOut else {
18+
return abTest.variation ?? .control
19+
}
20+
21+
if let variation = abTest.variation {
22+
try? cache.assign(variation: variation, for: abTest)
23+
return variation
24+
} else if let cachedVariation = cache.variation(for: abTest) {
25+
return cachedVariation
26+
} else {
27+
return .control
28+
}
29+
}
30+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import AutomatticTracks
2+
3+
enum VariationCacheError: Error {
4+
case onlyLoggedOutExperimentsShouldBeCached
5+
}
6+
7+
public struct VariationCache {
8+
private let variationKey = "VariationCacheKey"
9+
10+
private let userDefaults: UserDefaults
11+
12+
public init(userDefaults: UserDefaults) {
13+
self.userDefaults = userDefaults
14+
}
15+
16+
func variation(for abTest: ABTest) -> Variation? {
17+
guard abTest.context == .loggedOut else {
18+
return nil
19+
}
20+
21+
guard let data = userDefaults.object(forKey: variationKey) as? Data,
22+
let cachedVariations = try? JSONDecoder().decode([CachedVariation].self, from: data),
23+
case let variation = cachedVariations.first(where: { $0.abTest == abTest })?.variation
24+
else {
25+
return nil
26+
}
27+
28+
return variation
29+
}
30+
31+
func assign(variation: Variation, for abTest: ABTest) throws {
32+
guard abTest.context == .loggedOut else {
33+
throw VariationCacheError.onlyLoggedOutExperimentsShouldBeCached
34+
}
35+
36+
var variations = userDefaults.object(forKey: variationKey) as? [CachedVariation] ?? []
37+
variations.append(CachedVariation(abTest: abTest, variation: variation))
38+
39+
let encodedVariation = try JSONEncoder().encode(variations)
40+
userDefaults.set(encodedVariation, forKey: variationKey)
41+
}
42+
}
43+
44+
public struct CachedVariation: Codable {
45+
let abTest: ABTest
46+
let variation: Variation
47+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import XCTest
2+
@testable import Experiments
3+
4+
final class CachedABTestVariationProviderTests: XCTestCase {
5+
func test_variation_is_control_when_the_value_does_not_exist() throws {
6+
// Given
7+
let userDefaults = try XCTUnwrap(UserDefaults(suiteName: UUID().uuidString))
8+
9+
// When
10+
let cache = VariationCache(userDefaults: userDefaults)
11+
let provider = CachedABTestVariationProvider(cache: cache)
12+
13+
// Then
14+
XCTAssertEqual(provider.variation(for: .mockLoggedOut), .control)
15+
}
16+
17+
func test_correct_variation_is_returned_after_caching_it() throws {
18+
// Given
19+
let userDefaults = try XCTUnwrap(UserDefaults(suiteName: UUID().uuidString))
20+
let cache = VariationCache(userDefaults: userDefaults)
21+
let provider = CachedABTestVariationProvider(cache: cache)
22+
23+
// When
24+
try cache.assign(variation: .treatment, for: .mockLoggedOut)
25+
26+
// Then
27+
XCTAssertEqual(provider.variation(for: .mockLoggedOut), .treatment)
28+
}
29+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import XCTest
2+
@testable import Experiments
3+
4+
final class VariationCacheTests: XCTestCase {
5+
func test_variation_is_nil_when_the_value_does_not_exist() throws {
6+
// Given
7+
let userDefaults = try XCTUnwrap(UserDefaults(suiteName: UUID().uuidString))
8+
9+
// When
10+
let cache = VariationCache(userDefaults: userDefaults)
11+
12+
// Then
13+
XCTAssertNil(cache.variation(for: .mockLoggedOut))
14+
}
15+
16+
func test_correct_variation_is_returned_after_setting_it() throws {
17+
// Given
18+
let userDefaults = try XCTUnwrap(UserDefaults(suiteName: UUID().uuidString))
19+
let cache = VariationCache(userDefaults: userDefaults)
20+
21+
// When
22+
try cache.assign(variation: .treatment, for: .mockLoggedOut)
23+
24+
// Then
25+
XCTAssertEqual(cache.variation(for: .mockLoggedOut), .treatment)
26+
}
27+
28+
func test_it_throws_when_trying_to_cache_logged_in_experiment() throws {
29+
// Given
30+
let userDefaults = try XCTUnwrap(UserDefaults(suiteName: UUID().uuidString))
31+
let cache = VariationCache(userDefaults: userDefaults)
32+
33+
// When
34+
XCTAssertThrowsError(try cache.assign(variation: .treatment, for: .mockLoggedIn))
35+
}
36+
}

Podfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def aztec
2727
end
2828

2929
def tracks
30-
pod 'Automattic-Tracks-iOS', '~> 1.0.0'
30+
pod 'Automattic-Tracks-iOS', '~> 2.0.0-beta.1'
3131
# pod 'Automattic-Tracks-iOS', :git => 'https://github.com/Automattic/Automattic-Tracks-iOS.git', :branch => 'trunk'
3232
# pod 'Automattic-Tracks-iOS', :git => 'https://github.com/Automattic/Automattic-Tracks-iOS.git', :commit => ''
3333
# pod 'Automattic-Tracks-iOS', :path => '../Automattic-Tracks-iOS'

Podfile.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ PODS:
66
- AppAuth/Core (1.6.0)
77
- AppAuth/ExternalUserAgent (1.6.0):
88
- AppAuth/Core
9-
- Automattic-Tracks-iOS (1.0.0):
9+
- Automattic-Tracks-iOS (2.0.0-beta.1):
1010
- Sentry (~> 7.25)
1111
- Sodium (>= 0.9.1)
1212
- UIDeviceIdentifier (~> 2.0)
@@ -75,7 +75,7 @@ PODS:
7575

7676
DEPENDENCIES:
7777
- Alamofire (~> 4.8)
78-
- Automattic-Tracks-iOS (~> 1.0.0)
78+
- Automattic-Tracks-iOS (~> 2.0.0-beta.1)
7979
- CocoaLumberjack (~> 3.7.4)
8080
- CocoaLumberjack/Swift (~> 3.7.4)
8181
- Gridicons (~> 1.2.0)
@@ -135,7 +135,7 @@ SPEC REPOS:
135135
SPEC CHECKSUMS:
136136
Alamofire: 3ec537f71edc9804815215393ae2b1a8ea33a844
137137
AppAuth: 8fca6b5563a5baef2c04bee27538025e4ceb2add
138-
Automattic-Tracks-iOS: 93df154824af31eba947718110023acce1ce7905
138+
Automattic-Tracks-iOS: 20220d63a075787a890513ae56214a17a160ec43
139139
CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646
140140
GoogleSignIn: fd381840dbe7c1137aa6dc30849a5c3e070c034a
141141
Gridicons: 4455b9f366960121430e45997e32112ae49ffe1d
@@ -169,6 +169,6 @@ SPEC CHECKSUMS:
169169
ZendeskSupportProvidersSDK: 2bdf8544f7cd0fd4c002546f5704b813845beb2a
170170
ZendeskSupportSDK: 3a8e508ab1d9dd22dc038df6c694466414e037ba
171171

172-
PODFILE CHECKSUM: 5611275f2e251a88706eac841eb27ae63383a7ac
172+
PODFILE CHECKSUM: 60adb2c26ae523f8b998d67c2c81cddce40b9809
173173

174174
COCOAPODS: 1.11.3

0 commit comments

Comments
 (0)