📚

RealmのテストのTips

2023/08/08に公開

Realmのテストにおける注意点

Realmをテストするにあたって、いくつかハマるポイントがあったので備忘録として残しておきます。

1️⃣UnitTestができない問題

RealmをUnitTestでテストしようとして、RobolectricTestRunnerを使用して以下のようにセットアップしました。

@RunWith(RobolectricTestRunner::class)
class RealmUnitTest {
    private val context: Context = ApplicationProvider.getApplicationContext()
    
    @Before
    fun setup() {
        Realm.init(context)
    }
}

これを実行すると、MissingLibraryExceptionが発生します。Git issue等にも同様の問題があがっています。
これを解決する手段として、MockApplicationを使うという解決方法があるようです。

@RunWith(RobolectricTestRunner::class)
@Config(application = HiltTestApplication::class)
@HiltAndroidTest
class RealmUnitTest {
}

ただし、HiltTestApplicationと両立させて使うことはできませんでした。そこで、UIは使わないがテストを全てandroidTestに移動させました。androidTestでは、instrumentation contextを使うことができ、これを利用してMissingLibraryExceptionを回避することができます。

@HiltAndroidTest
class RealmUnitTest {
    private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
    
    @get:Rule
    var hiltRule = HiltAndroidRule(this)
    
    @Before
    fun setup() {
        Realm.init(context)
        realmConfiguration = RealmConfiguration.Builder()
            .inMemory()
            .name("test-realm")
            .allowWritesOnUiThread(true)
            .build()
        Realm.setDefaultConfiguration(realmConfiguration)
        hiltRule.inject()
    }
}

2️⃣テストデータを入れた後でもクエリ結果がexpected:<0>となる問題

この問題は、以下の2つの要因によるものでした。

  • Test時のCoroutineの動作理解不足による期待動作とのズレ
  • Realm.use関数を使っていたことによる期待動作とのズレ

現在、THIRDでのAndroidアプリのRealmの運用は、Repositoryクラスを用意してそのクラスの中でTransactionをCloseableとして処理するようにリファクタリング中です。

RealmDataSource.kt
suspend fun runCloseableTransaction(
        transaction: (realm: Realm) -> Unit
    ) =
        withContext(realmDispatcher) {
            Realm.getDefaultInstance().use { coroutineRealm ->
                coroutineRealm.executeTransactionAwait(transaction)
            }
        }

suspend関数なので、runTestで囲ってテストを実行しますが、この際コルーチンには

テストのコードを runTest でラップすると、基本的な suspend 関数をテストできるようになります。また、コルーチンの遅延がすべて自動的にスキップされるため、上記のテストは 1 秒もかからずに完了します。

という性質があります。この性質があることから、
suspend関数の実行後は advanceUntilIdle()等の処理を待つ関数を挿入する必要があります。

最後にRealm.use関数を使うときの注意点を記載します。
use関数の実装を見てみましょう。

@InlineOnly
public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    var exception: Throwable? = null
    try {
        return block(this)
    } catch (e: Throwable) {
        exception = e
        throw e
    } finally {
        when {
            apiVersionIsAtLeast(1, 1, 0) -> this.closeFinally(exception)
            this == null -> {}
            exception == null -> close()
            else ->
                try {
                    close()
                } catch (closeException: Throwable) {
                    // cause.addSuppressed(closeException) // ignored here
                }
        }
    }
}

@SinceKotlin("1.1")
@PublishedApi
internal fun Closeable?.closeFinally(cause: Throwable?) = when {
    this == null -> {}
    cause == null -> close()
    else ->
        try {
            close()
        } catch (closeException: Throwable) {
            cause.addSuppressed(closeException)
        }
}

例外が発生しなかった場合、最終的にclose()関数を読んでいることがわかります。Realmのセットアップで.inMemory()で定義している場合、close()を実行するとデータが全てなくなります。そこで、close()を実行しないようFakeRealmDataSourceを定義しました。

FakeRealmDataSource.kt
override suspend fun runCloseableTransaction(
        transaction: (realm: Realm) -> Unit
    ) =
        withContext(realmDispatcher) {
            Realm.getDefaultInstance().executeTransactionAwait(transaction)
        }

②番目のような、複数の要因で同じエラーの原因になる場合、それぞれの要因に対してABテストをしても結果が一向に改善しないので、調査に骨が折れました。。(結果ドキュメントを全て見る、内部実装まで見る等勉強にはなりますが…)どなたかの助けになれば幸いです。

株式会社THIRD エンジニアブログ

Discussion