Skip to content

Commit

Permalink
Merge pull request #15211 from wordpress-mobile/issue/ab_testing
Browse files Browse the repository at this point in the history
Add initial A/B testing support
  • Loading branch information
leandroalonso authored Nov 4, 2020
2 parents 676c6fa + 90bb65d commit 00f6ac4
Show file tree
Hide file tree
Showing 9 changed files with 313 additions and 2 deletions.
19 changes: 19 additions & 0 deletions WordPress/Classes/Utility/ABTesting/ABTesting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

enum Variation: Equatable {
case control
case treatment
case other(String)
case unknown
}

/// A protocol that defines a A/B Testing provider
///
protocol ABTesting {

/// Refresh the assigned experiments
func refresh(completion: (() -> Void)?)

/// Return an experiment variation
func experiment(_ name: String) -> Variation
}
11 changes: 11 additions & 0 deletions WordPress/Classes/Utility/ABTesting/Assignments.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

/// Model that contains experiments variations and TTL
///
struct Assignments: Decodable {
/// Time in seconds until the `variations` should be considered stale.
let ttl: Int

/// Mapping from experiment name to variation name.
let variations: [String: String?]
}
40 changes: 40 additions & 0 deletions WordPress/Classes/Utility/ABTesting/ExPlat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

class ExPlat: ABTesting {
let service: ExPlatService

private let assignmentsKey = "ab-testing-assignments"

init(service: ExPlatService = ExPlatService.withDefaultApi()) {
self.service = service
}

func refresh(completion: (() -> Void)? = nil) {
service.getAssignments { assignments in
guard let assignments = assignments else {
completion?()
return
}

let validVariations = assignments.variations.filter { $0.value != nil }
UserDefaults.standard.setValue(validVariations, forKey: self.assignmentsKey)
completion?()
}
}

func experiment(_ name: String) -> Variation {
guard let assignments = UserDefaults.standard.object(forKey: assignmentsKey) as? [String: String?],
case let variation?? = assignments[name] else {
return .unknown
}

switch variation {
case "control":
return .control
case "treatment":
return .treatment
default:
return .other(variation)
}
}
}
42 changes: 42 additions & 0 deletions WordPress/Classes/Utility/ABTesting/ExPlatService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation

class ExPlatService {
let wordPressComRestApi: WordPressComRestApi

let assignmentsPath = "wpcom/v2/experiments/0.1.0/assignments/calypso"

init(wordPressComRestApi: WordPressComRestApi) {
self.wordPressComRestApi = wordPressComRestApi
}

func getAssignments(completion: @escaping (Assignments?) -> Void) {
wordPressComRestApi.GET(assignmentsPath,
parameters: nil,
success: { responseObject, _ in
do {
let decoder = JSONDecoder()
let data = try JSONSerialization.data(withJSONObject: responseObject, options: [])
let assignments = try decoder.decode(Assignments.self, from: data)
completion(assignments)
} catch {
DDLogError("Error parsing the experiment response: \(error)")
completion(nil)
}
}, failure: { error, _ in
completion(nil)
})
}
}

