CoreDataのマイグレーションテスト

CoreDataを使う場合、Model(テーブル定義みたいなもの)を変更すると、 マイグレーションが走る。
例えば新しく追加されたカラムについてデフォルト値をどうするかなど。

それをGUIで設定できる反面、各モデルバージョンからのマージ内容を毎回作成しないといけないので、 必ず設定し忘れがある。

マージポリシーなど個別に実装すればよいのだが、ひとまずテストを書いて、 設定漏れがないようにする。

sqlitenの作成

dataに各モデルバージョンのsqliteを置いておく

Data1.sqlite (Version1)
Data2.sqlite (Version2)

CoreDataManager

CoreDataを扱うクラス

@interface CoreDataManager : NSObject
{
    NSManagedObjectModel *managedObjectModel;       
    NSPersistentStoreCoordinator *persistentStoreCoordinator;
}

@property (nonatomic, strong, readonly) NSManagedObjectModel   *managedObjectModel;
@property (nonatomic, strong, readonly) NSManagedObjectContext *managedObjectContext;
@property (nonatomic, strong, readonly) NSPersistentStoreCoordinator *persistentStoreCoordinator;

- (NSString*)storeDirectory; // 保存用ディレクトリ

Test

  1. テスト用の保存ディレクトリ作成
  2. テスト用のSQLのコピー
  3. マイグレーションテスト実行
  4. テスト用保存ディレクトリの削除 ※GHUnit + OCmockを使っています

テスト用の保存ディレクトリ作成を行う

テスト実行前にディレクトリの作成を行う

- (NSString*)testStoreDirectory
{
    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
    NSString *basePath = ([paths count] > 0) ? [paths objectAtIndex:0] : nil;
    return [basePath stringByAppendingPathComponent:@"core_data"];
}

- (void)setUpClass {
    // Run at start of all tests in the class

    // Store用ディレクトリ作成

    NSFileManager* fileManager = [NSFileManager defaultManager];

    NSString* storeDirectory = [self testStoreDirectory];
    if (![fileManager fileExistsAtPath:storeDirectory]) {
        NSError*    error;
        BOOL ret = [fileManager createDirectoryAtPath:storeDirectory
           withIntermediateDirectories:YES attributes:nil error:&error];
        GHAssertTrue(ret, @"create save dir");
    }
}

テスト用SQLコピー

ファイル名をもらってコピーするmethodを作成

- (void)copySQLiteFile:(NSString*)filename
{
    NSString* filePath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:filename];

    NSString* storePath = [[self testStoreDirectory] stringByAppendingPathComponent:@"Data.sqlite"];

    NSFileManager* fileManager = [NSFileManager defaultManager];

    if ([fileManager fileExistsAtPath:storePath]) {
        [fileManager removeItemAtPath:storePath error:NULL];
    }

    NSError* error;
    BOOL ret = [fileManager copyItemAtPath:filePath toPath:storePath error:&error];
    if (!ret)
        GHTestLog(@"error:%@", [error localizedDescription]);
    GHAssertTrue(ret, @"copy file");
}

マイグレーションテスト実行

OCMockをつかって保存用ディレクトリを書き換えておく

- (void)testDoMigration
    NSArray* tests
    = @[
        @"Data1.sqlite",
        @"Data2.sqlite",
        ];

    for (NSString* sqliteFile in tests) {
        GHTestLog(@"sqlite file :%@", sqliteFile);

        id mock = [OCMockObject partialMockForObject:[[CoreDataManager alloc] init]];
        [[[mock stub] andReturn:[self testStoreDirectory]] storeDirectory];

        [self copySQLiteFile:sqliteFile];

        GHAssertNoThrow([mock doMigration], @"no throw");

        // マイグレーション後のデータチェックとか
    }
}

テスト用保存ディレクトリの削除

- (void)tearDownClass {
    // Run at end of all tests in the class

    // Store用ディレクトリ削除

    NSFileManager* fileManager = [NSFileManager defaultManager];

    NSString* storeDirectory = [self testStoreDirectory];
    if ([fileManager fileExistsAtPath:storeDirectory]) {
        NSError*    error;
        BOOL ret = [fileManager removeItemAtPath:storeDirectory error:&error];
        GHAssertTrue(ret, @"remove save dir");
    }
}

OCMockの使い方

使い方

mock objectの生成

クラスオブジェクトから生成

空(メソッドがない)のmockオブジェクト生成
mockは定義していないメソッドを呼ぶと例外が発生する

id mock = [OCMockObject makeForClass:[NSString class]];
定義していないメソッドを読んでも例外が発生しない
id mock = [OCMockObject niceMockForClass:[NSString class]];
インスタンスオブジェクトから生成
id aMock = [OCMockObject partialMockForObject:[NSString stringWithString:@"foo"]];
プロトコルモック
id mock = [OCMockObject mockForProtocol:@protocol(NSLocking)];
observerモック
id mock = [OCMockObject observerMock]

stubの作成

id mock = [OCMockObject mockForClass:[NSString class]];
[[mock stub] lowercaseString];

呼ばれたことを検証する

id mock = [OCMockObject mockForClass:[NSString class]];
[[mock expect] lowercaseString];

// [mock lowercaseString];

