月: 2022年9月

DataStoreのユニットテスト

SharedPreferencesはしばらく使えると思いますが、最近はCoroutineFlowに対応しているDataStoreで設定値を保存するようにGoogleがオススメしています。下記ようにBoolean値を書き込むDataStoreがあるとしてそのユニットテストを書いてみます。DataStoreの使い方はネットにいっぱいあるので割愛します。

class Flag(private val dataStore: DataStore<Preferences>) {
    suspend fun consume() {
        dataStore.edit { it["key"] == true }
    }

    suspend fun load(): Flow<Boolean?> =
        dataStore.data.map { it["key"] }
    }
}

val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
    name = "data_store_v1"
)

// 下記のように使う
val flag = Flag(context)

まずはCoroutineとDataStoreのユニットテストのための便利クラスを作っておきましょう。

@ExperimentalCoroutinesApi
abstract class JunitTest {
    val dispatcher = UnconfinedTestDispatcher()

    @CallSuper
    @Before
    open fun setUp() {
        Dispatchers.setMain(dispatcher)
        TestTaskExecutor.onBefore()
    }

    @CallSuper
    @After
    open fun tearDown() {
        Dispatchers.resetMain()
        TestTaskExecutor.onAfter()
    }
}

@ExperimentalCoroutinesApi
abstract class JunitDataStoreTest : JunitTest() {
    private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
    private val dataStoreName: String = "datastore_name"
    val scope = TestScope(dispatcher + Job())
    val dataStore = PreferenceDataStoreFactory.create(
        scope = scope,
        produceFile = { context.preferencesDataStoreFile(dataStoreName) }
    )
}

何も設定値を書き込んでいない状態ではflag.load()を呼び出したらnullが返ってきて、flag.consume()を実行してからflag.load()を呼び出したらtrueが返ってくるというユニットテストを書きます。

@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class FlagTest : JunitDataStoreTest() {
    private val flag = Flag(context)
    
    @Test
    fun returnNull_returnTrue() {
        scope.runTest {
            assertThat(flag.load().firstOrNull()).isNull()
            flag.consume()
            assertThat(flag.load().firstOrNull()).isTrue()
        }
    }
}

データを保存するにあたってロジックが入っている場合はこのテストコードを参考にしてロジックを含むDataStoreのテストコードが書けるでしょう。


runCatchingとResultを使って例外処理を簡単に書く

Exceptionを投げる関数を実装するときはどのように実装しますか?通常は下記のように書くことが多いのではないでしょうか?

// 成功の場合はtrueを返す、失敗の場合はfalseを返す
fun randomFunction(): Boolean {
  var outcome = false
  try {
    val response = useCase.execute()
    _liveData.postValue(response)
    outcome = true
  } catch (e: Exception) {
    outcome = false
  }
  return outcome
}

try-catchするのも良いのですが、ネストも深くなるしコードが突然catch内に飛ぶし、var変数使わないといけない場面があったりして不便ですし個人的にも嫌いですw。そういうときはrunCatchingを使います。

fun randomFunction(): Boolean {
  val result = useCase.execute()
  if (result.isSuccess) _liveData.postValue(result.getOrThrow())
  return result.isSuccess
}

runCatchingを使うとネストも深くならないし、コードも飛ばないしvarを使う場面も減らせます。runCatchingの結果はResult型で返ってきてResult::isSuccess、Result::isFailure、Result::onSuccess()、Result::onFailure{}、、Result::getOrThrow()、Result::getOrNull()、Result::exceptionOrNull()等の便利な関数も用意されております。try-catchはやめてrunCatchingを使いましょう。


AndroidGradlePlugin7.2.2、Gradle7.3.3にアップグレードした時の対応メモ

AndroidGradlePlugin7.2から7.3.3、Gradle7.1.2から7.2.2にアップデートした時の対応です。全てのパターンを網羅しているわけではなく、私が実際にやってみて対応が必要になった項目です。

下記のエラーが表示される場合

Fix the issues identified by lint, or create a baseline to see only new errors:
  android {
      lint {
          baseline = file("lint-baseline.xml")
      }
  }

AndroidLintを使っている場合にbaselineの設定が足りないので設定を追加します。該当のモジュールのbuild.gradle.ktsに下記を追加します。

