基于Dagger Hilt的单元测试

architecture-samples


android/architecture-samples是google官方的sample项目:TODO-APP,通过其可以学习androidx中各种组件的使用。其中dev-hilt分支展示了Dagger Hilt的使用:
https://github.com/android/architecture-samples/tree/dev-hilt(hash:f2fd9ce969a431b20218f3ace38bbb95fd4d1151),
除了Hilt的使用,还可以了解一下如何使用Hilt进行单元测试。

一个Hilt项目通常会涉及以下Component,本文介绍几个最主要的Component以及其单元测试的方法
在这里插入图片描述
图片引自https://developer.android.com/training/dependency-injection/hilt-android


ApplicationComponent


为Application添加@HiltAndroidApp注解,将其关联到ApplicationComponent

@HiltAndroidApp
class TodoApplication : Application() {
    
    

    override fun onCreate() {
    
    
        super.onCreate()
        if (BuildConfig.DEBUG) Timber.plant(DebugTree())
    }
}

通过@InstallIn,将AppModule和TasksRepositoryModule安装到ApplicationComponent

ApplicationComponent

  • AppModule
  • TasksRepositoryModule
@Module
@InstallIn(ApplicationComponent::class)
object AppModule {
    
    
...
}

/**
 * The binding for TasksRepository is on its own module so that we can replace it easily in tests.
 */
@Module
@InstallIn(ApplicationComponent::class)
object TasksRepositoryModule {
    
    
...
}

AppModule用来提供多种TasksDataSource,例如LocalTasksDataSource、LocalTasksDataSourced等。
使用@Qualifier元注解用来区分不同的DataSource类型。

@Module
@InstallIn(ApplicationComponent::class)
object AppModule {
    
    

    @Qualifier
    @Retention(RUNTIME)
    annotation class RemoteTasksDataSource

    @Qualifier
    @Retention(RUNTIME)
    annotation class LocalTasksDataSource
...

    @Singleton
    @LocalTasksDataSource
    @Provides
    fun provideTasksLocalDataSource(
        database: ToDoDatabase,
        ioDispatcher: CoroutineDispatcher
    ): TasksDataSource {
    
    
        return TasksLocalDataSource(
            database.taskDao(), ioDispatcher
        )
    }
...
}

TasksRepositoryModule中provideTasksRepository,通过RemoteTasksDataSource等注解,就可以注入TasksDataSource了

/**
 * The binding for TasksRepository is on its own module so that we can replace it easily in tests.
 */
@Module
@InstallIn(ApplicationComponent::class)
object TasksRepositoryModule {
    
    

    @Singleton
    @Provides
    fun provideTasksRepository(
        @AppModule.RemoteTasksDataSource remoteTasksDataSource: TasksDataSource,
        @AppModule.LocalTasksDataSource localTasksDataSource: TasksDataSource,
        ioDispatcher: CoroutineDispatcher
    ): TasksRepository {
    
    
        return DefaultTasksRepository(
            remoteTasksDataSource, localTasksDataSource, ioDispatcher
        )
    }
}

单元测试

我们配合SourceSet进行单元测试,将fake的测试类定义在SourceSet目录中,

@Module
@InstallIn(ApplicationComponent::class)
abstract class TestTasksRepositoryModule {
    
    
    @Singleton
    @Binds
    abstract fun bindRepository(repo: FakeRepository): TasksRepository
}

然后在实际代码中,通过@UninstallModules卸载业务对象,替换为测试对象

@RunWith(AndroidJUnit4::class)
@MediumTest
@ExperimentalCoroutinesApi
// ** ↓UninstallModules将默认的业务对象删除,加载TestTasksRepositoryModule测试对象  **
@UninstallModules(TasksRepositoryModule::class)
@HiltAndroidTest
class TasksFragmentTest {
    
    

ActivityComponent


@AndroidEntryPoint 可以将Activity关联到ActivityComponent

@AndroidEntryPoint
class TasksActivity : AppCompatActivity() {
    
    

Activity的中使用Navigation显示Fragment

//TasksActivity.kt
val navController: NavController = findNavController(R.id.nav_host_fragment)
        appBarConfiguration =
            AppBarConfiguration.Builder(R.id.tasks_fragment_dest, R.id.statistics_fragment_dest)
                .setDrawerLayout(drawerLayout)
                .build()
        setupActionBarWithNavController(navController, appBarConfiguration)
        findViewById<NavigationView>(R.id.nav_view)
            .setupWithNavController(navController)
<!--tasks_act.xml-->

 <fragment
            android:id="@+id/nav_host_fragment"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"

            app:defaultNavHost="true"
            app:navGraph="@navigation/nav_graph" />
<!--nav_graph.xml-->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/nav_graph"
    app:startDestination="@id/tasks_fragment_dest">
   <!-- ↑ 设置初始Fragment ↑ -->