[mock verify]; // exception throw
呼ばれた順庵も検証
[mock setExpectationOrderMatters:YES];

[[mock expect] lowercaseString];
[[mock expect] uppercaseString];

戻り値を指定

任意のオブジェクトを指定
[[[mock stub] andRetugn:@"ret"] lowercaseString];
プリミティブ型

OCMOCK_VALUE(1)とやるとエラーになる

int expectedValue = 1;
[[[mock stub] andRetugnValue:OCMOCK_VALUE(expectedValue)] lenght];

引数の指定

固定オブジェクト
[[mock stub] hasSuffix:@"foo"];
任意のオブジェクト
[[mock stub] hasSuffix:[OCMArg any]];
[[mock stub] hasSuffix:OCMOCK_ANY];
可変長引数
[[mock stub] hasSuffix:@"foo"];
参照の指定
[[mock expect] completePathIntoString:[OCMArg setTo:expectedName] caseSensitive:YES 
                         matchesIntoArray:[OCMArg setTo:expectedArray] filterTypes:OCMOCK_ANY];
Nilチェック
[[mock expect] someMethod:[OCMArg isNil]]
[[mock expect] someMethod:[OCMArg isNotNil]]
オブジェクトチェック
NSString* aValue = @"bar";
[[mock expect] someMethod:[OCMArg isNotEqual:aValue]];

// selector
[[mock expect] someMethod:[OCMArg checkWithSelector:aSelector onObject:anObject]];

// block
[[mock stub] hasSuffix:[OCMArg checkWithBlock:^(id value) { return [value isEqualToString:@"foo"]; }]];

処理内容の指定

例外
NSException *exception = [NSException exceptionWithName:@"TestException" reason:@"test" userInfo:nil];
[[[mock expect] andThrow:exception] lowercaseString];

niceMockForClassにおける例外処理登録

id mock = [OCMockObject niceMockForClass:[NSString class]];

[[mock reject] uppercaseString];
通知
NSNotification *notification = [NSNotification notificationWithName:TestNotification object:self];
[[[mock stub] andPost:notification] lowercaseString];
特定の処理を呼ぶ
// selectorを使うやり方
// 
// 自分のクラスに定義済み
// - (NSString *)valueForString:(NSString *)aString andMask:(NSStringCompareOptions)mask
///
[[[mock stub] andCall:@selector(valueForString:andMask:) onObject:self] commonPrefixWithString:@"FOO" options:NSCaseInsensitiveSearch];


// ブロックを使うやり方
void (^theBlock)(NSInvocation *) = ^(NSInvocation *invocation) {
    NSString *value;
    [invocation getArgument:&value atIndex:2];
    value = [NSString stringWithFormat:@"MOCK %@", value];
    [invocation setReturnValue:&value];
};

[[[mock stub] andDo:theBlock] stringByAppendingString:[OCMArg any]];
オリジナルの処理を実行

ただしpartialMockForObjectで生成した場合のみ

[[[mock expect] andForwardToRealObject] method2];

できないこと

クラスメソッドの追加

UIButtonにimageもtextも縦に表示させる

UIButtonにimageとtextを設定すると、横に並べて表示される。 縦に表示したい場合は一手間が必要(だと思う)

何も指定せずに表示してみる

何も指定せずにimageと適すを表示すると、imageとtextを横に並べてセンタリングされる

余白調整

縦に表示するためにそれぞれの余白を調整する

  • imageの余白指定 : imageEdgeInsets
  • textの余白指定 : titleEdgeInsets

※計算するまえに文字列を設定しても問題ない

実装

今回はUIButtonのカテゴリにレイアウト調整する関数を追加

@implementation UIButton (Layout)

- (void)verticalLayout
{
    float buttonHeight = CGRectGetHeight(self.frame);
    float imageHeight  = CGRectGetHeight(self.imageView.frame);
    float textHeight   = CGRectGetHeight(self.titleLabel.frame);

    float verticalMergin = (buttonHeight - imageHeight - textHeight) / 2.0f;

    float buttonWidht = CGRectGetWidth(self.frame);
    float imageWidht  = CGRectGetWidth(self.imageView.frame);
    float textWidht   = CGRectGetWidth(self.titleLabel.frame);

    float imageHorizonMergin = (buttonWidht - imageWidht) / 2.0f;

    self.contentEdgeInsets = UIEdgeInsetsZero;
    self.imageEdgeInsets
        = UIEdgeInsetsMake(verticalMergin,
                           imageHorizonMergin,
                           verticalMergin + textHeight,
                           imageHorizonMergin - textWidht);
    self.titleEdgeInsets
        = UIEdgeInsetsMake(verticalMergin + imageHeight,
                           -imageWidht,
                           verticalMergin,
                           0);
}

ハマったところ

  • Interface Builderで設定する場合は@2xをファイル名につけると画像が拡大されて表示される
  • 文字列は自動でセンタリングされるが、imageはセンタリングされない

ReactNativeでAndroid対応する話

前提 ReactNativeでiOS版のアプリをリリースしていて、Android版をリリースする話 トラブルシューティング Build.VERSION_CODES.Q が存在しないエラー compileSdkVersionを29以上にすると解決 メモリー足りないエラー Execu...