Retty Tech Blog

実名口コミグルメサービスRettyのエンジニアによるTech Blogです。プロダクト開発にまつわるナレッジをアウトプットして、世の中がHappyになっていくようなコンテンツを発信します。

RettyにおけるComposeのUIテスト活用:ユーザー行動ログを守るテストコードの紹介

Rettyアプリチームの若田@wakanao_bananaです!暖かくなったと見せかけて急に寒くなる東京のツンデレ具合にキュンとし始めたこの頃です(いやツンデレというよりデレツンでしょうか)

この記事では、RettyにおけるComposeのUIテスト活用の事例をサンプルを用いながら解説をします🫡

はじめに

RettyではGoogle Analyticsを活用してユーザー行動の分析を行っています。このユーザー行動の分析は今後の施策を考えるうえで非常に重要な指標となります。 そのため、適切なタイミングでログが確実に送信されていることを保証することは、開発チームにとっての責務のひとつです。

ただこれまではログの追加が漏れていたり、コードに手が入った際に既存のコードを消してしまいログが送られなくなってしまうことなどが度々問題となっていました。

このようなログの抜け漏れは人の注意力だけでは防ぎきれないため、RettyのAndroidアプリでは ComposeのUIテストを用いて、ログが発火していることを自動的に検証できる仕組みを導入しました。 現在では新たにログを追加する際には対応するテストを実装しており、CI上で常にテストが実行されるようになっています。これにより、仮にログがデグレで抜けてしまった場合でもCIの段階で検知することが可能です。

RettyのAndroidアプリのCI体制についてはこちらをご覧ください

engineer.retty.me

RettyでのCompose化の状況

10年以上運用されてきたRettyのAndroidアプリですが、現在のRettyではコツコツとリファクタリングを進め主要な画面はほとんどComposeで作成されています。そのためAndroidViewを見る機会は非常に少なくなっており、分析を行いたい画面では比較的容易にComposeのテストが書ける状態になっています🫡

Composeで構成されている主要な画面

それでは例を用いてどのようにRettyではログのテストを実装しているのかみていきましょう

ログ実装~テストまで

主な使用ライブラリ

想定する画面とログ

今回は店名とクリックボタンのみのシンプルな画面を想定します

想定する画面

public data class HogeUiState(
    val restaurantName: String,
    val listener: Listener,
) {
    @Immutable
    public interface Listener {
        public fun onClick()
        public fun onResume()
    }
}

@Composable
public fun HogeScreen(
    modifier: Modifier = Modifier,
    uiState: HogeUiState,
) {
    LifecycleResumeEffect(Unit) {
        uiState.listener.onResume()
        onPauseOrDispose {  }
    }
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center,
    ) {
        Column(
            modifier = Modifier,
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            Text(text = uiState.restaurantName)
            Spacer(modifier = Modifier.height(16.dp))
            Button(
                onClick = { uiState.listener.onClick() },
            ) {
                Text(text = "Click Me")
            }
        }
    }
}
class HogeScreenViewModel() : ViewModel() {
    private val _uiState = MutableStateFlow(
        HogeUiState(
            restaurantName = "Rettyレストラン",
            listener = object : HogeUiState.Listener {
                override fun onResume() {}
                override fun onClick() {}
            },
        ),
    )
    val uiState: StateFlow<HogeUiState> = _uiState.asStateFlow()
}

こちらの画面で次のログを取得したいと想定します

  • 画面が表示されたこと(PageViewログ)
  • ボタンがクリックされたこと(Clickログ)

続いて想定したログを定義し発火させます

ログの実装

ログのラッパークラスを作成

Google Analytics へのログ送信処理をラップするクラスを作成します。このようにラッパークラスを用意することで、テスト時にはモックとして差し替えが可能になり、ログ発火の検証を容易に行えるようになります。

class HogeScreenAnalytics(
    private val trackUseCase: AbstractTrackLogUseCase, // 実際にGoogleAnalyticsにログを送る責務を担うクラス。今回は説明を省略
) {
    fun onPageView() {
        // keyをログとして送る。
        trackUseCase.track(
            trackingEntity = TrackingEntity.Hoge100(),
        )
    }

    fun onClickButton() {
        trackUseCase.track(
            trackingEntity = TrackingEntity.Hoge101(),
        )
    }
}

ViewModel側でログを呼び出す

ViewModelに先ほど作成したAnalyticsクラスを注入して、画面のライフサイクルやユーザー操作に応じて適切なログが発火するよう実装をします

class HogeScreenViewModel(
    private val analytics: HogeScreenAnalytics,
) : ViewModel() {
    private val _uiState = MutableStateFlow(
        HogeUiState(
            restaurantName = "Rettyレストラン",
            listener = object : HogeUiState.Listener {
                override fun onResume() {
                    analytics.onPageView() // onResume時にpageViewのログを発火させる
                }

                override fun onClick() {
                    analytics.onClickButton() // クリック時にクリックログを発火させる
                }
            },
        ),
    )
    val uiState: StateFlow<HogeUiState> = _uiState.asStateFlow()
}