extension ExPlatService {
class func withDefaultApi() -> ExPlatService {
let accountService = AccountService(managedObjectContext: ContextManager.shared.mainContext)
let defaultAccount = accountService.defaultWordPressComAccount()
let token: String? = defaultAccount?.authToken

let api = WordPressComRestApi.defaultApi(oAuthToken: token,
userAgent: WPUserAgent.wordPress(),
localeKey: WordPressComRestApi.LocaleKeyV2)
return ExPlatService(wordPressComRestApi: api)
}
}
52 changes: 50 additions & 2 deletions WordPress/WordPress.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1191,6 +1191,14 @@
8BDA5A74247C5EAA00AB124C /* ReaderDetailCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDA5A73247C5EAA00AB124C /* ReaderDetailCoordinatorTests.swift */; };
8BDA5A75247C63F300AB124C /* ReaderDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDA5A71247C5E5800AB124C /* ReaderDetailCoordinator.swift */; };
8BDC4C39249BA5CA00DE0A2D /* ReaderCSS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDC4C38249BA5CA00DE0A2D /* ReaderCSS.swift */; };
8BE25EFF2551B78B002E52E4 /* ABTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE25EFE2551B78B002E52E4 /* ABTesting.swift */; };
8BE25F0E2551B885002E52E4 /* ExPlat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE25F0D2551B885002E52E4 /* ExPlat.swift */; };
8BE25F202551B8C9002E52E4 /* ExPlatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE25F1F2551B8C9002E52E4 /* ExPlatTests.swift */; };
8BE25F642551C579002E52E4 /* Assignments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE25F3B2551C497002E52E4 /* Assignments.swift */; };
8BE25F732551C8A0002E52E4 /* explat-assignments.json in Resources */ = {isa = PBXBuildFile; fileRef = 8BE25F722551C8A0002E52E4 /* explat-assignments.json */; };
8BE25F9D2551D5BD002E52E4 /* ExPlatService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE25F812551D52C002E52E4 /* ExPlatService.swift */; };
8BE25FAC2551D6E7002E52E4 /* ExPlatServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE25FAB2551D6E7002E52E4 /* ExPlatServiceTests.swift */; };
8BE25FBB2551F6CC002E52E4 /* explat-malformed-assignments.json in Resources */ = {isa = PBXBuildFile; fileRef = 8BE25FBA2551F6CC002E52E4 /* explat-malformed-assignments.json */; };
8BE69512243E674300FF492F /* PrepublishingHeaderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE69511243E674300FF492F /* PrepublishingHeaderViewTests.swift */; };
8BE7C84123466927006EDE70 /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE7C84023466927006EDE70 /* I18n.swift */; };
8BF0B607247D88EB009A7457 /* UITableViewCell+enableDisable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF0B606247D88EB009A7457 /* UITableViewCell+enableDisable.swift */; };
Expand Down Expand Up @@ -3735,6 +3743,14 @@
8BDA5A71247C5E5800AB124C /* ReaderDetailCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDetailCoordinator.swift; sourceTree = "<group>"; };
8BDA5A73247C5EAA00AB124C /* ReaderDetailCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDetailCoordinatorTests.swift; sourceTree = "<group>"; };
8BDC4C38249BA5CA00DE0A2D /* ReaderCSS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCSS.swift; sourceTree = "<group>"; };
8BE25EFE2551B78B002E52E4 /* ABTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ABTesting.swift; sourceTree = "<group>"; };
8BE25F0D2551B885002E52E4 /* ExPlat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExPlat.swift; sourceTree = "<group>"; };
8BE25F1F2551B8C9002E52E4 /* ExPlatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExPlatTests.swift; sourceTree = "<group>"; };
8BE25F3B2551C497002E52E4 /* Assignments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assignments.swift; sourceTree = "<group>"; };
8BE25F722551C8A0002E52E4 /* explat-assignments.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "explat-assignments.json"; sourceTree = "<group>"; };
8BE25F812551D52C002E52E4 /* ExPlatService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExPlatService.swift; sourceTree = "<group>"; };
8BE25FAB2551D6E7002E52E4 /* ExPlatServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExPlatServiceTests.swift; sourceTree = "<group>"; };
8BE25FBA2551F6CC002E52E4 /* explat-malformed-assignments.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "explat-malformed-assignments.json"; sourceTree = "<group>"; };
8BE69511243E674300FF492F /* PrepublishingHeaderViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PrepublishingHeaderViewTests.swift; path = WordPressTest/PrepublishingHeaderViewTests.swift; sourceTree = SOURCE_ROOT; };
8BE7C84023466927006EDE70 /* I18n.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = I18n.swift; sourceTree = "<group>"; };
8BF0B606247D88EB009A7457 /* UITableViewCell+enableDisable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableViewCell+enableDisable.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -5694,7 +5710,7 @@
path = GutenbergWeb;
sourceTree = "<group>";
};
29B97314FDCFA39411CA2CEA = {
29B97314FDCFA39411CA2CEA /* CustomTemplate */ = {
isa = PBXGroup;
children = (
98D31BBF239720E4009CFF43 /* MainInterface.storyboard */,
Expand Down Expand Up @@ -7958,6 +7974,7 @@
7E21C763202BBE9F00837CF5 /* iAds */,
B5C9401B1DB901120079D4FF /* Account */,
85A1B6721742E7DB00BA5E35 /* Analytics */,
8BE25EFD2551B777002E52E4 /* ABTesting */,
F1D690131F828FF000200E30 /* BuildInformation */,
B5ECA6CB1DBAA0110062D7E0 /* CoreData */,
B5DB8AF51C949DC70059196A /* ImmuTable */,
Expand Down Expand Up @@ -8175,6 +8192,26 @@
path = Views;
sourceTree = "<group>";
};
8BE25EFD2551B777002E52E4 /* ABTesting */ = {
isa = PBXGroup;
children = (
8BE25F3B2551C497002E52E4 /* Assignments.swift */,
8BE25EFE2551B78B002E52E4 /* ABTesting.swift */,
8BE25F0D2551B885002E52E4 /* ExPlat.swift */,
8BE25F812551D52C002E52E4 /* ExPlatService.swift */,
);
path = ABTesting;
sourceTree = "<group>";
};
8BE25F1E2551B8B2002E52E4 /* ABTesting */ = {
isa = PBXGroup;
children = (
8BE25F1F2551B8C9002E52E4 /* ExPlatTests.swift */,
8BE25FAB2551D6E7002E52E4 /* ExPlatServiceTests.swift */,
);
path = ABTesting;
sourceTree = "<group>";
};
8BE69514243E676C00FF492F /* Prepublishing Nudges */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -10181,6 +10218,7 @@
isa = PBXGroup;
children = (
32110548250BFC5A0048446F /* Image Dimension Parser */,
8BE25F1E2551B8B2002E52E4 /* ABTesting */,
7EC9FE0822C6275900C5A888 /* Analytics */,
572FB3FE223A800500933C76 /* Classes */,
FF9A6E6F21F9359200D36D14 /* Gutenberg */,
Expand Down Expand Up @@ -10410,6 +10448,8 @@
D88A64A5208D92B1008AE9BC /* stock-photos-media.json */,
D88A64A9208D974D008AE9BC /* thumbnail-collection.json */,
D88A64AD208D9CF5008AE9BC /* stock-photos-pageable.json */,
8BE25F722551C8A0002E52E4 /* explat-assignments.json */,
8BE25FBA2551F6CC002E52E4 /* explat-malformed-assignments.json */,
);
name = "Mock Data";
path = "Test Data";
Expand Down Expand Up @@ -11326,7 +11366,7 @@
bg,
sk,
);
mainGroup = 29B97314FDCFA39411CA2CEA;
mainGroup = 29B97314FDCFA39411CA2CEA /* CustomTemplate */;
productRefGroup = 19C28FACFE9D520D11CA2CBB /* Products */;
projectDirPath = "";
projectRoot = "";
Expand Down Expand Up @@ -11720,6 +11760,7 @@
D848CC0520FF062100A9038F /* notifications-user-content-meta.json in Resources */,
8BB185CF24B62D7600A4CCE8 /* reader-cards-success.json in Resources */,
7E4A772320F7BE94001C706D /* activity-log-comment-content.json in Resources */,
8BE25F732551C8A0002E52E4 /* explat-assignments.json in Resources */,
E12BE5EE1C5235DB000FD5CA /* get-me-settings-v1.1.json in Resources */,
933D1F6C1EA7A3AB009FB462 /* TestingMode.storyboard in Resources */,
08F8CD371EBD2AA80049D0C0 /* test-image-device-photo-gps.jpg in Resources */,
Expand All @@ -11733,6 +11774,7 @@
3211056B250C0F750048446F /* valid-png-header in Resources */,
748BD8851F19234300813F9A /* notifications-mark-as-read.json in Resources */,
7E442FCA20F678D100DEACA5 /* activity-log-pingback-content.json in Resources */,
8BE25FBB2551F6CC002E52E4 /* explat-malformed-assignments.json in Resources */,
F127FFD824213B5600B9D41A /* atomic-get-authentication-cookie-success.json in Resources */,
D848CC0120FF030C00A9038F /* notifications-comment-meta.json in Resources */,
93594BD5191D2F5A0079E6B2 /* stats-batch.json in Resources */,
Expand Down Expand Up @@ -12580,6 +12622,7 @@
46183D1F251BD6A0004F9AFD /* PageTemplateCategory+CoreDataClass.swift in Sources */,
40E7FED02211FFBC0032834E /* AllTimeStatsRecordValue+CoreDataProperties.swift in Sources */,
4054F45F2214F50300D261AB /* StreakInsightStatsRecordValue+CoreDataClass.swift in Sources */,
8BE25EFF2551B78B002E52E4 /* ABTesting.swift in Sources */,
174C9697205A846E00CEEF6E /* PostNoticeViewModel.swift in Sources */,
9A2D0B25225CB980009E585F /* JetpackInstallStore.swift in Sources */,
738B9A5421B85CF20005062B /* WizardNavigation.swift in Sources */,
Expand Down Expand Up @@ -12954,6 +12997,7 @@
08CC67801C49B65A00153AD7 /* MenuLocation.m in Sources */,
4349B0AF218A477F0034118A /* RevisionsTableViewCell.swift in Sources */,
738B9A5921B85CF20005062B /* KeyboardInfo.swift in Sources */,
8BE25F642551C579002E52E4 /* Assignments.swift in Sources */,
E15644F31CE0E5A500D96E64 /* PlanDetailViewModel.swift in Sources */,
17E4CD0C238C33F300C56916 /* DebugMenuViewController.swift in Sources */,
08D978571CD2AF7D0054F19A /* MenuItemCheckButtonView.m in Sources */,
Expand Down Expand Up @@ -13053,6 +13097,7 @@
98A437AE20069098004A8A57 /* DomainSuggestionsTableViewController.swift in Sources */,
3F3087C424EDB7040087B548 /* AnnouncementCell.swift in Sources */,
5D1D04761B7A50B100CDE646 /* ReaderStreamViewController.swift in Sources */,
8BE25F0E2551B885002E52E4 /* ExPlat.swift in Sources */,
B57B92BD1B73B08100DFF00B /* SeparatorsView.swift in Sources */,
E69BA1981BB5D7D300078740 /* WPStyleGuide+ReadableMargins.m in Sources */,
7E4123C320F4097B00DF8486 /* FormattableContentStyles.swift in Sources */,
Expand Down Expand Up @@ -13367,6 +13412,7 @@
57BAD50C225CCE1A006139EC /* WPTabBarController+Swift.swift in Sources */,
E1468DE71E794A4D0044D80F /* LanguageSelectorViewController.swift in Sources */,
E14694071F3459E2004052C8 /* PluginListViewController.swift in Sources */,
8BE25F9D2551D5BD002E52E4 /* ExPlatService.swift in Sources */,
FF0D8146205809C8000EE505 /* PostCoordinator.swift in Sources */,
7EA30DB621ADA20F0092F894 /* AztecAttachmentDelegate.swift in Sources */,
82B67B361FC726CD006FB593 /* Memoize.swift in Sources */,
Expand Down Expand Up @@ -14219,6 +14265,7 @@
40EE948222132F5800CD264F /* PublicizeConectionStatsRecordValueTests.swift in Sources */,
577C2AAB22936DCB00AD1F03 /* PostCardCellGhostableTests.swift in Sources */,
02761EC4227010BC009BAF0F /* BlogDetailsSectionIndexTests.swift in Sources */,
8BE25FAC2551D6E7002E52E4 /* ExPlatServiceTests.swift in Sources */,
732A473D218787500015DA74 /* WPRichTextFormatterTests.swift in Sources */,
1ABA150822AE5F870039311A /* WordPressUIBundleTests.swift in Sources */,
57569CF2230485680052EE14 /* PostAutoUploadInteractorTests.swift in Sources */,
Expand Down Expand Up @@ -14327,6 +14374,7 @@
E1B921BC1C0ED5A3003EA3CB /* MediaSizeSliderCellTest.swift in Sources */,
40F50B80221310D400CBBB73 /* FollowersStatsRecordValueTests.swift in Sources */,
3F50945F245537A700C4470B /* ReaderTabViewModelTests.swift in Sources */,
8BE25F202551B8C9002E52E4 /* ExPlatTests.swift in Sources */,
0885A3671E837AFE00619B4D /* URLIncrementalFilenameTests.swift in Sources */,
D848CBF920FEF82100A9038F /* NotificationsContentFactoryTests.swift in Sources */,
575802132357C41200E4C63C /* MediaCoordinatorTests.swift in Sources */,
Expand Down
76 changes: 76 additions & 0 deletions WordPress/WordPressTest/ABTesting/ExPlatServiceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import XCTest
import OHHTTPStubs

