[Android] ImageComposeでローカルの画像が表示されているか確認するユニットテストを考える

ユニットテストでDrawable画像が表示されているかを確認したい時ってあんまり無いのでしょうか。Googleから公式で用意されていないようなのでEspressoの時は自作でDrawableMatcherを作って対応していました。Composeだとどうなんだろうと調べてみるとSemanticsというのを使うらしい。

https://developer.android.com/jetpack/compose/semantics?hl=ja

「テスト フレームワークは、このプロパティを使用してノードを検出し、それを操作して、アサーションを行います。」

一応Semantics自体はテストフレームワークのプロパティを設定してアサーションしても良いことは書いてありました。Semanticsを使うとModifierの属性を追加できるようなのでImageComposeにDrawableリソースを保存できるようにします。

build.gradle.ktsに下記を追加。

implementation("androidx.compose.ui:ui-test-junit4:2.6.2") // バージョンは適宜調べてください

下記のコードを追加。

import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.junit4.ComposeContentTestRule

val DrawableId = SemanticsPropertyKey<Int>("DrawableResId")
var SemanticsPropertyReceiver.drawableId by DrawableId
fun ComposeContentTestRule.onNodeWithDrawable(drawableId: Int): SemanticsNodeInteraction =
    onNode(SemanticsMatcher.expectValue(DrawableId, drawableId))

ImageComposeに画像を設定します。

Image(
    modifier = Modifier.semantics { drawableId = R.drawable.ic_app },
    painter = painterResource(id = R.drawble.ic_app),
    contentDescription = stringResource(id = R.string.app_name)
)

ユニットテストでonNodeWithDrawable()を使ってアサーションします。

@RunWith(AndroidJUnit4::class)
class ImageComposeTest {
    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun assert_image() {
        composeRule.setContent {
            // Imageを表示するCompose
      }
    composeRule.onNodeWithDrawable(R.drawable.ic_app).assertIsDisplayed()
    }
}

https://stackoverflow.com/a/71389302

これでImageComposeがDrawableリソースのsemanticsを持っているか確認できるが、painterと同じ設定をしてしまっているし、ImageComposeのcontentDescriptionはnullableであるが設定するべきで、contentDescriptionのアサーションをすれば実質同じことなので、なんかテストとしては微妙なのかなと思います。公式でonNodeWithDrawable()を作ってくれないかなぁ。


[Compose] LocalInspectionModeを使うと@Previewが少し便利になる

Composeの@Preview便利ですよね。@Previewをを使い込んでいくと「プログラム実行時の処理と@Preview時の処理を分けたい」場面が出てきます。例えばプログラム実行時にサーバから画像を取得して表示するけど@Preview時は通信できないのでローカルの画像を表示するとします。そのときはLocalInspectionModeを使うと便利です。

https://developer.android.com/jetpack/compose/tooling/previews?hl=ja#localinspectionmode

@Composable
fun compose() {
  if (LocalInspectionMode.current) {
    // @Previewでプレビュー表示されている場合
      // ローカル画像を表示する等
  } else {
    // 通常の実行時
    // 通信して画像を表示する等
  }
}

AndroidViewだと<ImageView tools:src=”@drawable/ic_app”>みたいな感じですね。ビルドが長いアプリほど導入のメリットが大きいんではないでしょうか。


[JetpackCompose] NestedScrollの状態でLazyVerticalGridのスクロールリスナーを設定する

LazyVerticalGrid(LazyHorizontalGrid)やLazyColumnでも同様かと思いますがスクロールしたときに他のCompose(View)の表示非表示を切り替えたりしたい場合があります。スクロール状態を取得できるComposeの引数があるかなと思いましたが、少し調べるとModifierでやるようなのでメモしておきます。

val nestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            if (available.y >= 0) { /*上にスクロール*/ }
            else { /*下にスクロール*/ } 
            return Offset.Zero
        }
    }
}

LazyVerticalGrid(
    modifier = Modifier.nestedScroll(nestedScrollConnection),
    columns = GridCells.Adaptive(400.dp),
    content = {
        items(listItems) { index ->
            val item = listItems[index]
            // itemを使って表示する内容をここに書く
        }
    }
)
        

