Experiments is a framework designed to facilitate the setup of A/B testing infrastructure. It offers methods for defining remote keys and values, along with services to interact with them.
Currently, the framework supports a Firebase Remote Configs service, but you can define your own by conforming to the necessary protocols.
Key is something that conforms to protocol RemoteKey
:
public protocol RemoteKey {
var name: String { get }
var valueType: RemoteValue.Type { get }
}
The recommended approach is to use enum for this:
enum Key: String, CaseIterable, RemoteKey {
case paywallType = "paywall_type"
case aFeatureEnabled = "a_feature_enabled"
var name: String {
rawValue
}
var valueType: RemoteValue.Type {
switch self {
case .paywallType:
PaywallType.self
case .aFeatureEnabled:
AFeatureEnabled.self
}
}
}
Here RemoteValue is a protocol with only one requirement:
public protocol RemoteValue {
static var `default`: Self { get }
}
default value is the one that will be used by the app, if it failed to retrieve remote values. We can call it in-app default / baseline value
Currenly only flows with values that are created from Bool or String are supported. Craetion from Int / JSON will be possibly added later.
There are 4 use-cases for remote values:
- String Remote Value. Use when you need a multivariant remote value
- Bool Remote Value. Use when you need enabled / disabled remote value
- (Advacned) Baseline String Remote Value. Use when you need a multivariant remote value and overriding of the in-app default / baseline.
- (Advanced) Baseline Bool Remote Value. Use when you need enabled / disabled remote value and overriding of the in-app default / baseline.
Experiments
framework provides macros to help you define your remote values, which are heighly recommended, but not obligatory. Below, both variants will be shown.
Non macros way | Macros way | |
---|---|---|
String Remote Value |
enum PaywallType: String, CaseIterable, StringInitializableRemoteValue {
case a, b, c
} Override `default`enum PaywallType: String, CaseIterable, StringInitializableRemoteValue {
case a, b, c
static let `default`: Self = .b
} |
@StringRemoteValue
enum PaywallType: String {
case a, b, c
} Override `default`@StringRemoteValue
enum PaywallType: String {
case a, b, c
static let `default`: Self = .b
} |
Bool Remote Value |
enum AFeatureEnabled: CaseIterable, BoolEnumRemoteValue {
case enabled
case disabled
} Override `default`enum AFeatureEnabled: CaseIterable, BoolEnumRemoteValue {
case enabled
case disabled
static let `default`: Self = .enabled
} |
@BoolRemoteValue
enum AFeatureEnabled { } Override `default`@BoolRemoteValue
enum AFeatureEnabled {
static let `default`: Self = .enabled
}
or
@BoolRemoteValue(enabledByDefault: true)
enum AFeatureEnabled { } |
Baseline String Remote Value |
struct StringBaselineTest: CaseIterable, BaselineStringRemoteValue {
enum Variant: String, CaseIterable, StringInitializableRemoteValue {
case a, b, c
}
let baseline: Bool
let variant: Variant
init(baseline: Bool, variant: Variant) {
self.baseline = baseline
self.variant = variant
}
init?(name: String) {
let baseline = name.hasSuffix("_baseline")
let name = baseline ? String(name.dropLast("_baseline".count)) : name
guard let variant = Variant(name: name) else {
return nil
}
self.baseline = baseline
self.variant = variant
}
var name: String {
variant.name
}
static var allCases: [StringBaselineTest] {
Variant.allCases.flatMap {
[
StringBaselineTest(baseline: true, variant: $0),
StringBaselineTest(baseline: false, variant: $0)
]
}
}
} Override `default`struct StringBaselineTest: CaseIterable, BaselineStringRemoteValue {
enum Variant: String, CaseIterable, StringInitializableRemoteValue {
case a, b, c
}
let baseline: Bool
let variant: Variant
init(baseline: Bool, variant: Variant) {
self.baseline = baseline
self.variant = variant
}
init?(name: String) {
let baseline = name.hasSuffix("_baseline")
let name = baseline ? String(name.dropLast("_baseline".count)) : name
guard let variant = Variant(name: name) else {
return nil
}
self.baseline = baseline
self.variant = variant
}
var name: String {
variant.name
}
static var allCases: [StringBaselineTest] {
Variant.allCases.flatMap {
[
StringBaselineTest(baseline: true, variant: $0),
StringBaselineTest(baseline: false, variant: $0)
]
}
}
static let `default`: Self = StringBaselineTest(baseline: true, variant: .b)
} |
@BaselineStringRemoteValue
struct StringBaselineTestMacro {
enum Variant: String {
case a, b, c
}
} Override `default`@BaselineStringRemoteValue
struct StringBaselineTestMacro {
enum Variant: String {
case a, b, c
static let `default`: Self = .b
}
} |
Baseline Bool Remote Value |
struct BoolBaselineTest: CaseIterable, BaselineBoolRemoteValue {
let baseline: Bool
let isEnabled: Bool
init(baseline: Bool, isEnabled: Bool) {
self.baseline = baseline
self.isEnabled = isEnabled
}
init?(name: String) {
let baseline = name.hasSuffix("_baseline")
let name = baseline ? String(name.dropLast("_baseline".count)) : name
let isEnabled: Bool? = if name == "true" {
true
} else if name == "false" {
false
} else {
nil
}
guard let isEnabled else { return nil }
self.baseline = baseline
self.isEnabled = isEnabled
}
var name: String {
isEnabled ? "Enabled" : "Disabled"
}
static var allCases: [BoolBaselineTest] {
[
BoolBaselineTest(baseline: true, isEnabled: true),
BoolBaselineTest(baseline: false, isEnabled: true),
BoolBaselineTest(baseline: false, isEnabled: true),
BoolBaselineTest(baseline: false, isEnabled: false)
]
}
} Override `default`struct BoolBaselineTest: CaseIterable, BaselineBoolRemoteValue {
let baseline: Bool
let isEnabled: Bool
init(baseline: Bool, isEnabled: Bool) {
self.baseline = baseline
self.isEnabled = isEnabled
}
init?(name: String) {
let baseline = name.hasSuffix("_baseline")
let name = baseline ? String(name.dropLast("_baseline".count)) : name
let isEnabled: Bool? = if name == "true" {
true
} else if name == "false" {
false
} else {
nil
}
guard let isEnabled else { return nil }
self.baseline = baseline
self.isEnabled = isEnabled
}
var name: String {
isEnabled ? "Enabled" : "Disabled"
}
static var allCases: [BoolBaselineTest] {
[
BoolBaselineTest(baseline: true, isEnabled: true),
BoolBaselineTest(baseline: false, isEnabled: true),
BoolBaselineTest(baseline: false, isEnabled: true),
BoolBaselineTest(baseline: false, isEnabled: false)
]
}
static let `default`: Self = BoolBaselineTest(baseline: truee, isEnabled; false)
} |
@BaselineBoolRemoteValue
struct BoolBaselineTestMacro { } Override `default`@BaselineBoolRemoteValue(enabledByDefault: true)
struct BoolBaselineTestMacro { } |
Using RemoteValuesNamespace
macro, you can encapsulate all remote values in namespace and avoid using macros with every declaration:
enum Remote {
@RemoteValuesNamespace
enum Value {
struct PaywallType {
enum Variant: String {
case a, b, c
}
}
struct AFeatureEnabled { }
// use only to override default
@BaselineBoolRemoteValue(enabledByDefault: true)
struct BFeatureEnabled { }
enum CFeatureEnabled { }
enum OnboardingType: String {
case x, y, z
}
}
}
will expand to:
enum Remote {
enum Value {
@BaselineStringRemoteValue
struct PaywallType {
enum Variant: String {
case a, b, c
}
}
@BaselineBoolRemoteValue
struct AFeatureEnabled { }
@BaselineBoolRemoteValue(enabledByDefault: true)
struct BFeatureEnabled { }
@BoolRemoteValue
enum CFeatureEnabled { }
@StringRemoteValue
enum OnboardingType: String {
case x, y, z
}
}
}
To work with remote keys / values you've defined, you can use RemoteConfigService
. It is a class that only requires some RemoteConfigKeeper
.
There is one ready-to-use class FirebaseRemoteConfigService
, that uses instance of FirebaseRemoteConfigKeeper
.
The instance of FirebaseRemoteConfigService
should be kept as singleton in your app, as it has its local state.
Algorithm is the following:
- Use
fetch()
method and wait for the completion (it will fetch config from the Firebase) - To setup some kind of analytics groups, track missing keys / incorrect values, use
getValues(for:)
method (eg.:getValues(for: RemoteKey.allCases)
) - You can later use methods
remoteValue(for:)
orremoteValuePublisher(for:)
to retrieve value / publisher for remote key
RemoteConfigService
has several open methods you probably would like to override:
trackKeyNotFound
- is called when performinggetValues(for:)
for missing keytrackKeysNotFound
- is called after performinggetValues(for:)
for all missing keystrackIncorrectValue
- is called when performinggetValues(for:)
for valid key but corrupted valuetrackIncorrectValues
- is called after performinggetValues(for:)
for valid key but corrupted valuetrackExperimentalGroup
- is called when performinggetValues(for:)
for valid remote value, if it conforms toExperimentalGroupTrackable
debugValue(for:)
- if your app uses some kind of debug mechanism and this method does not returnnil
, the returned value will be used instead of real from Firebase.customValue(for:)
- if you app needs some custom value logic, you can override this method for some particular key and returnnil
in other cases. This method overrides logic ofdebugValue(for:)
method and real value