🚀

UserDefaultsのDIを考える

に公開

概要

  • UserDefaults.standardを使っているクラスがあり、これを使ってテストを実行するとアプリで実際に利用しているデータに影響が出てしまうため、これのDIを考える

参考

class MockUserDefaults: UserDefaults {
  var gameStyleChanged = 0
  override func set(_ value: Int, forKey defaultName: String) {
    if defaultName == "gameStyle" {
      gameStyleChanged += 1
    }
  }
}

コード

コード全体
import SwiftUI

struct ContentView: View {
    
    @State private var name = "(nil)"
    
    private let userDefaultsSecondarySuitName = "com.ikeh.secondary"
    private let userDefaultsStandard: UserDefaults
    private let userDefaultsSecondary: UserDefaults
    
    init() {
        self.userDefaultsStandard = UserDefaults.standard
        self.userDefaultsSecondary = UserDefaults(suiteName: userDefaultsSecondarySuitName)!
    }
    
    var body: some View {
        Form {

            Section("Value") {
                LabeledContent("Name", value: name)
            }
            
            Section("Standard") {
                Button("Load") {
                    name = userDefaultsStandard.string(forKey: "name") ?? "(nil)"
                }
                
                Button("Write") {
                    userDefaultsStandard.set("standard-\(UUID().uuidString.prefix(5))", forKey: "name")
                }
                
                Button("Print plist path") {
                    guard let bundleIdentifier = Bundle.main.bundleIdentifier,
                          let url = UserDefaults.plistURL(suitName: bundleIdentifier) else  {
                        fatalError()
                    }
                    print(url.path)
                }
                
                Button("Delete", role: .destructive) {
                    guard let identifier = Bundle.main.bundleIdentifier else {
                        fatalError()
                    }
                    UserDefaults().removePersistentDomain(forName: identifier)
                }
            }
            
            Section("Secondary") {
                
                Button("Load") {
                    name = userDefaultsSecondary.string(forKey: "name") ?? "(nil)"
                }
                
                Button("Write") {
                    userDefaultsSecondary.set("debug-\(UUID().uuidString.prefix(5))", forKey: "name")
                }
                
                Button("Print plist path") {
                    guard let url = UserDefaults.plistURL(suitName: userDefaultsSecondarySuitName) else  {
                        fatalError()
                    }
                    print(url.path)
                }
                
                Button("Delete", role: .destructive) {
                    /*
                     https://developer.apple.com/documentation/foundation/userdefaults/1417339-removepersistentdomain
                     removeObject(forKey:)を回すのと同等の処理
                     */
                    UserDefaults().removePersistentDomain(forName: userDefaultsSecondarySuitName)
                    
                    if let url = UserDefaults.plistURL(suitName: userDefaultsSecondarySuitName),
                       FileManager.default.fileExists(atPath: url.path()) {
                        do {
                            try FileManager.default.removeItem(at: url)
                        } catch {
                            fatalError()
                        }
                    }
                }
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

// MARK: - UserDefaults+

extension UserDefaults {
    static func plistURL(suitName: String) -> URL? {
        guard let libraryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first else {
            return nil
        }
        return libraryURL
            .appendingPathComponent("Preferences")
            .appendingPathComponent("\(suitName).plist")
    }
}

UserDefaultsの作成

  • 下記のようにinit(suiteName:)を使って、テスト専用のUserDefaultsを作って、テストの際にこれをDIしてやるといい
private let userDefaultsSecondarySuitName = "com.ikeh.secondary"
private let userDefaultsStandard: UserDefaults
private let userDefaultsSecondary: UserDefaults

init() {
    self.userDefaultsStandard = UserDefaults.standard
    self.userDefaultsSecondary = UserDefaults(suiteName: userDefaultsSecondarySuitName)!
}

値の読み書き

// userDefaultsStandard

Button("Load") {
    name = userDefaultsStandard.string(forKey: "name") ?? "(nil)"
}

Button("Write") {
    userDefaultsStandard.set("standard-\(UUID().uuidString.prefix(5))", forKey: "name")
}

// userDefaultsSecondary

Button("Load") {
    name = userDefaultsSecondary.string(forKey: "name") ?? "(nil)"
}

Button("Write") {
    userDefaultsSecondary.set("debug-\(UUID().uuidString.prefix(5))", forKey: "name")
}

plistの場所

  • UserDefaultsのデータが保存されているplistのパスは以下で取得できる
extension UserDefaults {
    static func plistURL(suitName: String) -> URL? {
        guard let libraryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first else {
            return nil
        }
        return libraryURL
            .appendingPathComponent("Preferences")
            .appendingPathComponent("\(suitName).plist")
    }
}
Button("Print plist path") {
    guard let bundleIdentifier = Bundle.main.bundleIdentifier,
          let url = UserDefaults.plistURL(suitName: bundleIdentifier) else  {
        fatalError()
    }
    print(url.path)
}

Button("Print plist path") {
    guard let url = UserDefaults.plistURL(suitName: userDefaultsSecondarySuitName) else  {
        fatalError()
    }
    print(url.path)
}
/Users/ikeh/Library/Developer/CoreSimulator/Devices/09885C14-C8CE-4A1A-AA3E-633854426CE9/data/Containers/Data/Application/EA8D5FA4-003C-411B-B144-286606BA2B98/Library/Preferences/com.ikeh1024.UserDefaultsDemo.plist
/Users/ikeh/Library/Developer/CoreSimulator/Devices/09885C14-C8CE-4A1A-AA3E-633854426CE9/data/Containers/Data/Application/EA8D5FA4-003C-411B-B144-286606BA2B98/Library/Preferences/com.ikeh.secondary.plist

保存した値の削除

  • removePersistentDomain(forName:)を使って指定したUserDefaultsの全値を削除できる
  • テストでUserDefaultsを利用したあと、tearDown等で値を削除してやるといい
Button("Delete", role: .destructive) {
    guard let identifier = Bundle.main.bundleIdentifier else {
        fatalError()
    }
    UserDefaults().removePersistentDomain(forName: identifier)                                                    
}

Button("Delete", role: .destructive) {
    UserDefaults().removePersistentDomain(forName: userDefaultsSecondarySuitName)
}
  • また等価な別の方法としてUserDefaultsのDictionaryのremoveObjectを使ってリセットする方法もある

removePersistentDomain(forName:)
Calling this method is equivalent to initializing a user defaults object with init(suiteName:) passing domainName, and calling the removeObject(forKey:) method on each of its keys.

UserDefaultsの概要と操作方法(Swift) #iOS - Qiita

extension UserDefaults {
    func removeAll() {
        dictionaryRepresentation().forEach { removeObject(forKey: $0.key) }
    }
}
  • また前述の処理だとplist自体は残ってしまうため、テストケースのtearDownで値の削除と併せてファイルを削除すると丁寧かと思う
if let url = UserDefaults.plistURL(suitName: userDefaultsSecondarySuitName),
   FileManager.default.fileExists(atPath: url.path()) {
    do {
        try FileManager.default.removeItem(at: url)
    } catch {
        fatalError()
    }
}

Discussion