目次

目次

【Swift】 CoreDataのテスト方法を考えてみる

アバター画像
神山義仁
アバター画像
神山義仁
最終更新日2022/08/22 投稿日2022/08/22

はじめに

こんにちは。iOSアプリ開発グループの神山です。

最近CoreDataを触る機会が増えているのですが、テストについての記事がなかなか見つからず苦戦したため自分なりにまとめてみました。

今回はCoreDataのテストについて焦点を当てて進めていくため、CoreDataの概要などについては省略させていただくことをご了承ください。

下準備

テストを行うための下準備を行なっていきます。

はじめにデータモデルを定義していきます。 今回は namepriceのプロパティを持った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の処理を行っていきます。

Overview.png

Persistent container(NSPersistentContainer)のインスタンスが保持するもの

  1. Model(NSManagedObjectModel) → アプリで定義したモデルエンティティ(ここではFruit)
  2. Context(NSManagedObjectContext) → モデルエンティティのインスタンスの変更を追跡
  3. 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エンジニアです。

目次