🚀
UserDefaultsのDIを考える
概要
-
UserDefaults.standard
を使っているクラスがあり、これを使ってテストを実行するとアプリで実際に利用しているデータに影響が出てしまうため、これのDIを考える
参考
- 【Swift】UserDefaultsをもう少しちゃんと理解する #iOS - Qiita
- removePersistentDomain(forName:) | Apple Developer Documentation
- UserDefaultsを含むclassをテストするtips #Swift - Qiita
- UserDefaultsの概要と操作方法(Swift) #iOS - Qiita
-
iOS Unit Testing and UI Testing Tutorial | Kodeco
- UserDefaultsを継承したクラスを作成する手法もある
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.
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