12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

UserDefaultsを含むclassをテストするtips

Posted at

モチベーション

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を実行して見ましょう

失敗します
これは,既にアプリ本体のUserDefaultsnameKenが書き込まれているためです
このことから,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を利用する件
を御覧ください

検証してみましょう

ViewController.swift
 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

結構無茶苦茶ですが,
nameKenを,nicknameKentyを書き込んでいます

ViewModelTests.swift
 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))

によって,nameTomに書き換えています.また,nicknameを取得しています.
この時の実行結果は,
実行結果

 Kenty

です.
テストを実行した後に,再度アプリを起動します.
その実行結果は,

実行結果

 Ken

上記のことから,

任意のUserDefaultsからstandardの値を参照することはできますが,任意のUserDefaultsに値を書き込んでもstandardには影響がありません

が証明されました!

最後に

UserDefaultsを含むclassをテストする時は,init(suiteName:)を使用しましょう!

コードも一覧として載せておきます

ViewController.swift
 final class ViewController: UIViewController {
 
     private let viewModel: ViewModel = .init()
 
     override func viewDidLoad() {
         super.viewDidLoad()
         viewModel.setName(name: "Ken")
     }
 
 }
ViewModel.swift
 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)
     }
 }
ViewModelTests.test
 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)
         }
     }
 }

参考文献

12
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?