if (available.y > 0)にしてしまうとオーバースクロールでも反応してしまうのでif (available.y >= 0)にします。NestedScrollは実装がうまくいかないことも多いので調べながら少しづつやっていこう…。DroidKaigi2023でも発表している人がいたからみんな苦労している感じがする


[Android] シングルモジュールのプロジェクトでDetektによる静的解析をする

巷には多くの静的解析ツールがあります。無料でAndroidのプロジェクトを解析したい場合はDetektがおすすめです。

https://github.com/detekt/detekt

導入も簡単でした。今回はシングルモジュールの場合の導入方法を記載します。appモジュールのbuild.gradle.ktsに下記を追加します。

plugins {
  ....
   id("io.gitlab.arturbosch.detekt").version("1.23.5")
}

detekt {
    // 並列処理
    parallel = true

    // 違反があった場合failさせない
    ignoreFailures = true

    // レポートファイルのバスを相対パスにする
    basePath = rootDir.absolutePath
}

ここで一旦sync project with gradle filesを実行します。すると./gradlew detektが実行できるようになっているのでターミナルで実行します。Detektの結果はモジュール内のbuild/reports/detekt内に出力されます。detekt.html, detekt.md, detekt.sarif, detekt.txt, detekt.xmlがあるのであとは煮るなり焼くなり…


[Kotlin] Parameterizedテストを書く

ユニットテストを充実させていくと小さい関数のテストが必要になってきます。例えば下記のような拡張関数がある場合

fun Int.toBoolean(): Boolean = this == 1

Int型の変数が1の場合のみtrueでそれ以外はfalse。ParameterizedテストはJunitの機能で記述できます。

import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized

@RunWith(Parameterized::class)
class IntTest(
    private val cate: String,
    private val input: Int,
    private val expected: Boolean
) {
    @Test
    fun intToBoolean() {
        val actual = input.toBoolean()
        assertThat(actual).isEqualTo(expected) // junitのみで書きたい場合はassertEquals()でも良い
    }

    companion object {
        @Parameterized.Parameters(name = "{0}")
        @JvmStatic
        fun data() = listOf(
            arrayOf(
                "0の場合はfalse",
                0,
                false
            ),
            arrayOf(
                "1の場合はtrue",
                1,
                true
            ),
            arrayOf(
                "2の場合はfalse",
                2,
                false
            )
        )
    }
}

private val case: Stringはテストの内容を記述します。@Test内では利用されていませんが、実行結果やエラー時に表示されるので書いておくことをおすすめします。

caseを書かない場合

Parameterizedテストを書けるようにしておくとAndroidのコードから切り出したif文が正しく動作するか、data class内の関数が動作するかが確認できるようになります。


HTTP Toolkitを使って通信内容を書き換える

HTTP/HTTPSで通信するアプリで動作確認等をしたりする場合、環境によってはFakeモジュールで固定の通信結果を返したり、ローカルPCにサーバを立てて通信内容を確認/返却したり、色々な方法があると思いますがHTTP Toolkitを使うとVPNを使って通信内容を書き換えることができ、動作確認等に便利です。GoogleでHTTP Toolkitで検索しても日本語の検索結果があまり出てこないので使い方だけ覚書しておきます。今回はmac版で導入します。

https://httptoolkit.com

Download free nowをクリックします。

自動でダウンロードされない場合はClick hereを押下します。

ダウンロードしたdmgファイルを開きます。

HTTP Toolkit.appをApplicationsフォルダに移動します。(普通にインストールします)

HTTP Toolkitを実行します。

まずはアプリがインストールされている通信内容を確認しましょう。ADBが使われるのでUSBデバッグ可能な状態でAndroid端末をmacにUSB接続します。InterceptタブにあるAndroid Device via ADBをクリックします。

HTTP ToolkitはVPNを利用して通信内容をインターセプトするのでOKを押します。

CA証明書をインストールする必要があるのでそのままOKを押します。

そのままOKを押します。

