Robolectricを使ってActivityのユニットテストを書いてみた
こんにちは、くふうAIスタジオのひじかたです。
トクバイというチラシアプリのAndroid版の開発に携わっています。
今回は、Robolectricを使ったユニットテストを導入する際に躓いた箇所と解決法を備忘録としてまとめました。
最近テストを書くようになったテスト初心者ですので、記事中に間違っている箇所があればコメントなどでご指摘いただけると幸いです。
きっかけ
トクバイアプリでは、チラシなどのコンテンツの閲覧ログを送っていて、サービスの改善に活用しています。
しかし最近、アプリの小規模なリファクタリングを行った際に意図せずログの送信部分に関わる変更が入ってしまい、一部のログが欠損してしまうという事がありました。
これまでログの送信確認は手動で行っていましたが、手間も時間もかかる為、より手軽にログの送信確認が出来るように閲覧ログの送信を確認するテストを書く事にしました。
手動で閲覧ログの確認を行う際は、実際に画面を表示した上で、閲覧ログが送信されているかを確認しています。
なので最初は、自動テストを行う際も実際に画面を表示した上で確認出来るようにインストルメンテーションテストを導入しました。
しかし、インストルメンテーションテストは実機やエミュレーター上で動かすテストなので、忠実度が高い代わりに実行速度が遅くなります。
そこで、JVM上で動かせて軽量なRobolectricを使ってテストを書いてみる事にしました。(Robolectric使ってみたかったという理由もあります。)
実際に書いてみる
とりあえずassert(true)が通るようにする
Robolectric公式のセットアップガイドに従って build.gradle.kts に必要な依存関係を記述します。
実際はバージョンカタログを使っていたりしてややこしいので、Robolectric公式のセットアップガイドから引用したコードを貼っておきます。
android {
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.13")
}
build.gradle.kts を更新したら、プロジェクトとgradleファイルを同期して、最初のテストコードを書いてみます。
/* import等は一部省略 */
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.core.app.launchActivity
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ShopAnnouncementActivityTest {
@Test
fun firstTest() {
val intent = ShopAnnouncementActivity.createIntent(
context = ApplicationProvider.getApplicationContext(),
shopId = 1000,
announceId = 2000,
fromTop = false,
)
launchActivity<ShopAnnouncementActivity>(intent = intent).use { activityScenario ->
// ここでactivityScenarioを使う
}
assert(true)
}
}
実行してみると、以下の様なエラーが出ました。
java.lang.IllegalStateException: Hilt Activity must be attached to an @HiltAndroidApp Application.
トクバイはHiltを使ってDIしています。
なのでHilt関連の設定も行う必要があるのです。
めっちゃわかりやすい記事があったのでそれと公式ドキュメントを参考にコードを修正します。
修正後のコードは以下の通りです。
/* import等は一部省略 */
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.core.app.launchActivity
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import io.mockk.mockk
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@HiltAndroidTest
@Config(application = HiltTestApplication::class)
@RunWith(AndroidJUnit4::class)
class ShopAnnouncementActivityTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@BindValue
val navigationProvider: ShopAnnouncementNavigationProvider = mockk(relaxed = true)
@BindValue
val getShopUseCase: GetShopUseCase = mockk(relaxed = true)
@BindValue
val getShopAnnouncementUseCase: GetShopAnnouncementUseCase = mockk(relaxed = true)
@BindValue
val getBargainProductsForShopAnnouncementUseCase: GetBargainProductsForShopAnnouncementUseCase =
mockk(relaxed = true)
@BindValue
val getSubscribedShopsUseCase: GetSubscribedShopsUseCase = mockk(relaxed = true)
@Before
fun setUp() {
coEvery { getShopUseCase(any(), any(), any()) } returns ShopModel.createDummy(id = 1000)
coEvery { getShopAnnouncementUseCase(any(), any()) } returns mockk(relaxed = true)
coEvery { getBargainProductsForShopAnnouncementUseCase(any()) } returns mockk(relaxed = true)
coEvery { getSubscribedShopsUseCase(any()) } returns listOf(
SubscribedShop(1000, ShopModel.createDummy(id = 1000)),
)
}
@Test
fun firstTest() {
val intent = ShopAnnouncementActivity.createIntent(
context = ApplicationProvider.getApplicationContext(),
shopId = 1000,
announceId = 2000,
fromTop = false,
)
launchActivity<ShopAnnouncementActivity>(intent = intent).use { activityScenario ->
// ここでactivityScenarioを使う
}
assert(true)
}
}
HiltのDIについての解説
基本的には全部 Android Developers のHiltテストガイドに書いてますが、今回使ってる部分の説明だけしておきます。
@HiltAndroidTest
このアノテーションを付けることで各テストにHiltコンポーネントを生成させます。
@get:Rule
val hiltRule = HiltAndroidRule(this)
このルールはコンポーネントの状態管理とインジェクションのために必要です。
ルールが複数ある場合は @get:Rule(order = 0) を指定して、HiltAndroidRule が最初に実行される様にする必要があります。
Robolectricを使っている場合はテストアプリの指定も必要で、
@Config(application = HiltTestApplication::class)
でHiltが提供している HiltTestApplication を指定しました。
DIする依存関係は、@BindValueアノテーションを使って以下の様な感じで提供出来ます。
@BindValue
val getShopUseCase: GetShopUseCase = mockk(relaxed = true)
@BindValueの詳細についてはHiltのリファレンスが参考になると思います。
今回は ShopAnnouncementActivity と、 ShopAnnouncementActivity 内で使っている ShopAnnouncementViewModel で使っている依存関係を用意しました。
@BindValue を使うとプロパティとしてアクセス出来るので、以下の様にmockkなどを使って簡単にモックが出来ます
coEvery { getShopUseCase(any(), any(), any()) } returns ShopModel.createDummy(id = 1000)
この方法はテストケース毎に任意のデータを返せて便利なので好きです。
他にもモジュールをテスト用のモジュールに置き換えるやり方もあります。
今回は試していませんが、Hiltテストガイドに詳細が載っています。
ActivityScenarioについて解説
最初に書いたコードから何も説明せず使っている ActivityScenario についてもせっかくなので解説しておきます。
こちらも詳しくはリファレンスに載ってるのですが、アクティビティの Lifecycle.State を変更するときに、正しい順番でライフサイクルメソッドを呼び出してくれるみたいです。
Activity が作られて Lifecycle.State.RESUMED になる時に onCreate() 、 onStart() 、 onResume() を正しい順番で呼び出してくれるみたいな感じですね。
似たようなものがRobolectricにもありますが、リファレンスに ActivityScenario を使ってねと書かれています。
private lateinit var activityScenario: ActivityScenario<HogeActivity>
@Before
fun setUp() {
activityScenario = launchActivity<HogeActivity>()
}
@After
fun tearDown() {
activityScenario.close()
}
こんな感じで使えます。
テスト終了後もActicityが実行されたままになることがあるらしいので activityScenario.close() でリソースを解放の解放を行なっています。
@After あたりで呼び出すと良いです。
もしくは use を使って自動で解放する方法もあります。(今回のコードでやってるやつです。)
@Test
fun hogeTest() {
launchActivity<HogeActivity>().use { activityScenario ->
// ここでactivityScenarioを使う
}
}
テスト毎に違うIntentを使いたい場合は特にこの方法の方が良いと思います。
launchActivity() を呼び出した直後は、Activityは基本的には RESUMED になっています。
("基本的には"と言っているのは、 onCreate() で finish() とかを呼び出したりすると DESTROYED になったりするからです。)
ログが送られているかテストする
とりあえずActivityを生成した上で asert(true) が通る様になったので、本来の目的であるログ送信のテストを書きます。
まずはダミーのロガーをセットアップしてくれるルールが既存であるので追加して
@get:Rule(order = 1)
val loggerRule = DummyPureeLoggerRule()
テストケースを良い感じに書きます。
@Test
fun `ShopAnnouncementActivityで閲覧ログが送信される`() {
val intent = ShopAnnouncementActivity.createIntent(
context = ApplicationProvider.getApplicationContext(),
shopId = 1000,
announceId = 2000,
fromTop = false,
)
launchActivity<ShopAnnouncementActivity>(intent = intent)
.use { /* ActivityがRESUMEDになれば良いので Nothing TO DO */ }
val pvLog = loggerRule.logger.sentJsonHistories.filter {
it != null &&
it.contains(
"""
"key":"value"
""".trimIndent(), // 記事なのでダミーです。本当は実際に送るログが書かれています。
)
}
assert(pvLog.size == 1) // ログは1回だけ送られて欲しいので`pvLog.size == 1`をアサートする
}
無事に通りました🥳
まとめ
記事にしてみると意外と簡単だった気がしますが、やってる最中は色々躓きました。
記事にまとめきれなかったんですが、使ってないはずの依存関係の解決をしろというエラーが出てきて、色々調べてみたらUseCaseモジュールがとあるfeatureモジュールに依存していたのでリファクタしたりもしました。
[Dagger/MissingBinding] hogehoge cannot be provided without an @Inject constructor or an @Provides-annotated method.
使っているはずの依存関係は全部用意した筈なのにこんな感じのエラーが出ているという人は、一度モジュールの依存関係を見直してみても良いかも。
また、今回はActivityを起動しただけですが、Espresso などど組み合わせるとActivityの操作なども出来るので、UIテストも書けるようです。
試したらまた記事にしたいと思います。
参考にさせていただいた記事
宣伝
くふうAIスタジオでは、採用活動を行っています。
当社は「AX で 暮らしに ひらめきを」をビジョンに、2023年7月に設立されました。
(AX=AI eXperience(UI/UX における AI/AX)とAI Transformation(DX におけるAX)の意味を持つ当社が唱えた造語)
くふうカンパニーグループのサービスの企画開発運用を主な事業とし、非エンジニアさえも当たり前にAIを使いこなせるよう、積極的なAI利活用を推進しています。
(サービスの一例:累計DL数1,000万以上の家計簿アプリ「Zaim」、月間利用者数1,600万人のチラシアプリ「トクバイ」等)
AXを活用した未来を一緒に作っていく仲間を募集中です。
ご興味がございましたら、以下からカジュアル面談のお申込みやご応募等お気軽にお問合せください。
https://open.talentio.com/r/1/c/kufu-ai-studio/homes/3849