ここまでがログ自体の実装になります。続いてログが発火するかをテストで確認していきます

ComposeのUIテストを実装

下準備:UiElementを定義してComposeのtestTagに埋め込む

UIの要素をテストで特定するためのタグを作成します。

public enum class HogeUiElement {
    Root,
    Button,
    ;

    public val testTag: String = this::class.java.name + "#" + name // 一意の文字列を生成
}

作成した HogeUiElement.XX.testTagをModifierのtestTagに埋め込みます。

@Composable
public fun HogeScreen(
    modifier: Modifier = Modifier,
    uiState: HogeUiState,
) {
    Box(
        modifier = Modifier
            .testTag(HogeUiElement.Button.testTag) // タグを埋め込む
            .fillMaxSize(),
        contentAlignment = Alignment.Center,
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            Text(text = uiState.restaurantName)
            Spacer(modifier = Modifier.height(16.dp))
            Button(
                modifier = Modifier.testTag(HogeUiElement.Button.testTag), // タグを埋め込む
                onClick = { uiState.listener.onClick() },
            ) {
                Text(text = "Click Me")
            }
        }
    }
}

これにより、テストでタグを埋めた要素を見つけ、画面の表示やクリックなどの動作も確認することができるようになります

ComposeのUIテスト実装

テストの目的は画面の表示およびユーザー操作(クリック)をシミュレートし、対応するログメソッドが適切に発火されることを検証することです。

テストの流れは以下のようになります

  1. モック化したAnalyticsクラスをViewModelに注入し、ログ発火処理を観測可能な状態にする
  2. ViewModelのuiStateをComposeに渡し、対象の画面を描画する
  3. テストタグを利用してUI要素を特定し、表示やクリックなどのUI動作を検証する
  4. ログメソッド(onPageView, onClickButton)が期待通りに呼び出されていることをモックのverifyで検証する

全体のテストコードはこのようになります

// Robolectricテストの実行環境を設定
@Config(
    sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM],  // Android 15環境でテストを実行
    application = MockApplication::class,  // テスト用の軽量なアプリケーションクラスを使用
    qualifiers = "w400dp-h680dp",          // テストデバイスの画面サイズを指定
)
@RunWith(RobolectricTestRunner::class)  // RobolectricでAndroidフレームワークをエミュレート
class HogeAnalyticsTest {
    // コルーチンのテスト用設定
    private val testDispatcher = UnconfinedTestDispatcher()
    private var testScope = TestScope(testDispatcher)

    // Jetpack Composeのテスト用ルール
    // UIコンポーネントのレンダリングやインタラクションのテストを可能にする
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun `Hoge画面で定義したログが呼ばれる`() = testScope.runTest {
        // 1. テストの準備
        // モックのアナリティクスを作成
        val analytics = mockk<HogeScreenAnalytics>(relaxed = true)
        // テスト対象のViewModelを作成
        val viewModel = HogeScreenViewModel(
            analytics = analytics,
        )

        // 2. テスト対象の画面を表示
        // ComposeのテストルールにUIを設定
        composeTestRule.setContent {
            HogeScreen(
                uiState = viewModel.uiState.collectAsState().value,
            )
        }

        // 3. テストの実行と検証
        // roborazziでUIテストの実行過程をGIFアニメーションとして記録する
        composeTestRule.onRoot().captureRoboGif(composeTestRule, "build/test.gif") {
            // 3-1. 画面表示時のページビューログを検証
            // Rootノードの存在を確認(画面が表示されていることを確認)
            composeTestRule.onNode(
                hasTestTag(HogeUiElement.Root.testTag),
            )
            // onPageView()が1回だけ呼ばれたことを検証
            verify(exactly = 1) {
                analytics.onPageView()
            }

            // 3-2. ボタンクリック時のログを検証
            // ボタンを見つけてクリックを実行
            composeTestRule.onNode(
                hasTestTag(HogeUiElement.Button.testTag),
            ).performClick()
            // onClickButton()が1回だけ呼ばれたことを検証
            verify(exactly = 1) {
                analytics.onClickButton()
            }
        }
    }
}

テストを実行し成功することを確認しました🎉

テストが通ることを確認🎉

ちなみにRoborazziで撮影したgifはテスト上で対象のUIが表示されているかを目視で確認するために使用しています

Roborazziで撮影したgif

さいごに

以上がRettyのAndroidで活用されているテストの事例でした! こちらを導入してからはしっかりとログの漏れを防ぐことができるようにもなり、よりテストが書きやすいコードの設計が意識されるようになりました👍 今後も仕組みで解決する安心安全な開発を心がけていきたいです!