-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #15211 from wordpress-mobile/issue/ab_testing
Add initial A/B testing support
- Loading branch information
Showing
9 changed files
with
313 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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?] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
76 changes: 76 additions & 0 deletions
76
WordPress/WordPressTest/ABTesting/ExPlatServiceTests.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]) | ||
} | ||
} | ||
} |
Oops, something went wrong.