android {
  lint {
    baseline = file("lint-baseline.xml")
  }
}

下記のエラーが表示される場合

> Task [モジュール]:lintDebug FAILED
Lint found 1 errors, 2 warnings. First failure:
/home/runner/work/[ファイルまでのパス]/○○UseCaseModuleTest.kt: Error: Unexpected failure during lint analysis of ○○UseCaseModuleTest.kt (this is a bug in lint or one of the libraries it depends on)

build.gradle.ktsでDaggerHiltのバージョンを2.43.2以上にします。

        object Dagger {
            private const val hiltVersion = "2.43.2"
            const val hiltAndroid = "com.google.dagger:hilt-android:$hiltVersion"
            const val hiltCompiler = "com.google.dagger:hilt-compiler:$hiltVersion"
            const val hiltAndroidTesting = "com.google.dagger:hilt-android-testing:$hiltVersion"
            const val hiltAndroidCompiler = "com.google.dagger:hilt-android-compiler:$hiltVersion"
            const val hiltAndroidGradlePlugin = "com.google.dagger:hilt-android-gradle-plugin:$hiltVersion"
        }

下記のエラーが表示される

e: /home/runner/work/[ファイルまでのパス]/○○ViewModel.kt: (557, 11): Inheritance from an interface with '@JvmDefault' members is only allowed with -Xjvm-default option

該当モジュールのコンパイルオプションに-Xjvm-default=enableを設定します。

android {
  kotlinOptions {
    freeCompileArgs = listOf("-Xjvm-default=enable")
   }
}

下記のエラーが表示される

> Task [モジュール]:lintQaDebug FAILED
/home/runner/work/[ファイルまでのパス]/SslUtils$1.class: Warning: checkClientTrusted is empty, which could cause insecure network traffic due to trusting arbitrary TLS/SSL certificates presented by peers [TrustAllX509TrustManager]
/home/runner/work/[ファイルまでのパス]/SslUtils$1.class: Warning: checkServerTrusted is empty, which could cause insecure network traffic due to trusting arbitrary TLS/SSL certificates presented by peers [TrustAllX509TrustManager]
   Explanation for issues of type "TrustAllX509TrustManager":
   This check looks for X509TrustManager implementations whose
   checkServerTrusted or checkClientTrusted methods do nothing (thus trusting
   any certificate chain) which could result in insecure network traffic
   caused by trusting arbitrary TLS/SSL certificates presented by peers.

このエラーが表示される場合はlintを無効にしますがlint自体を無効にしてしまうので設定するかはよく検討してください。私の場合はCIでのみこのエラーが表示されたのでその場合のみlintを無効にしました。通常ビルドする場合は有効にしています。

lint {
  if (System.getenv("IS_CI")?.toBoolean() == true) {
    disable += mutableSetOf("TrustAllX509TrustManager")
  }
}

アプリ起動時に下記のエラーが表示されクラッシュする

Fatal Exception: java.lang.RuntimeException: Unable to start activity ComponentInfo{[パッケージ名].app.qa/[パッケージ名].app.presentation.activity.SplashActivity}: java.lang.IllegalArgumentException: CreationExtras must have a value by `SAVED_STATE_REGISTRY_OWNER_KEY`

https://github.com/google/dagger/issues/3484

build.gradle.ktsで指定していないViewModelのバージョンを使ってしまっていると思うのでbuild.gradle.ktsでViewModelのバージョンを揃えましょう。

Versions {
  object AndroidX {
    object Lifecycle {
      private const val lifecycleVersion = "2.4.0"
      const val lifecycleViewModel = "androidx.lifecycle:lifecycle-viewmodel:$lifecycleVersion"
      const val lifecycleViewModelKtx = "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
      const val lifecycleViewModelCompose = "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion"
    }
  }
}

↑でバージョンを指定して該当モジュールのbuild.gradle.ktsにでViewModelライブラリを指定する

dependencies {
  implementation(Versions.AndroidX.Lifecycle.lifecycleViewModel)
  implementation(Versions.AndroidX.Lifecycle.lifecycleViewModelKtx)
  implementation(Versions.AndroidX.Lifecycle.lifecycleViewModelCompose)
}

AndroidGradlePluginやGradleのアップグレードは苦しむことも多いので参考に。