モチベーション
UserDefaultsを含むclassをテストする際に,アプリ本体のUserDefaultsに影響がない形でテストを書きたかった
環境
Xcode: Version 11.5 (11E608c)
Swift: 5
iOS: 13.5
今回のテスト対象のclass
class名はViewModelですが,何らかのデザインパターンを適用しているわけではありません
enum Keys: String {
case name = "name"
case nickname = "nickname"
}
final class ViewModel {
private let userDefaults = UserDefaults.standard
func getName() -> String {
guard let name = userDefaults.string(forKey: Keys.name.rawValue) else { return "none" }
return name
}
func setName(name: String) {
userDefaults.set(name, forKey: Keys.name.rawValue)
}
}
name というkeyのString型の値を取得し書き込むclassです
これのテストclassは下記です
final class ViewModelTests: XCTestCase {
var dependency: Dependency!
override func setUp() {
super.setUp()
dependency = Dependency()
}
func testGetnName() {
let testTarget = dependency.testTarget
XCTAssertEqual(testTarget.getName(), "none")
}
}
extension ViewModelTests {
struct Dependency {
let testTarget: ViewModel
init() {
testTarget = .init()
}
}
}
テスト関数である testGetnName は成功します
ではここで,ViewControllerを見てみましょう
class ViewController: UIViewController {
private let viewModel: ViewModel = .init()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.setName(name: "Ken")
}
}
アプリが起動した後に,name というkeyに対してKenを書き込んでいます.
では,アプリを1度でも起動した後に,testGetnNameを実行して見ましょう
失敗します
これは,既にアプリ本体のUserDefaultsのnameにKenが書き込まれているためです
このことから,ViewModelのテストであるViewModelTestsが,アプリ本体のUserDefaultsに依存してることがわかります
これでは,テストコードとして十分に機能していません
ViewModelを改善する
改善ポイントは,init時にuserDefaultsに対してDIをすることです
DIとはDependency Injectionの略称であり,日本語で依存性の注入といいます
詳しくは,
猿でも分かる! Dependency Injection: 依存性の注入
を御覧ください
今回はViewModelからアプリ本体のUserDefaultsへの依存を切り離します
まずViewModelを修正します
final class ViewModel {
private let userDefaults: UserDefaults // 修正箇所
init(userDefaults: UserDefaults = UserDefaults.standard ) {
self.userDefaults = userDefaults // これで準備OK
}
func getName() -> String {
guard let name = userDefaults.string(forKey: Keys.name.rawValue) else { return "none" }
return name
}
func setName(name: String) {
userDefaults.set(name, forKey: Keys.name.rawValue)
}
}
init時に代入することにより,DIする準備が整いました
次に,テストコードを修正します
final class ViewModelTests: XCTestCase {
var dependency: Dependency!
override func setUp() {
super.setUp()
dependency = Dependency()
}
func testGetnName() {
let testTarget = dependency.testTarget
XCTAssertEqual(testTarget.getName(), "none")
}
}
extension ViewModelTests {
struct Dependency {
let testTarget: ViewModel
init() {
testTarget = .init(userDefaults: UserDefaults()) // 修正箇所
}
}
}
これで,テストが成功する...!
と思いましたが,失敗します
原因は testTarget = .init(userDefaults: UserDefaults())
です
UserDefaults()
とUserDefaults.standard
は同じものを指しています
open class UserDefaults : NSObject {
/**
+standardUserDefaults returns a global instance of NSUserDefaults configured to search the current application's search list.
*/
open class var standard: UserDefaults { get }
・
・
・
}
Appleの公式ドキュメントより
https://developer.apple.com/documentation/foundation/userdefaults/1416603-standard
class var standard: UserDefaults { get }
The shared defaults object.
Use this initializer only if you're not using the shared standard user defaults.
ここで登場するのが,init(suiteName:)
です
ViewModelTestsを改善する
init(suiteName:)
は任意の名前でUserDefaultsを作ることができます
ただし,Apple公式ドキュメントより
https://developer.apple.com/documentation/foundation/userdefaults/1409957-init
The argument and registration domains are shared between all instances of UserDefaults.
引数と登録ドメインは、UserDefaultsのすべてのインスタンス間で共有されます。
では何が嬉しいのか?
それは,
任意のUserDefaultsからstandardの値を参照することはできますが,任意のUserDefaultsに値を書き込んでもstandardには影響がありません
ということです
詳しくは
UserDefaultsをテストする際にuserSuiteを利用する件
を御覧ください
検証してみましょう
final class ViewController: UIViewController {
private let viewModel: ViewModel = .init()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.setName(name: "Ken")
// 検証用です 本来はいりません
UserDefaults.standard.set("Kenty", forKey: Keys.nickname.rawValue)
print(viewModel.getName())
}
}
実行結果
Ken
結構無茶苦茶ですが,
nameにKenを,nicknameにKentyを書き込んでいます
final class ViewModelTests: XCTestCase {
var dependency: Dependency!
override func setUp() {
super.setUp()
dependency = Dependency()
}
override func tearDown() {
super.tearDown()
dependency.removeUserDefaults() // テストが終わったら,削除しましょう
}
func testGetnName() {
let testTarget = dependency.testTarget
XCTAssertEqual(testTarget.getName(), "none")
// 検証用です 本来はいりません
testTarget.setName(name: "Tom")
print(UserDefaults.standard.string(forKey: Keys.nickname.rawValue))
}
}
extension ViewModelTests {
struct Dependency {
let testTarget: ViewModel
let userDefaults: UserDefaults
static let suiteName: String = "Test"
init() {
userDefaults = UserDefaults(suiteName: ViewModelTests.Dependency.suiteName)!
testTarget = .init(userDefaults: userDefaults)
}
func removeUserDefaults() {
userDefaults.removePersistentDomain(forName: ViewModelTests.Dependency.suiteName)
}
}
}
先程アプリを起動したので,standardのUserDefaultsには,"Ken"が書き込まれています
なので,
XCTAssertEqual(testTarget.getName(), "none")
は失敗しそうですが,成功します
userDefaults = UserDefaults(suiteName: ViewModelTests.Dependency.suiteName)!
のおかげです
必ずテストが終わったら,作成したUserDefaultsは削除しましょう
dependency.removeUserDefaults() // テストが終わったら,削除しましょう
削除しないと残ってしまいます
最後に,検証結果を見てみましょう
// 検証用です 本来はいりません
testTarget.setName(name: "Tom")
print(UserDefaults.standard.string(forKey: Keys.nickname.rawValue))
によって,nameをTomに書き換えています.また,nicknameを取得しています.
この時の実行結果は,
実行結果
Kenty
です.
テストを実行した後に,再度アプリを起動します.
その実行結果は,
実行結果
Ken
上記のことから,
任意のUserDefaultsからstandardの値を参照することはできますが,任意のUserDefaultsに値を書き込んでもstandardには影響がありません
が証明されました!
最後に
UserDefaultsを含むclassをテストする時は,init(suiteName:)
を使用しましょう!
コードも一覧として載せておきます
final class ViewController: UIViewController {
private let viewModel: ViewModel = .init()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.setName(name: "Ken")
}
}
enum Keys: String {
case name = "name"
case nickname = "nickname"
}
final class ViewModel {
private let userDefaults: UserDefaults
init(userDefaults: UserDefaults = UserDefaults.standard ) {
self.userDefaults = userDefaults
}
func getName() -> String {
guard let name = userDefaults.string(forKey: Keys.name.rawValue) else { return "none" }
return name
}
func setName(name: String) {
userDefaults.set(name, forKey: Keys.name.rawValue)
}
}
final class ViewModelTests: XCTestCase {
var dependency: Dependency!
override func setUp() {
super.setUp()
dependency = Dependency()
}
override func tearDown() {
super.tearDown()
dependency.removeUserDefaults() // テストが終わったら,削除しましょう
}
func testGetnName() {
let testTarget = dependency.testTarget
XCTAssertEqual(testTarget.getName(), "none")
}
}
extension ViewModelTests {
struct Dependency {
let testTarget: ViewModel
let userDefaults: UserDefaults
static let suiteName: String = "Test"
init() {
userDefaults = UserDefaults(suiteName: ViewModelTests.Dependency.suiteName)!
testTarget = .init(userDefaults: userDefaults)
}
func removeUserDefaults() {
userDefaults.removePersistentDomain(forName: ViewModelTests.Dependency.suiteName)
}
}
}