読者です 読者をやめる 読者になる 読者になる

やらなイカ?

たぶん、iOS/Androidアプリの開発・テスト関係。

Realm(realm-cocoa)を使うアプリをテストする #realm_jp

iOS/Android向けDBMS+ORMのRealmを利用しているアプリのテストコードの書きかた、またテストコードからIn Memory Storeで使用する方法を試してみました。

realm-cocoa のバージョンは 0.90.6 を使用しています。

テストターゲットの準備

Realmをプロジェクトに取り込む方法はいくつかありますが、今回はCocoaPodsを利用しました。このとき、下記のようにテストターゲット側にもRealm/Headersの利用を宣言する必要があります。

target 'RealmInMemoryStoreExampleTests', exclusive: true do
  pod 'Realm/Headers'
end

テスト用のRealmインスタンスを使用する

Realmのサンプルコードにあるように[RLMRealm defaultRealm]でデフォルトRealmインスタンス*1を使用すると、テストコードからの実行でもテスト対象アプリの利用するDBを使ってしまうため、これに干渉してしまいます。

また、[RMLObject allObjects]といったRealmオブジェクトを指定しないメソッドは総じて、デフォルトRealmインスタンスを使用します。

これを避け、テストコードからはデフォルトとは異なるDBを利用する方法は二通りあります。

方法1: setDefaultRealmPathでデフォルトを置き換える

プロダクトコード(テスト対象アプリ)側で[RLMRealm defaultRealm][RMLObject allObjects]などを直接利用している場合、テストコード側であらかじめ+[RLMRealm setDefaultRealmPath:]を呼ぶことで、以降のデフォルトRealmを置き換えることができます。

通常は[setUp]を下記のようにすれば良いでしょう。

- (void)setUp {
    [super setUp];

    //default realmをテスト専用のものに置き換える(テスト対象アプリのrealmファイルに干渉させないため)
    NSString *docPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject];
    NSString *realmPath = [docPath stringByAppendingPathComponent:@"test.realm"];
    [RLMRealm setDefaultRealmPath:realmPath];

    //内容をクリア
    RLMRealm *realm = [RLMRealm defaultRealm];
    [realm beginWriteTransaction];
    [realm deleteAllObjects];
    [realm commitWriteTransaction];
}

これで、以降個々のテストメソッドでは全てのオブジェクトがクリアされたクリーンなtest.realmに対して読み書きを行なうことができます。

なお、DBのクリアは意味的には「後処理」ですが、テストがエラーで落ちる等の要因で実行されないと次(次回)のテストが失敗してしまうこともあります。安定したテスト実行のために[tearDown]でなく「前処理」である[setUp]でクリアを行なっています。

方法2: テスト対象メソッドでRealmインスタンスを受け取る

メソッドの引数、もしくはRealmを扱うクラスのインスタンスフィールドにRealmインスタンスを保持し、これをテストコードから書き換え可能にしておきます。

Realmインスタンスは、デフォルトでなく[RLMRealm realmWithPath:]でファイルパスを指定して初期化できます。 プロダクトコードでは[RLMRealm defaultRealm]を利用し、テストコードからは[RLMRealm realmWithPath:]でファイル名を指定することで専用のRealmインスタンスを使用できます。

プロダクトコード

+ (Employee *)addEmployeeWithName:(NSString *)name salary:(float)salary realm:(RLMRealm *)realm{
    Employee *newEmployee = [[Employee alloc] init];
    newEmployee.name = name;
    newEmployee.salary = salary;
    newEmployee.addDate = [NSDate date];

    //引数で受け取ったRLMRealmインスタンスを使用する
    [realm beginWriteTransaction];
    [realm addObject:newEmployee];
    [realm commitWriteTransaction];

    return newEmployee;
}

テストコード

- (void)testAddEmployee {
    NSString *docPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject];
    NSString *realmPath = [docPath stringByAppendingPathComponent:@"test.realm"];
    RLMRealm *realm = [RLMRealm realmWithPath:realmPath];

    //テスト用のRLMRealmインスタンスを渡す
    [EmployeeManager addEmployeeWithName:@"John Cena" salary:2000000 realm:realm];
}

テストデータをファイルで用意して使用する

大量のデータや複雑なクエリをテストするためのデータのinsertは、テストコードで行なうよりも、あらかじめRealmのファイル形式で準備するほうが管理しやすいケースもあるでしょう。*2

これは、上記いずれかの方法でRealmファイルパスを指定してRealmインスタンスを得る場合、あらかじめ用意したRealmファイルをアプリのDocuments下に置き、そのパスを[setDefaultRealmPath:][realmWithPath:]に渡すことで実現できます。

テスト実行環境のファイルの扱いについては、拙著『iOSアプリ テスト自動化入門』の「2.4.1 テストフィクスチャをファイルでバンドルする」「2.4.3 アプリケーションデータを再現してテスト実行する」が参考になるはずです。

In Memory Storeで使用する

Realmは、ファイルでなくメモリ上だけで動作させることもできます。インメモリでの動作は、ファイルI/Oが無いぶん高速、かつ、実行ごとにクリーンな状態のRealmインスタンスを得られるため、テストコードから使うには最適です。

インメモリで動作するRealmインスタンスは、[RLMRealm inMemoryRealmWithIdentifier:]で生成できます。 こちらはデフォルトを置き換えるクラスメソッドは存在しないため、プロダクトコード側が上記「テスト対象メソッドでRealmインスタンスを受け取る」にあるようなRealmインスタンスを差し替え可能な構造である必要があります。

テストコード

- (void)testAddEmployee {
    //インメモリで動作するRealmインスタンスを生成
    RLMRealm *realm = [RLMRealm inMemoryRealmWithIdentifier:@"test"];

    //テスト用のRLMRealmインスタンスを渡す
    [EmployeeManager addEmployeeWithName:@"John Cena" salary:2000000 realm:realm];
}

余り性能は出ないという話でしたが、小さなオブジェクトを1,000件insertする*3テストでは、

  • ファイル : 0.820[sec]
  • インメモリ : 0.189[sec]

となり、1/4程度には抑えられるようです。

サンプルコード

GitHubに置いてあります。

参考資料

Realmのドキュメント

書籍

iOSアプリ テスト自動化入門

iOSアプリ テスト自動化入門

*1:実体はアプリのDocumentsディレクトリ下にdefault.realmというファイル

*2:例えば、RealmファイルはiOS/Androidで同一なので、両プラットフォーム対応の場合には共通化もできます

*3:1レコードごとに毎回commitしています