はじめに
こんにちは。iOSアプリ開発グループの神山です。
最近CoreDataを触る機会が増えているのですが、テストについての記事がなかなか見つからず苦戦したため自分なりにまとめてみました。
今回はCoreDataのテストについて焦点を当てて進めていくため、CoreDataの概要などについては省略させていただくことをご了承ください。
下準備
テストを行うための下準備を行なっていきます。
はじめにデータモデルを定義していきます。
今回は
nameと
priceのプロパティを持った
Fruitというデータモデルを作成しました。
以下は自動生成されたコードになります。
// Fruit+CoreDataClass.swift @objc(Fruit) public class Fruit: NSManagedObject {} // Fruit+CoreDataproperties.swift extension Fruit { @nonobjc public class func fetchRequest() -> NSFetchRequest<Fruit> { return NSFetchRequest<Fruit>(entityName: "Fruit") } @NSManaged public var name: String @NSManaged public var price: Int64 } extension Fruit : Identifiable {} |
次にアプリ内で定義したデータモデルを操作するためのクラスを作成します。
下図でもあるように、ここでは一つのインスタンスでCoreDataの操作をする
NSPersistentContainerを作成するのが目的です。
このクラス内の
viewContextのプロパティを使用してCoreDataに対してCRUDの処理を行っていきます。
※ Persistent container(NSPersistentContainer)のインスタンスが保持するもの
- Model(NSManagedObjectModel) → アプリで定義したモデルエンティティ(ここでは Fruit)
- Context(NSManagedObjectContext) → モデルエンティティのインスタンスの変更を追跡
- Store coordinator(NSPersistentStoreCoordinator) → モデルエンティティのインスタンスを保存・取得
final class CoreDataManager { private(set) lazy var viewContext = persistentContainer.viewContext lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "コンテナ名") container.loadPersistentStores { _, error in if let error = error { print(error.localizedDescription) } } return container }() static let shared = CoreDataManager() private init() {} } extension CoreDataManager { func deleteAllObjects() { persistentContainer.managedObjectModel.entities .compactMap(\.name) .forEach { let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: $0) let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try persistentContainer.viewContext.execute(batchDeleteRequest) } catch { print(error.localizedDescription) } } persistentContainer.viewContext.reset() } } extension NSManagedObjectContext { func saveIfNeeded() { if !hasChanges { return } do { try save() } catch { print(error.localizedDescription) } } } |
次は先ほど作成した CoreDataManagerを使用して、実際にCoreDataに対してCURD処理を行う構造体を作成します。今回は CoreDataStorageという名前で作成しました。
struct CoreDataStorage<T: NSManagedObject> { static var context: NSManagedObjectContext { CoreDataManager.shared.viewContext } static func entity() -> T { let entity = NSEntityDescription.entity( forEntityName: String(describing: T.self), in: context )! return T(entity: entity, insertInto: nil) } static func read( sortDescriptors: [NSSortDescriptor] = [], predicate: NSPredicate? = nil, fetchLimit: Int = 0 ) -> [T] { let fetchRequest = NSFetchRequest<T>(entityName: String(describing: T.self)) fetchRequest.sortDescriptors = sortDescriptors fetchRequest.predicate = predicate fetchRequest.fetchLimit = fetchLimit guard let result = try? context.fetch(fetchRequest) else { return [] } return result } static func create(_ object: T) { context.insert(object) context.saveIfNeeded() } static func update() { context.saveIfNeeded() } static func delete(_ object: T) { context.delete(object) context.saveIfNeeded() } } |
CoreDataStorageを作成しましたら FruitがCoreDataStorageを使ってCURD操作ができるように関数を追加しておきます。
extension Fruit { static func find(key: String) -> Fruit? { let predicate = NSPredicate(format: "name == %@", key) return CoreDataStorage.read(predicate: predicate).first } static func create(name: String, price: Int64) -> Fruit { let fruit: Fruit = CoreDataStorage.entity() fruit.name = name fruit.price = price return fruit } static func update(key: String, name: String, price: Int64) { if let fruit = find(key: key) { fruit.name = name fruit.price = price } } } |
これで諸々のCoreDataの処理を行えるようになりました。
CoreDataのCURD処理
テストに入る前に一度どのようにデータを処理するのかを確認してみましょう。
【取得】
let fruits: [Fruit] = CoreDataStorage.read() |
【作成】
let apple = Fruit.create(name: "りんご", price: 100) CoreDataStorage.create(apple) |
【削除】
CoreDataStorage.delete(apple) |
【更新】
Fruit.update(key: "りんご", name: "もも", price: 500) CoreDataStorage.update() |
CoreDataのテスト
テストを書くための準備が完了したのでテストコードを追加してみましょう。
はじめにCoreDataに対してCURD処理を行う構造体として作成した CoreDataStorageのテストを作成してみます。
final class CoreDataStorageTests: XCTestCase { private let context = CoreDataManager.shared.viewContext override func tearDown() { super.tearDown() CoreDataManager.shared.deleteAllObjects() } func test_CoreDataのモデルエンティティを生成できる() { // act let fruit: Fruit = CoreDataStorage.entity() // assert XCTAssertNotNil(fruit) } func test_CoreDataから果物を取得できる() { // arrange let grape = Fruit(context: context) grape.name = "ぶどう" grape.price = 1000 context.insert(grape) context.saveIfNeeded() // act let firstResult: [Fruit] = CoreDataStorage.read() // assert XCTAssertEqual(firstResult.count, 1) XCTAssertTrue(firstResult.contains(grape)) // arrange let apple = Fruit(context: context) apple.name = "りんご" apple.price = 300 context.insert(apple) context.saveIfNeeded() // act let secondResult: [Fruit] = CoreDataStorage.read() // assert XCTAssertEqual(secondResult.count, 2) XCTAssertTrue(secondResult.contains(apple)) } func test_CoreDataに果物を追加できる() { // arrange let fruit = Fruit(context: context) fruit.name = "ぶどう" fruit.price = 1000 // act CoreDataStorage.create(fruit) let fetchRequest: NSFetchRequest<Fruit> = Fruit.fetchRequest() let result = try! context.fetch(fetchRequest) // assert XCTAssertEqual(result.count, 1) XCTAssertTrue(result.contains(fruit)) } func test_CoreDataから果物を削除できる() { // arrange let fruit = Fruit(context: context) fruit.name = "ぶどう" fruit.price = 1000 context.insert(fruit) context.saveIfNeeded() // act CoreDataStorage.delete(fruit) let fetchRequest: NSFetchRequest<Fruit> = Fruit.fetchRequest() let result = try! context.fetch(fetchRequest) // assert XCTAssertEqual(result.count, 0) XCTAssertFalse(result.contains(fruit)) } func test_CoreData内の果物を更新できる() { // arrange let fruit = Fruit(context: context) fruit.name = "ぶどう" fruit.price = 1000 context.insert(fruit) context.saveIfNeeded() // act let fetchRequest: NSFetchRequest<Fruit> = Fruit.fetchRequest() let beforeResult = try! context.fetch(fetchRequest) beforeResult.first!.name = "りんご" beforeResult.first!.price = 500 CoreDataStorage.update() let afterResult = try! context.fetch(fetchRequest) // assert XCTAssertEqual(afterResult.count, 1) XCTAssertEqual(afterResult.first!.name, "りんご") XCTAssertEqual(afterResult.first!.price, 500) } } |
次にCoreDataStorageを使ってCURD操作ができるように関数を追加したモデルエンティティ(Fruit)に対してのテストも作成してみます。
final class FruitTests: XCTestCase { private let context = CoreDataManager.shared.viewContext override func tearDown() { super.tearDown() CoreDataManager.shared.deleteAllObjects() } func test_Fruitエンティティを作成できる() { // act let apple = Fruit.create(name: "りんご", price: 200) // assert XCTAssertEqual(apple.name, "りんご") XCTAssertEqual(apple.price, 200) } func test_Fruitエンティティを検索できる() { // arrange let apple = Fruit(context: context) apple.name = "りんご" apple.price = 200 context.insert(apple) context.saveIfNeeded() // act let result = Fruit.find(key: "りんご") // assert XCTAssertNotNil(result) } func test_Fruitエンティティを更新できる() { // arrange let apple = Fruit(context: context) apple.name = "りんご" apple.price = 200 context.insert(apple) context.saveIfNeeded() // act Fruit.update(key: "りんご", name: "もも", price: 500) context.saveIfNeeded() let fetchRequest: NSFetchRequest<Fruit> = Fruit.fetchRequest() let result = try! context.fetch(fetchRequest) // assert XCTAssertEqual(result.first!.name, "もも") XCTAssertEqual(result.first!.price, 500) } } |
テストを実行してみますと、失敗する場合が出てくるかと思います。
例えば、既にデータをCoreDataに保存していたなどの場合です。テストを実行した際にそのデータも含めてテストが行われるためデータに不整合が生じてしまいます。
CoreDataのテストでは実行するタイミングや状況に応じてテストの結果が変わってしまうことがあるため、これを防ぐ必要があります。
テスト用のCoreData設定
CoreDataではデータの書き込み方法を変更することで、アプリとは別にテストのための領域を作成することができます。これを用いてテストではディスク上ではなくメモリに書き込むように変更してみましょう。CoreDataManagerのファイルに関数を追加します。
final class CoreDataManager { private(set) lazy var viewContext = persistentContainer.viewContext lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "コンテナ名") container.loadPersistentStores { _, error in if let error = error { print(error.localizedDescription) } } return container }() static let shared = CoreDataManager() private init() {} func inject(_ persistentContainer: NSPersistentContainer) { self.persistentContainer = persistentContainer } } |
そして、上記で作成した関数を用いて、テストではメモリ上に書き込むを行う persistentContainerを作成し設定します。
final class CoreDataStorageTests: XCTestCase { ... override func setUp() { super.setUp() let persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "コンテナ名") // メモリに書き込みを行う設定 let description = NSPersistentStoreDescription() description.url = URL(fileURLWithPath: "/dev/null") container.persistentStoreDescriptions = [description] container.loadPersistentStores { _, error in if let error = error { print(error.localizedDescription) } } return container }() CoreDataManager.shared.inject(persistentContainer) } override func tearDown() { ... } } |
これでアプリ上でCoreDataにデータを保存した状態でテストを実行したとしてもテストが成功することが確認できるかと思います。
おまけ
CoreDataをメモリ上に保存する方法として以下の方法もあります。
let persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "コンテナ名") let description = NSPersistentStoreDescription() description.type = NSInMemoryStoreType container.persistentStoreDescriptions = [description] container.loadPersistentStores { _, error in if let error = error { print(error.localizedDescription) } } return container }() |
ただ、このように設定するより初めに紹介した /dev/nullへの書き込みの方が好ましいとの記事を見つけました。AppleのNSInMemoryStoreTypeのドキュメントが更新されていないため最新の推奨方法は不明瞭ですが、 /dev/nullを使われることをお勧めします。
また、テストにおける副作用を減らすという観点で、テストの際にはテスト用のAppDelegateを使用することも効果的です。具体的な作成方法はこちらの記事がとても参考になりました。
テスト用のAppDelegateを使用してテストコードを書き直すと以下のようになりました。
// TestAppDelegate.swift @objc(TestAppDelegate) final class TestAppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { let persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "コンテナ名") let description = NSPersistentStoreDescription() description.url = URL(fileURLWithPath: "/dev/null") container.persistentStoreDescriptions = [description] container.loadPersistentStores { _, error in if let error = error { print(error.localizedDescription) } } return container }() CoreDataManager.shared.inject(persistentContainer) return true } } // main.swift private extension UIApplication { static var isXCTesting: Bool { NSClassFromString("XCTestCase") != nil } } private func delegateClassName() -> String { UIApplication.isXCTesting ? NSStringFromClass(TestAppDelegate.self) : NSStringFromClass(AppDelegate.self) } UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, delegateClassName()) // CoreDataStorageTests.swift final class CoreDataStorageTests: XCTestCase { private let context = CoreDataManager.shared.viewContext override func tearDown() { super.tearDown() CoreDataManager.shared.deleteAllObjects() } func test_.....() { ... } ... } |
TestAppDelegate内でCoreDataの persistentContainerを設定する処理を入れることにより、各テストファイル内の setUp()で設定する必要がなくなり、共通化することができました。
さいごに
CoreDataのテストに関してご紹介しましたが、テストコードを書くことはアプリの品質を保つ上でもとても重要な要素であるため、より良いテストコードを書くために引き続き調査を続けていこうと思います。
最後まで記事を読んで頂き、ありがとうございました。
参考文献
https://www.donnywals.com/setting-up-a-core-data-store-for-unit-tests/
https://qiita.com/y-okudera/items/bf91374fdb4acfab927c
https://developer.apple.com/documentation/coredata/setting_up_a_core_data_stack
https://developer.apple.com/videos/play/wwdc2018/224/?time=1838
この記事を書いた人
- iOSエンジニアです。
最近書いた記事
- 2023.05.09iOSの証明書関連をイラストで理解する
- 2022.12.19【Swift】Widgetの作り方 〜iOS 16対応版〜
- 2022.09.26【Swift】 Combine Publisher Operatorsまとめ
- 2022.09.26【Swift】 Combineを使用するメリットについて考えてみる