    <fragment
        android:id="@+id/task_detail_fragment_dest"
        android:name="com.example.android.architecture.blueprints.todoapp.taskdetail.TaskDetailFragment"
        android:label="Task Details">
...
        <argument
            android:name="taskId"
            app:argType="string" />
...
    </fragment>
   <!-- **↓ 定义初始Fragment ↓** -->
    <fragment
        android:id="@+id/tasks_fragment_dest"
        android:name="com.example.android.architecture.blueprints.todoapp.tasks.TasksFragment"
        android:label="@string/app_name">
...
        <action
            android:id="@+id/action_tasksFragment_to_taskDetailFragment"
            app:destination="@id/task_detail_fragment_dest" />
...
    </fragment>

FragmentComponent


@AndroidEntryPoint关联TasksFragment和FragmentComponent

@AndroidEntryPoint
class TasksFragment : Fragment() {
    
    
    private val viewModel by viewModels<TasksViewModel>()
    private val args: TasksFragmentArgs by navArgs()

TasksFragment的参数通过navigation的navArgs获取,显示任务列表,如下图:

单元测试

SourceSet中定义测试对象:

@Module
@InstallIn(ApplicationComponent::class)
abstract class TestTasksRepositoryModule {
    
    
    @Singleton
    @Binds
    abstract fun bindRepository(repo: FakeRepository): TasksRepository
}

通过HiltAndroidRule,注入测试对象进行测试:

@RunWith(AndroidJUnit4::class)
@MediumTest
@ExperimentalCoroutinesApi
// ** ①卸载业务对象 **
@UninstallModules(TasksRepositoryModule::class)
@HiltAndroidTest
class TasksFragmentTest {
    
    

    @get:Rule
    var hiltRule = HiltAndroidRule(this)
    // ② 根据HiltAndroidRule、注入FakeRepository
    @Inject
    lateinit var repository: TasksRepository

    @Before
    fun init() {
    
    
        // Populate @Inject fields in test class
        hiltRule.inject()
    }

使用FakeRepository保存数据,并在TestCase中使用

  @Test
    fun displayTask_whenRepositoryHasData() {
    
    
        // GIVEN - One task already in the repository
        repository.saveTaskBlocking(Task("TITLE1", "DESCRIPTION1"))

        // WHEN - On startup
        launchActivity()

        // THEN - Verify task is displayed on screen
        onView(withText("TITLE1")).check(matches(isDisplayed()))
    }

    private fun launchActivity(): ActivityScenario<TasksActivity>? {
    
    
        val activityScenario = launch(TasksActivity::class.java)
        activityScenario.onActivity {
    
     activity ->
            // Disable animations in RecyclerView
            (activity.findViewById(R.id.tasks_list) as RecyclerView).itemAnimator = null
        }
        return activityScenario
    }

这里使用launchFragmentInHiltContainer启动Fragment

 @Test
    fun clickAddTaskButton_navigateToAddEditFragment() {
    
    
        // GIVEN - On the home screen
        val navController = mock(NavController::class.java)

        launchFragmentInHiltContainer<TasksFragment>(Bundle(), R.style.AppTheme) {
    
    
            Navigation.setViewNavController(this.view!!, navController)
        }

        // WHEN - Click on the "+" button
        onView(withId(R.id.add_task_fab)).perform(click())

        // THEN - Verify that we navigate to the add screen
        verify(navController).navigate(
            TasksFragmentDirections.actionTasksFragmentToAddEditTaskFragment(
                null, getApplicationContext<Context>().getString(R.string.add_task)
            )
        )
    }

Fragment的启动依赖Activity,构造HiltTestActivity帮助Fragment启动

/**
 * launchFragmentInContainer from the androidx.fragment:fragment-testing library
 * is NOT possible to use right now as it uses a hardcoded Activity under the hood
 * (i.e. [EmptyFragmentActivity]) which is not annotated with @AndroidEntryPoint.
 *
 * As a workaround, use this function that is equivalent. It requires you to add
 * [HiltTestActivity] in the debug folder and include it in the debug AndroidManifest.xml file
 * as can be found in this project.
 */
inline fun <reified T : Fragment> launchFragmentInHiltContainer(
    fragmentArgs: Bundle? = null,
    @StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
    crossinline action: Fragment.() -> Unit = {
    
    }
) {
    
    
    val startActivityIntent = Intent.makeMainActivity(
        ComponentName(
            ApplicationProvider.getApplicationContext(),
            HiltTestActivity::class.java
        )
    ).putExtra(EmptyFragmentActivity.THEME_EXTRAS_BUNDLE_KEY, themeResId)

    ActivityScenario.launch<HiltTestActivity>(startActivityIntent).onActivity {
    
     activity ->
        val fragment: Fragment = activity.supportFragmentManager.fragmentFactory.instantiate(
            Preconditions.checkNotNull(T::class.java.classLoader),
            T::class.java.name
        )
        fragment.arguments = fragmentArgs
        activity.supportFragmentManager
            .beginTransaction()
            .add(android.R.id.content, fragment, "")
            .commitNow()

        fragment.action()
    }
}

猜你喜欢

转载自blog.csdn.net/vitaviva/article/details/112059347