@testable import WordPress

class ExPlatServiceTests: XCTestCase {
override func tearDown() {
super.tearDown()

OHHTTPStubs.removeAllStubs()
}

// Return TTL and variations
//
func testRefresh() {
let expectation = XCTestExpectation(description: "Return assignments")
stubAssignmentsResponseWithFile("explat-assignments.json")
let service = ExPlatService.withDefaultApi()

service.getAssignments { assignments in
XCTAssertEqual(assignments?.ttl, 60)
XCTAssertEqual(assignments?.variations, ["experiment": "control"])
expectation.fulfill()
}

wait(for: [expectation], timeout: 2.0)
}

// Do not return assignments when the decoding fails
//
func testRefreshDecodeFails() {
let expectation = XCTestExpectation(description: "Do not return assignments")
stubAssignmentsResponseWithFile("explat-malformed-assignments.json")
let service = ExPlatService.withDefaultApi()

service.getAssignments { assignments in
XCTAssertNil(assignments)
expectation.fulfill()
}

wait(for: [expectation], timeout: 2.0)
}

// Do not return assignments when the server returns an error
//
func testRefreshServerFails() {
let expectation = XCTestExpectation(description: "Do not return assignments")
stubAssignmentsResponseWithError()
let service = ExPlatService.withDefaultApi()

service.getAssignments { assignments in
XCTAssertNil(assignments)
expectation.fulfill()
}

wait(for: [expectation], timeout: 2.0)
}

private func stubAssignmentsResponseWithFile(_ filename: String) {
stubAssignments(withFile: filename)
}

private func stubAssignmentsResponseWithError() {
stubAssignments(withStatus: 503)
}

private func stubAssignments(withFile file: String = "explat-assignments.json", withStatus status: Int32? = nil) {
let endpoint = "wpcom/v2/experiments/0.1.0/assignments/calypso"
stub(condition: { request in
return (request.url!.absoluteString as NSString).contains(endpoint) && request.httpMethod! == "GET"
}) { _ in
let stubPath = OHPathForFile(file, type(of: self))
return fixture(filePath: stubPath!, status: status ?? 200, headers: ["Content-Type" as NSObject: "application/json" as AnyObject])
}
}
}
Loading

0 comments on commit 00f6ac4

Please sign in to comment.