Connectedと表示されれば成功です。

試しにTest interceptionを押すとViewタブで通信内容を確認できます。

通信内容を書き換える場合は書き換えたいURLの一部をコピーするなり文字列入力できるようにしてMockタブを開きます。

今回はすべてのリクエストを対象にしたいのでAny requestsを選択し、FOR URLS MATCHINGでextensionsと入力します。このようにするとリクエスト先URLにextensionsが含まれる通信に対してインターセプトできます。忘れずに+ボタンを押します。レスポンスの内容を書き換えたいのでPause the response to manually edit itを選択します。リクエストを書き換えたい場合はPause the resquest to manually edit itを選択します。最後に右上のSave changesを押すと設定が適用されます。+ボタンとSave changesボタンは忘れがちなので必ず押してください。

その後、Match:のルールに書いたリクエストが有効になるような操作をAndroid端末で行います。するとViewタブでインターセプト中である画面に切り替わります。

今回はレスポンスを書き換えたいのでRESPONSE BODYの内容を書き換えてResumeボタンを押すと書き換えたレスポンスがAndroidアプリに送られます。(Mockタブでリクエストを書き換える設定にしている場合はREQUEST BODYの内容を書き換えます)ステータスコード401,402とかも発生されるのが難しいですがHTTP Toolkitで簡単に確認できます。

使い方は簡単ですが、いくつかの注意点があります。

  • Mockタブで+ボタンやSave changesボタンを押し忘れることが多い(入力したら自動でインターセプトしてほしい)
  • 無料版ではMockタブの設定内容はアプリ終了と共に削除されるので毎回入力しないといけない
  • アプリ側で通信タイムアウトする前に操作しないといけないのでコピー&ペーストするなり、素早い操作が必要になる
  • Android端末で2重VPNを使うことができないのでリモートワーク等で会社のVPNに繋ぎつつ、HTTP Toolkitと併用することができない。(ホストPC側で会社のVPNに繋いでエミュレータでHTTP Toolkitを使うことは可能です)
  • Android14は2024年1月時点で利用できません。https://httptoolkit.com/blog/android-14-breaks-system-certificate-installation/#enter-android-14

mockkで直接モックできない場合の対応方法

DBに時刻を保存したりログを送信したりするときに現在時刻をSystem.currentTimeMillis()を実行して現在時刻を取得します。mockkでモックする場合はスタティックモックで実行しようとすると正しくモックしてくれません。

fun returnTime() {
  mockkStatic(System::class)
  every { System.currentTimeMillis() } returns 1L
  assertEquals(System.currentTimeMillis(), 1L) // StackOverflowErrorが発生し失敗する
}

System.currentTimeMillis()はシステムのネイティブメソッドなのでモックすることができません。このような場合はobjectでラップしてモックできるようにします。

object SystemTime {
  fun current() = System.currentTimeMillis()
}

fun returnTime() {
  mockkObject(SystemTime)
  every { SystemTime.current() } returns 1L
  assertEquals(1L, SystemTime.current())
}

とにかくobjectでラップしてしまえば他の場合でも使えそうですね。

https://github.com/mockk/mockk/issues/98


KAPTからKSPに移行したときのメモ

https://developer.android.com/studio/build/migrate-to-ksp?hl=ja

ルートのbuild.gradle.ktsの一番下に下記を追加する

plugins {
    id("com.google.devtools.ksp") version "1.9.20-1.0.14" apply false
}

“1.9.20-1.0.14″のハイフンの左側はkotlinのバージョンと一致しています。kotlinのバージョンに合わせてどのバージョンが必要かgithubから確認します。

https://github.com/google/ksp/releases

kaptをkspに変更する

id("kotlin-kapt")
↓
id("com.google.devtools.ksp")

kapt("xxxxxx")
↓
ksp("xxxxx")

下記を削除
kapt {
    correctErrorTypes true
    useBuildCache true
}

必要最低限の変更はこれだけですが、プロジェクトによっては関連ライブラリのバージョンアップも必要になるのでそれぞれ確認する。例としてDagger、Room、Hilt、HiltWorker、Moshi、Epoxy等

