はじめに
こんにちは、iOSアプリ開発Gの深山です。
現在、所属プロジェクトでリアーキテクチャに取り組んでいます。その中でCoreDataのユニットテストを実装する機会があったのですが、日本語のわかりやすい記事がなかなか見つからなかったため、英語で執筆されている「Unit Testing Core Data in iOS」という記事を元にまとめてみました。
ユニットテストって?
表題に入る前に、まず「ユニットテストとはなんぞや」となっている人向けに、ユニットテストについて紹介します。
ユニットテスト(単体テスト)とは、プロジェクトを構成する比較的小さな単位(ユニット)の機能を正しく果たしているかどうかを検証するテストのことです。
「ボタンをタップするとアプリが新しい記録を作成する」という、シナリオでテストするのではなく、ボタンのタッチアップイベント、エンティティの作成、保存が成功したかどうかなど、より小さなアクションのテストを行います。
アプリのユニットテストの必要性は、以下のことが理由となります。
- アプリのUIに依存することなく、アプリのビジネスロジックのみを切り離してテストすることができる。
- リグレッションの心配なく、機能を追加したり、プロジェクトのリファクタリングを行うことができます。
テストが失敗した場合、問題箇所をすぐ見つけることができます。 - テストの時間を節約できます。3つの異なる画面をタップして入力欄にテストデータを入力したり、UIを手動で操作したりせず、アプリの任意の部分に対して小さなテストを実行できます。
ユニットテストの実装方法の詳細については、本記事のスコープ外となるため、説明を省略します。
こちらについては、AppleのドキュメントやiOS Unit Testing and UI Test Tutorialなどをご覧ください。
アクセスコントロールについて
デフォルトで、Swiftのクラスはアクセスレベルが
internalとなっています。つまり、独自のモジュール内からのみアクセスすることができます。
しかし、アプリとテストは別々のターゲットと別々のモジュールにあるため、通常、テストのモジュールからテスト対象のクラスを参照することはできません。
この問題を解決する方法は3つあります。
- アプリ内のクラスとメソッドを publicとし、テストから参照できるようにする。(もしくは openにして、サブクラス化できるようにする。)
- File Inspectorでテスト対象のクラスを追加することで、テストにコンパイルされ、テストから参照できるようにする。
- 単体テストでインポート時に @testableと記述して、インポートされたクラス内のものを参照できるようにする。
ユニットテストのための、CoreDataStackの作成
テストを行うために、まずはCoreDataStackをテスト用にセットアップします。
優れたユニットテストは、次のような「FIRST」に従います。
- Fast
テストの実行に時間があまりにも時間がかかるなら、わざわざテストを実行する必要はありません。
-
Isolated
テストを単独で実行、または、他のテストの前後で実行した場合に、正しく機能する必要があります。
-
Repeatable
同じテスト対象コードに対して、テストを実行するたびに、同じ結果が得られます。
-
Self-verifying
テスト自体が成功または失敗を報告する必要があります。ファイルやコンソールのログの内容を確認する必要はありません。
-
Timely
開発とともにこまめにテストを実装することで、速やかにバグを発見し、ロジック単位で細かく品質を担保することができます。
ユニットテストを開始すると、アプリが起動し、実行中のアプリの環境内でユニットテストが実行されます。
しかし、以下のような場合で問題が発生する可能性があります。
- 実行中のテストによってアプリの状態が影響を受ける場合
- アプリの状態によって実行中のテストが影響を受ける場合
特にCoreDataは、テストデータをディスク上のデータベースファイルに書き込むので、他のテストに影響を与える可能性があります。
他に影響を与えているので、独立(Isolated)しているとは言えません。
さらに、テストを実行する度にテストデータを蓄積していく為、再現性(Repeatable)も高くはありません。
各テストを実行する前に、データベースを手動で削除して再生成することもできますが、それは時間を要し、高速(Fast)ではありません。
これらの解決策として、CoreDataのユニットテストでは、ディスク上ではなく、オンメモリでSQLiteを使用するように設計したCoreDataStackを作成する方法があります。
これは、高速でかつ毎回クリーンな状態のデータベースを提供します。
アプリ内で使用される、ディスク上にデータを保存する形の以下のようなCoreDataStackがあるとします。
class CoreDataStack { lazy var storeContainer:NSPersistentContainer = { let container = NSPersistentContainer(name: "コンテナ名") container.loadPersistentStores { (storeDescription, error) in if let error = error as NSError? { print("Unresolved error \(error), \(error.userInfo)") } } return container }() private(set) lazy var viewContext: NSManagedObjectContext = { storeContainer.viewContext }() } |
テスト用のCoreDataStackは以下のように実装します。
final class CoreDataTestStack: CoreDataStack { override init() { super.init() let container = NSPersistentContainer(name: "コンテナ名") let description = NSPersistentStoreDescription() description.url = URL(fileURLWithPath: "/dev/null") container.persistentStoreDescriptions = [description] container.loadPersistentStores { _, error in if let error = error as NSError? { fatalError( "Unresolved error \(error), \(error.userInfo)" ) } } self.storeContainer = container } } |
上記のコードでは、CoreDataStackに次のコードが追加されています。
let description = NSPersistentStoreDescription() description.url = URL(fileURLWithPath: "/dev/null") container.persistentStoreDescriptions = [description] |
TestCoreDataStackのコンテナは、nullデバイスである
/dev/nullのファイルの場所を使用します。
これは、すべてのデータが破棄される特殊なファイルシステムの場所であり、これによりSQLiteでは、ディスクに保持されているストアではなく、メモリ内のストアを作成します。テストが終了すると、オンメモリのストアであるため、自動的にクリアされます。
CoreDataには、テストに使用できるインメモリストアタイプ NSInMemoryStoreTypeもありますが、SQLiteとインメモリストアの内部での動作には違いがあり、メモリを利用したSQLiteを使用すると、アプリの実際の動作にもっとも近いものが得られるようです。
テストのセットアップ
前項で作成した TestCoreDataStackを用いて、テストコードのセットアップを行います。
テストコードを実装するクラスに以下のプロパティを追加します。
private var coreDataStack: CoreDataTestStack! private var storage: CoreDataStorage! |
CoreDataStorageは、ディスク上のデータベースファイルに対するCRUD処理を実行することを責務として作成したクラスです。
これらのプロパティは、テスト対象の
CoreDataStorageインスタンスとCoreDataStackへの参照を保持します。
プロパティの初期化は
setUp()内で行います。
次に setUp()内を以下のように実装します。
final class CoreDataStorageTests: XCTestCase { ... override func setUp() { super.setUp() coreDataStack = .init() storage = .init( managedContext: coreDataStack.viewContext, coreDataStack: coreDataStack ) } } |
setUp()は、各テストが実行される前に呼び出されます。これは、クラス内のすべてのユニットテストに必要なリソースを作成します。
今回の場合、
coreDataStackプロパティと
storageプロパティを初期化します。
ユニットテストでは、すべてのテスト後にデータはリセットされるべきです。また、テストはIsolatedであり、Repeatableである必要があると説明しました。
インメモリストアを使用し、
setUp()で新しいコンテキストを作成すると、このリセットが自動的に実行されます。
次に、 setUp()のすぐ下に次の実装を追加します。
override func tearDown() { super.teatDown() coreDataStack = nil storage = nil } |
tearDown()は
setUp()の反対で、各テストの実行後に呼び出されます。
ここでは、すべてのプロパティを
nilにし、すべてのテストの後にCoreDataStackをリセットします。
以上がCoreDataのユニットテストのための環境構築になります。
さいごに
ユニットテストとは「FIRST」に従うべきである、というのを元に、CoreDataのテストのためのセットアップについて紹介しました。
なかなか記事が見つからず、実装に苦戦するところもありますが、アプリの品質担保のために、引き続きより良い実装を調査していこうと思います。
参考文献
この記事を書いた人
最近書いた記事
- 2023.03.30【Swift】iOS 16で画面回転禁止処理を行っている箇所で無限ループが発生しクラッシュする
- 2023.03.27SwiftLintでカスタムルールを追加してみた
- 2022.09.30【Swift】CoreDataのユニットテストの環境構築
- 2022.09.30【Swift】CoreDataの概念を知る