はじめに
こんにちは。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エンジニアです。