Jetpack Compose Preview時のRender issueにmockkで立ち向かう

はじめに

こんにちは。5月よりフルスタックエンジニアとしてVoicyに入社しました、たかまてぃー(@kyappamu) と申します。GW明けから始まったブログリレーの波に私も乗っからせていただきました。

今回は Jetpack Compose で Android アプリ開発をする際の Preview 表示でよく遭遇するであろう、Render issue に mockk と共に立ち向かってみたことについて書いてみたいと思います。

mockk とは

mockk.io

Kotlin 用のモックライブラリで、依存モジュールの振る舞いを変えたり、任意のレスポンスを返したりなど、ユニットテストでよく使われます。

発生したエラー

@Preview(showBackground = true)
@Composable
fun PreviewMiniPlayerView() {
    val miniPlayerViewModel = MiniPlayerViewModel()
    ・・・(略)

    MiniPlayerView(
        viewModel = miniPlayerViewModel,
        onTap = {},
        onPlayTap = {},
        onPauseTap = {},
        onRewindTap = {},
        onDragStart = {},
        onDragEnd = {},
        onDrag = {}
    )
}

上記プレビュー箇所で、以下のエラーが発生しました。

Render problem

_layoutlib_._internal_.kotlin.UninitializedPropertyAccessException: lateinit property instance has not been initialized
・・・(略)

MiniPlayerViewModel クラスの init の処理等で voiceAudioService にアクセスしているのですが、その際にこのプロパティが初期化されていないため UninitializedPropertyAccessException エラーが発生し、結果として Preview が失敗しています。

init {
        viewModelScope.launch {
            voiceAudioService.totalElapsedTimeFlow.collect { elapsedTime ->
                ・・・(略)
            }
        }
        ・・・(略)
    }
private val voiceAudioService: VoiceAudioService
        get() {
            return PlayerApplication.getInstance().getAudioService()
        }

対応内容

そもそも Preview ではレイアウトを確認したいだけなので、ViewModel のインスタンス化時の処理等々は不要であり、ここをモック化することで対処しました。 以下実装例になります。

@Preview(showBackground = true)
@Composable
fun PreviewMiniPlayerView() {
    val mockkMiniPlayerViewModel = mockk<MiniPlayerViewModel>()
    
    every { mockkMiniPlayerViewModel.viewData } returns MutableStateFlow(
        MiniPlayerViewData(
            isHidden = false,
            ・・・(略)
        )
    )

    MiniPlayerView(
        viewModel = mockkMiniPlayerViewModel,
        onTap = {},
        onPlayTap = {},
        onPauseTap = {},
        onRewindTap = {},
        onDragStart = {},
        onDragEnd = {},
        onDrag = {}
    )
}

mockk でテスト用のモックを作成し、MiniPlayerView の表示で使用する MiniPlayerViewData だけ任意の値を返すように定義しています。 これにより、init 等の処理をスキップしつつ、プレビュー表示に必要な値を返すようにし、Render issue を回避しました。

なお、通常 mockk 等のモックライブラリはユニットテスト目的で使用するために testImplementation として依存関係を追加すると思いますが、Preview で使いたい場合はそれだと import できず、implementation として依存を持つ必要があります。

implementation(libs.mockk)

ちなみに今回試した mockk のバージョンは 1.14.2です。バージョンによっては mockk<{対象クラス名}> でモックする際に、下記のエラーが出力される場合があり、その場合は mockk のバージョンを適宜変えてみてください。(mockk のバージョン 1.12.4 で発生しました。)

java. lang. ClassNotFoundException: io. mockk. proxy. jvm. dispatcher. JvmMockKWeakMap   at java. lang. ClassLoader. loadClass  at java. lang. ClassLoader. loadClass  at io. mockk. proxy. jvm. advice. jvm. WeakMockHandlersMap.<init>

おまけ

今回の対応ですが、他のモックライブラリとして有名な Mockito でも同様のことができます。 以下実装例です。

@Preview(showBackground = true)
@Composable
fun PreviewMiniPlayerView() {
    val mockMiniPlayerViewModel = Mockito.mock(MiniPlayerViewModel::class.java)
    Mockito.`when`(mockMiniPlayerViewModel.viewData).thenReturn(
        MutableStateFlow(
            MiniPlayerViewData(
                isHidden = false,
                ・・・(略)
            )
        )
    )

    MiniPlayerView(
        viewModel = mockMiniPlayerViewModel,
        onTap = {},
        onPlayTap = {},
        onPauseTap = {},
        onRewindTap = {},
        onDragStart = {},
        onDragEnd = {},
        onDrag = {}
    )
}

注意点として、Mockito で対象クラスをモックする場合、モック対象クラスやプロパティに open 修飾子を付与して継承可能とする必要があります。open を付けない状態だと、下記のような MockitoException が出力されます。

org. mockito. exceptions. base. MockitoException: Cannot mock/ spy class ・・・ Mockito cannot mock/ spy because : - final class

終わりに

mockk を使った Preview 表示を試したことで、JUnit 以外に活用の場面があることを知りました。 クラスやメソッド引数で制御したり、BuildConfig を使って制御する等の方法もありますが、プロダクションコードにそのような制御分岐が散乱するのを避けたく、今回の方法を思いついたので試してみました。 この記事が何か参考になるところがあれば嬉しいです。 ここまでお読みいただきありがとうございました!