sunshinejr / SwiftyUserDefaults

Modern Swift API for NSUserDefaults
http://radex.io/swift/nsuserdefaults/static
MIT License
4.84k stars 364 forks source link

Consider making DefaultsAdapter a class #268

Open alexito4 opened 3 years ago

alexito4 commented 3 years ago

The current design of DefaultsAdapter relies on it being used via the global variable Defaults which is a var. But using that global variable is not ideal for testing so I'm trying to use this library with typical DI.

Once doing that and storing a DefaultsAdapter as a property to be used things don't work as smoothly as expected. The problem is that the adapter is a struct and thus you can't mutate it easily. And in reality is not mutating any values so it's kind of misleading.

Changing it to a class shouldn't have any impact and would allow dynamic member lookup to be used in more places.

agmike commented 3 years ago

Setters can be declared as nonmutating, which will remove the need to declare DefaultsAdapter as a var in order to mutate the values.

sunshinejr commented 3 years ago

Hey @alexito4 - I just merged two PRs, one that adds nonmutating keywords to the adapter's setters, and the other one for providing a protocol that you can implement to pass around testing adapters in your test suite. I'll be releasing 5.2.0 that should have both of these shortly. Please let me know if these help :)

alexito4 commented 3 years ago

Oh that sounds cool! I would made a task to check out the update. Thanks 😉

Jeehut commented 2 years ago

Thank you for this @sunshinejr!

In case anyone is interested, it took me a while but I figured out how to use SwiftyUserDefaults in the app and mock it in my tests. I'm using TCA which gives me an environment object where I pass in a UserDefaults object.

Then, I have this convenience extension method that I call on my passed UserDefaults object in the app:

import Foundation
import SwiftyUserDefaults

extension UserDefaults {
  /// Returns a SwiftyUserDefaults enhanced object that can be used like the `Defaults` object in the SwiftyUserDefaults documentation.
  var swifty: DefaultsAdapter<DefaultsKeys> {
    DefaultsAdapter<DefaultsKeys>(defaults: self, keyStore: .init())
  }
}

Then, instead of Defaults.isFirstAppStart I can now use environment.userDefaults.swifty.isFirstAppStart everywhere in the app. I've setup a custom lint rule via AnyLint to ensure no developer uses Defaults. directly.

In my test target, I have another extension for convenience:

import Foundation

extension UserDefaults {
  static var test = UserDefaults(suiteName: "com.my.app.tests")!
}

This allows me to pass in UserDefaults.test to my environment object in the test suite (in the app I pass UserDefaults.standard instead). Additionally I call UserDefaults.test.removeAll() in the setUp() method of all my test classes. When I need to set a specific environment, I just set the values I need via UserDefaults.test.swifty.isFirstAppStart = false (etc.).

class LoginTests: XCTestCase {
  override func setUp() {
    UserDefaults.test.removeAll()
  }

  func testForgotPassword() {
    let store = TestStore(initialState: .init(), reducer: loginReducer, environment: .init(userDefaults: .test))
    UserDefaults.test.swifty.isFirstAppStart = true    

    // my tests expecting `isFirstAppStart` to be `true`
  }

  // ...
}

I hope this helps someone out there!

VillSkog commented 2 years ago

@Jeehut: I would recommend you use removePersistentDomain as well. As is, I believe some tests might impact others eventually.

https://www.swiftbysundell.com/tips/avoiding-mocking-userdefaults/