【Android + UIテスト】Hilt Moduleの一部バインディングをモックする


Hilt で Dependency Inject (DI)

みなさん Hilt 使ってますか? 手動による DI に比べコーディングが楽&テスト時のモックした依存との置換が楽、などメリット沢山です! まだの人は今すぐ始めよう
[Android Developer] Hilt を使用した依存関係の注入

UIテストで依存の置換

テストの基本的な方法はここでは説明しません。詳細は以下の記事が詳しいです。

[Android Developer] Hilt テストガイド
[Dagger] Hilt - Testing

状況の設定

次のようなMainViewModelと依存関係があるとします。

MainViewModel.kt
@HiltViewModel
class MainViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val repository1: Repository1,
    private val repository2: Repository2,
) : ViewModel() {
    // some implementation
}

依存 Repository1, Repository2はいずれもinterfaceとして定義されており、次のような Hilt Module から DI されるとします。

Repository.kt
interface Repository1 {
    // some definition
}

// ここではApplicationを通して同一インスタンスをバインディングするようスコープを設定する
@Singleton 
class Repository1Impl @Inject constructor(
    @ApplicationContext context: Context
) : Repository1 {
    // some implmentation
}
RepositoryModule.kt
@Module
@InstallIn(SingletonComponent::class)
interface RepositoryModule {

    @Binds
    fun bindRepository1(impl: Repository1Impl): Repository1

    @Binds
    fun bindRepository2(impl: Repository2Impl): Repository2

    // other bindings
}

一部バインディングを置換

テストのためRepository2のみモックしたいとします。テストクラスではモックしたインスタンスをDIするよう@BindValueでHiltに指示します。そのままではバインディングがRepositoryModule@Bindsと重複してしまうので@UninstallModulesでモジュールの方を無視するよう指示を追加します。

SomeActivityTest.kt
@UninstallModules(RepositoryModule::class)
@HiltAndroidTest
@LargeTest
@RunWith(AndroidJUnit4::class)
class SomeActivityTest {

    @Rule
    @JvmField
    val rule: RuleChain = RuleChain.outerRule(HiltAndroidRule(this))
        .around(ActivityScenarioRule(SomeActivity::class.java))

    // Mockk でテスト用にモックしたインスタンス
    @BindValue
    val repository2 = mockk<Repository2>(relaxed = true)

    @Before
    fun setup() {
        // some setup code which must be run before each test unit
    }

    @Test
    fun someTest() {
        // test code here
    }
}

しかし同時に Repository1等の他バインディングも消えてしまうので、このままでは "error: [Dagger/MissingBinding] Repository1 cannot be provided ..." と怒られてしまいます。

調べた範囲では特定のバインディングのみ削除する方法はなく、モジュールごと@UninstallModulesとする以外にないようです。モック対象以外のバインディングを再度定義し直す必要がありますが、@BindValueで列挙するのは面倒です。そこで、モック対象のバインディングのみ削除した新しいモジュールを再度@InstallInします。

SomeActivityTest.kt
  @UninstallModules(RepositoryModule::class)
  @HiltAndroidTest
  @LargeTest
  @RunWith(AndroidJUnit4::class)
  class SomeActivityTest {
  ...
+    // ネストして定義したモジュールはこのテストクラスのみインストールされる
+    @Module
+    @InstallIn(SingletonComponent::class)
+    abstract class TestModule : RepositoryModule {
+        // モック対象のみバインディングを削除
+        override fun bindRepository2(impl: Repository2Impl): Repository2 = throw NotImplementedError()
+    }
  ...
  }

@TestInstallInでも似たような方法が可能ですが、ネストしたモジュールの@InstallInと異なりすべてのテストに影響してしまうため選びませんでした

Hiltモジュールの設計は慎重に

そもそもモジュールを適切に分割するなどして@UninstallModulesで対応できれば発生しない問題です。機能単位で分割するなどテストに優しい設計を心がけたいです。