kaptからkspにするとビルド速度が早くなるって書いてある記事があったけど、あまり変わらないですね…。大規模プロジェクトだと数十秒早くなるのだろうか。


Composeのプレビューをちょっと便利にする

Composeのプレビューを表示しておくとアプリをビルドしなくても大体の見た目を確認できるので便利です。しかし普通に@Previewアノテーションだけつけてもプレビューは一つしか表示されません。複数のプレビューを表示したい時はPreviewParameterProviderを使うと2個以上のプレビューを表示できます。

import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter

internal class AlertDialogPreviewParameterProvider : PreviewParameterProvider<AlertDialogPreviewParameterProvider.AlertDialogPreviewParameter> {
    data class AlertDialogPreviewParameter(
        val iconRes: Int?,
        val iconDescription: String?
    )

    override val values: Sequence<AlertDialogPreviewParameter>
        get() = sequenceOf(
            AlertDialogPreviewParameter(null, null),
            AlertDialogPreviewParameter(android.R.drawable.ic_delete, "iconDescription"),
        )
}

@Preview
@Composable
internal fun AlertDialogPreview(
    @PreviewParameter(AlertDialogPreviewParameterProvider::class) parameter: AlertDialogPreviewParameterProvider.AlertDialogPreviewParameter,
) =
    AlertDialog(
        iconRes = parameter.iconRes,
        iconDescription = parameter.iconDescription,
        title = "title",
        text = "text",
        dismissText = "dismissText",
        confirmText = "confirmText",
        onDismissRequest = {},
        onConfirmation = {},
    )

@Composable
fun AlertDialog(
    @DrawableRes iconRes: Int? = null,
    iconDescription: String? = null,
    title: String,
    text: String,
    confirmText: String,
    dismissText: String,
    onDismissRequest: () -> Unit,
    onConfirmation: () -> Unit,
) {
    // iconResとiconDescriptionはnullの場合に表示が異なるAlertDialog
}

これで2個以上のプレビューがAndroidStudio上に表示されます。ちなみにkoverでカバレッジを計測している場合は@Previewの関数やPreviewParameterProviderを継承しているクラスがカバレッジ対象のコードとして判定されてしまいますが、下記のような設定をbuild.gradle.ktsに記載すればカバレッジ対象から外すことができます。

koverReport {
    filters {
        excludes {
            classes(
                // PreviewParamterProviderというクラス名を除外する
                "*PreviewParameterProvider*",
            )
            // @Previewアノテーションがついている関数を除外する
            annotatedBy("androidx.compose.ui.tooling.preview.Preview")
        }
    }
}

setHasOptionsMenu()、onCreateOptionsMenu()、onOptionsItemSelected()あたりのdeprecated対応でMenuProviderに置き換える

メニュー関連の関数がdeprecatedになったので対応しないといけなくなりました。

https://developer.android.com/reference/androidx/core/view/MenuProvider

変更前

class MyFragment : Fragment() {
    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
        super.onCreateOptionsMenu(menu, inflater)
        inflater.inflate(R.menu.fragment_my, menu)
        // menuの初期化処理
    }

    override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
        when (menuItem) {
            R.id.menu1 -> {
                // menu1が押されたときの処理
            }
        }
        return super.onOptionsItemSelected(menuItem)
    }
}

変更後

class MyFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        setupMenus()
    }

    private fun setupMenus() {
        (requireActivity() as MenuHost).addMenuProvider(object : MenuProvider {
            override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
                menuInflater.inflate(R.menu.fragment_my, menu)
                // menuの初期化処理
            }

            override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
                when (menuItem.itemId) {
                    R.id.menu1 -> {
                        // menu1が押されたときの処理
                    }
                }
                return true // 押下したイベントを消費する場合はtrue、それ以外の場合はfalse
            }
        }, viewLifecycleOwner, Lifecycle.State.RESUMED)
    }
}

MenuProviderにはonPrepareMenu()とonMenuClosed()があるのでもう少し細かい制御が必要な場合はこのあたりを使うと良いかも。