Skip to content

Commit

Permalink
Introduce new @UserDefaultOverride property wrapper for better UI t…
Browse files Browse the repository at this point in the history
…esting support (#8)

* Update UserDefaults.ValueContainer to sort 'launchArguments' output more predictably based on input order

* Remove workaround in UserDefaultsValueContainerTests and test order of launchArguments

* Create and document the @UserDefaultOverride property wrapper

* Add and test the counterpart LaunchArgumentEncodable protocol

* Update README.md

* Omit 'key' label from @UserDefaultOverride property wrapper iniitalisers to align better with @AppStorage and @userdefault

* Update Example project to use new @UserDefaultOverride property wrapper

* Update LaunchArgumentEncodableTests to stop depending on Swift 5.5 only API

* Fix typo in comments

* Restructure UserDefaultKeyValueRepresentable as UserDefaultOverrideRepresentable

* Update LaunchArgumentEncodable to expose userDefaultOverrides property to seperate usage of Mirror and allow for possible customisation
  • Loading branch information
liamnichols authored Jan 7, 2022
1 parent 4b99edb commit 1f66635
Show file tree
Hide file tree
Showing 9 changed files with 425 additions and 45 deletions.
3 changes: 2 additions & 1 deletion Example/Example/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import SwiftUI
struct ContentView: View {
// AppStorage is backed by UserDefaults so this works too!
@AppStorage(.contentTitle) var title: String?
@AppStorage(.contentSortOrder) var sortOrder: ContentSortOrder = .descending
@ObservedObject var viewModel = ContentViewModel()

var body: some View {
Expand All @@ -37,7 +38,7 @@ struct ContentView: View {
.frame(maxWidth: .infinity)
} else {
List {
ForEach(viewModel.items, id: \.self) { item in
ForEach(viewModel.items.sorted(by: sortOrder.compare(lhs:rhs:)), id: \.self) { item in
Text("\(item, formatter: viewModel.dateFormatter)")
}
.onDelete(perform: { viewModel.items.remove(atOffsets: $0) })
Expand Down
16 changes: 16 additions & 0 deletions Example/ExampleKit/Sources/ExampleKit/ExamplePreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,20 @@ public extension UserDefaults.Key {

/// User defaults key representing the items displayed in the list of `ContentView`
static let contentItems = Self("ContentItems")

/// The order used to sort the items that are displayed in `ContentView`
static let contentSortOrder = Self("ContentSortOrder")
}

public enum ContentSortOrder: String {
case ascending, descending

public func compare<T: Comparable>(lhs: T, rhs: T) -> Bool {
switch self {
case .ascending:
return lhs < rhs
case .descending:
return lhs > rhs
}
}
}
53 changes: 36 additions & 17 deletions Example/ExampleUITests/ExampleUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,44 +25,63 @@ import SwiftUserDefaults
import XCTest

class ExampleUITests: XCTestCase {
func testNoItemsPlaceholder() {
struct Configuration: LaunchArgumentEncodable {
@UserDefaultOverride(.contentTitle)
var title: String = "Example App (Test)"

@UserDefaultOverride(.contentItems)
var items: [Date] = []

@UserDefaultOverride(.contentSortOrder)
var sortOrder: ContentSortOrder = .descending

var deviceLocale: Locale = Locale(identifier: "en_US")

var additionalLaunchArguments: [String] {
// Type `Locale` doesn't match how we want to represent the `AppleLocale` UserDefault so we'll encode it manually
var container = UserDefaults.ValueContainer()
container.set(deviceLocale.identifier, forKey: UserDefaults.Key("AppleLocale"))

return container.launchArguments
}
}

func testNoItemsPlaceholder() throws {
// Configure UserDefaults to ensure that there are no items
// Use the UserDefaults.Key constants from ExampleKit to keep test code in sync
var container = UserDefaults.ValueContainer()
container.set("Example App", forKey: .contentTitle)
container.set(Array<Date>(), forKey: .contentItems)
// The default definition of `Configuration` sets sensible defaults to ensure a consistent (empty) state.
let configuration = Configuration()

// Launch the app with the user defaults
let app = XCUIApplication()
app.launchArguments = container.launchArguments
app.launchArguments = try configuration.encodeLaunchArguments()
app.launch()

// Ensure the placeholder is set properly
XCTAssertTrue(app.navigationBars["Example App"].exists)
XCTAssertTrue(app.navigationBars["Example App (Test)"].exists)
XCTAssertTrue(app.staticTexts["No Items"].exists)
}

func testDeleteItem() {
func testDeleteItem() throws {
let calendar = Calendar.current
let startDate = calendar.date(from: DateComponents(year: 2021, month: 6, day: 1, hour: 9, minute: 10))!

// Configure UserDefaults to contain items with known dates
// Use 'String' keys for a simplified extension (avoiding UserDefaults.Key)
var container = UserDefaults.ValueContainer()
container.set(["fr_FR"], forKey: UserDefaults.Key("AppleLanguages"))
container.set("Example App", forKey: .contentTitle)
container.set([
// Configure a more complex scenario to test by overriding various values
var configuration = Configuration()
configuration.deviceLocale = Locale(identifier: "fr_FR")
configuration.sortOrder = .ascending
configuration.title = "Example App"
configuration.items = [
startDate,
calendar.date(byAdding: .day, value: 1, to: startDate)!,
calendar.date(byAdding: .day, value: 2, to: startDate)!,
calendar.date(byAdding: .day, value: 3, to: startDate)!,
calendar.date(byAdding: .day, value: 4, to: startDate)!,
calendar.date(byAdding: .day, value: 5, to: startDate)!
], forKey: .contentItems)
]

// Launch the app with the user defaults
// Launch the app with the user default overrides
let app = XCUIApplication()
app.launchArguments = container.launchArguments
app.launchArguments = try configuration.encodeLaunchArguments()
app.launch()

// Find a known cell, ensure it exists
Expand Down
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,15 +198,25 @@ import MyAppCommon
import SwiftUserDefaults
import XCTest

struct MyAppConfiguration: LaunchArgumentEncodable {
@UserDefaultOverride(.currentLevel)
var currentLevel: Int?

@UserDefaultOverride(.userName)
var userName: String?

@UserDefaultOverride(.userGUID)
var userGUID = "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"
}

final class MyAppTests: XCTestCase {
func testMyApp() {
var container = UserDefaults.ValueContainer()
container.set(8, forKey: .currentLevel)
container.set("John Doe", forKey: .userName)
container.set("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", forKey: .userGUID)
func testMyApp() throws {
var configuration = MyAppConfiguration()
container.currentLevel = 8
container.userName = "John Doe"

let app = XCUIApplication()
app.launchArguments = container.launchArguments
app.launchArguments = try configuration.encodeLaunchArguments()
app.launch()

// ...
Expand Down
48 changes: 48 additions & 0 deletions Sources/SwiftUserDefaults/LaunchArgumentEncodable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation

/// A protocol used by container types that can have their representations encoded into launch arguments via the ``encodeLaunchArguments()`` method.
///
/// This protocol works exclusively in conjunction with the ``UserDefaultOverride`` property wrapper.
public protocol LaunchArgumentEncodable {
/// Additional values to be appended to the result of `collectLaunchArguments()`.
///
/// A default implementation is provided that returns an empty array.
var additionalLaunchArguments: [String] { get }

/// An array of types that represent UserDefault key/value overrides to be converted into launch arguments.
///
/// A default implementation is provided that uses reflection to collect these values from the receiver.
/// You are free to override and provide your own implementation if you would prefer.
var userDefaultOverrides: [UserDefaultOverrideRepresentable] { get }
}

public extension LaunchArgumentEncodable {
var additionalLaunchArguments: [String] {
[]
}

/// Uses reflection to collect properties that conform to `UserDefaultOverrideRepresentable` from the receiver.
var userDefaultOverrides: [UserDefaultOverrideRepresentable] {
Mirror(reflecting: self)
.children
.compactMap { $0.value as? UserDefaultOverrideRepresentable }
}

/// Collects the complete array of launch arguments from the receiver.
///
/// The contents of the return value is built by using Reflection to look for all `@UserDefaultOverride` property wrapper instances. See ``UserDefaultOverride`` for more information.
///
/// In addition to overrides, the contents of `additionalLaunchArguments` is appended to the return value.
func encodeLaunchArguments() throws -> [String] {
// Map the overrides into a container
var container = UserDefaults.ValueContainer()
for userDefaultOverride in userDefaultOverrides {
// Add the storable value into the container only if it wasn't nil
guard let value = try userDefaultOverride.getValue() else { continue }
container.set(value, forKey: userDefaultOverride.key)
}

// Return the collected user default overrides along with any additional arguments
return container.launchArguments + additionalLaunchArguments
}
}
177 changes: 177 additions & 0 deletions Sources/SwiftUserDefaults/UserDefaultOverride.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import Foundation

/// A protocol to help erase generic type information from ``UserDefaultOverride`` when attempting to obtain the key value pair.
public protocol UserDefaultOverrideRepresentable {
/// The key of the user default value that should be overidden.
var key: UserDefaults.Key { get }

/// The value of the user default value that shoul be overridden, or nil if an override should not be applied.
func getValue() throws -> UserDefaultsStorable?
}

/// A property wrapper used for marking types as a value that should be used as an override in `UserDefaults`.
///
/// On its own, `@UserDefaultOverride` or `LaunchOverrides` cannot override values stored in `UserDefaults`, but they can provide an array of launch arguments that you can then pass to a process. There are two scenarios where you might find this useful:
///
/// 1. Running UI Tests via XCTest, you might set `XCUIApplication`'s `launchArguments` array before calling `launch()`.
/// 2. Invoking a `Process`, you might pass values to the `arguments` array.
///
/// **UI Test Example**
///
/// When using SwiftUserDefaults, if you define `UserDefaults.Key` definitions and other model types in a separate framework target (in this example, `MyFramework`), you can then share them between your application target and your UI test target:
///
/// ```swift
/// import SwiftUserDefaults
///
/// public extension UserDefaults.Key {
/// public static let user = Self("User")
/// public static let state = Self("State")
/// public static let isLegacyUser = Self("LegacyUser")
/// }
///
/// public struct User: Codable {
/// public var name: String
///
/// public init(name: String) {
/// self.name = name
/// }
/// }
///
/// public enum State: String {
/// case registered, unregistered
/// }
/// ```
/// To easily manage overrides in your UI Testing target, import your framework target and define a container that conforms to `LaunchArgumentEncodable`. In this container, use the `@UserDefaultOverride` property wrapper to build up a configuration of overrides that match usage in your app:
///
/// ```swift
/// import MyFramework
/// import SwiftUserDefaults
///
/// struct AppConfiguration: LaunchArgumentEncodable {
/// // An optional Codable property, encoded to data using the `.plist` strategy.
/// @UserDefaultOverride(.user, strategy: .plist)
/// var user: User?
///
/// // A RawRepresentable enum with a default value, encoded to it's backing `rawValue` (a String).
/// @UserDefaultOverride(.state)
/// var state: State = .unregistered
///
/// // An optional primitive type (Bool). When `nil`, values will not be used as an override since null cannot be represented.
/// @UserDefaultOverride(.isLegacyUser)
/// var isLegacyUser: Bool?
///
/// // A convenient place to define other launch arguments that don't relate to `UserDefaults`.
/// var additionalLaunchArguments: [String] {
/// ["UI-Testing"]
/// }
/// }
/// ```
///
/// Finally, in your test cases, create and configure an instance of your container type and use the `collectLaunchArguments()` method to pass the overrides into your `XCUIApplication` and perform the UI tests like normal. The overrides will be picked up by `UserDefaults` instances in your app to help you in testing pre-configured states.
///
/// ```swift
/// import SwiftUserDefaults
/// import XCTest
///
/// class MyAppUITestCase: XCTestCase {
/// func testScenario() throws {
/// // Create a configuration, update the overrides
/// var configuration = AppConfiguration()
/// configuration.user = User(name: "John")
/// configuration.state = .registered
///
/// // Create the test app, assign the launch arguments and launch the process.
/// let app = XCUIApplication()
/// app.launchArguments = try configuration.encodeLaunchArguments()
/// app.launch()
///
/// // The launch arguments will look like the following:
/// app.launchArguments
/// // ["-User", "<data>...</data>", "-State", "<string>registered</string>", "UI-Testing"]
///
/// // ...
/// }
/// }
/// ```
@propertyWrapper
public struct UserDefaultOverride<Value>: UserDefaultOverrideRepresentable {
let valueGetter: () -> Value
let valueSetter: (Value) -> Void
let storableValue: () throws -> UserDefaultsStorable?

public let key: UserDefaults.Key

public func getValue() throws -> UserDefaultsStorable? {
try storableValue()
}

public var wrappedValue: Value {
get {
valueGetter()
}
set {
valueSetter(newValue)
}
}

public var projectedValue: UserDefaultOverrideRepresentable {
self
}

init(
wrappedValue defaultValue: Value,
key: UserDefaults.Key,
transform: @escaping (Value) throws -> UserDefaultsStorable?
) {
var value: Value = defaultValue

self.key = key
self.valueGetter = { value }
self.valueSetter = { value = $0 }
self.storableValue = {
guard let value = try transform(value) else { return nil }
return value
}
}

public init(
wrappedValue defaultValue: Value,
_ key: UserDefaults.Key
) where Value: UserDefaultsStorable {
self.init(wrappedValue: defaultValue, key: key, transform: { $0 })
}

public init<T: UserDefaultsStorable>(
_ key: UserDefaults.Key
) where Value == T? {
self.init(wrappedValue: nil, key: key, transform: { $0 })
}

public init(
wrappedValue defaultValue: Value,
_ key: UserDefaults.Key
) where Value: RawRepresentable, Value.RawValue: UserDefaultsStorable {
self.init(wrappedValue: defaultValue, key: key, transform: { $0.rawValue })
}

public init<T: RawRepresentable>(
_ key: UserDefaults.Key
) where Value == T?, T.RawValue: UserDefaultsStorable {
self.init(wrappedValue: nil, key: key, transform: { $0?.rawValue })
}

public init(
wrappedValue defaultValue: Value,
_ key: UserDefaults.Key,
strategy: UserDefaults.CodingStrategy
) where Value: Encodable {
self.init(wrappedValue: defaultValue, key: key, transform: { try strategy.encode($0) })
}

public init<T: Encodable>(
_ key: UserDefaults.Key,
strategy: UserDefaults.CodingStrategy
) where Value == T? {
self.init(wrappedValue: nil, key: key, transform: { try $0.flatMap({ try strategy.encode($0) }) })
}
}
Loading

0 comments on commit 1f66635

Please sign in to comment.