以前は画面遷移にはIntentを使ったりしていましたが、最近はNavigationとか出てきて画面遷移方法が変わってきました。今回は画面遷移用のモジュールを作成し、ユニットテストできるようにしてみます。例としてMainActivity内にデフォルトでRulesFragmentが表示されていて、HistoriesFragmentに遷移できて戻るボタンを1回押すとRulesFragmentに戻り、更に戻るボタンを1回押すとアプリが終了するとします。

対象

  • Navigationのユニットテストを書きたい方
  • 画面遷移用のモジュールを作成したい方

またNavigationの使い方は今回は説明しません(BackStackの積み方とかが今までと違うのでそのあたりは理解した上でテストコードを読むことをオススメします。

class MainActivity : AppCompatActivity()
    @Inject // Dagger or HiltでDIする場合
    lateinit var router: MainActivityRouter
    private lateinit var viewBinding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(viewBinding.root)
   }
}
<!-- activity_main.xml レイアウトファイル -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    android:layout_height="match_parent"
    android:layout_width="match_parent">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/fragment_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:navGraph="@navigation/activity_main"
        app:defaultNavHost="true"/>

</LinearLayout>
<!-- activity_main.xml ナビゲーションファイル -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_activity_navigation"
    app:startDestination="@id/rulesFragment">

    <fragment
        android:id="@+id/rulesFragment"
        android:name="com.example.ui.fragment.RulesFragment"
        android:label="fragment_rules">

        <action
            android:id="@+id/action_rules_to_histories"
            app:destination="@id/historiesFragment"
            app:popUpTo="@id/rulesFragment"/>

    </fragment>

    <fragment
        android:id="@+id/historiesFragment"
        android:name="com.example.ui.fragment.HistoriesFragment"
        android:label="fragment_histories">
    
        <action
            android:id="@+id/action_histories_to_rules"
            app:destination="@id/rulesFragment"/>

    </fragment>

</navigation>
class RulesFragment : Fragment() {
    companion object {
        const val argKey = "arg"
        operator fun invoke(arg: Boolean?): RulesFragment =
                RulesFragment().apply {
                    val bundle = Bundle().also { b ->
                        param?.let { p ->
                            b.apply {
                                putBoolean(argKey, p.arg)
                            }
                        }
                    }
                    this.arguments = bundle
                }
    }
    private val argument: Boolean? get() = arguments?.getBoolean(argKey)
}
class HistoriesFragment : Fragment() { }
interface MainActivityRouter {
    fun toRules(@IdRes container: Int, arg: Boolean?)
    fun toHistories(@IdRes container: Int)
}
internal class MainActivityRouterImpl(
        private val activity: AppCompatActivity
) : MainActivityRouter {
    override fun toRules(@IdRes container: Int, arg: Boolean?) {
        val navController = activity.findNavController(container)
        if (navController.currentDestination?.id == R.id.rulesFragment) return
        navController.navigate(
                when (navController.currentDestination?.id) {
                    R.id.historiesFragment -> R.id.action_histories_to_rules
                    else -> throw IllegalStateException("unknown navigation")
                },
                Bundle().apply { arg?.let {
                    putBoolean(RulesFragment.argKey, arg)
                } }
        )
    }
    override fun toHistories(@IdRes container: Int) {
        val navController = activity.findNavController(container)
        if (navController.currentDestination?.id == R.id.historiesFragment) return
        navController.navigate(
                when (navController.currentDestination?.id) {
                    R.id.rulesFragment -> R.id.action_rules_to_histories
                    else -> throw IllegalStateException("unknown navigation")
                }
        )
    }
}

https://developer.android.com/guide/navigation/navigation-testing?hl=ja

Navigationのテストコードを書く場合はTestNavHostControllerを使います。val navController = TestNavHostController(ApplicationProvider.getApplicationContext())で初期化でき、Fragment内でテストしたい場合はlaunchFragmentInContainer<EmptyFragment>(themeResId = R.style.Theme_MaterialComponents_Light).onFragment()で起動したFragmentのViewを指定

Navigation.setViewNavController(fragment.requireView(), navController)

Activity内でテストしたい場合は今回のようなMainActivityを用意してlaunchActivity<MainActivity>().onActivity()で起動したActivityのViewを指定

Navigation.setViewNavController(activity.requireViewById(R.id.fragment_container), navController)

このようにすることでユニットテストでTestNavHostControllerを利用できるようになります。

テストコードは下記のようになります。

@RunWith(AndroidJUnit4::class)
class MainActivityRouterImplSpec {
    companion object {
        private const val ARG = true
    }
    private val navController = TestNavHostController(ApplicationProvider.getApplicationContext()).apply {
        setGraph(R.navigation.activity_main)
    }
    private fun launchActivity(block: (MainActivity) -> Unit): ActivityScenario<MainActivity> =
            launchActivity<MainActivity>().onActivity { activity ->
                activity.router = MainActivityRouterImpl(activity)
                Navigation.setViewNavController(activity.requireViewById(R.id.fragment_container), navController)
                block.invoke(activity)
            }

    @Test
    fun rules_to_rules_with_null() {
        launchActivity { activity ->
            activity.router.toRules(R.id.fragment_container, null)
            activity.router.toRules(R.id.fragment_container, null)
                        // 現在の状態を確認
            assertThat(navController.currentDestination?.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.currentDestination?.arguments?.size).isEqualTo(0)
                        // 1つ前のbackStackを確認
            assertThat(navController.previousBackStackEntry?.destination?.id).isNull()
            assertThat(navController.previousBackStackEntry?.arguments).isNull()
                        // backStack全体を確認
            assertThat(navController.backStack.size).isEqualTo(2)
            assertThat(navController.backStack[0].destination.id).isEqualTo(R.id.main_activity_navigation)
            assertThat(navController.backStack[0].destination.arguments.size).isEqualTo(0)
            assertThat(navController.backStack[1].destination.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.backStack[1].destination.arguments.size).isEqualTo(0)
        }
    }

    @Test
    fun histories_to_rules_with_param() {
        launchActivity { activity ->
            activity.router.toHistories(R.id.fragment_container)
            activity.router.toRules(R.id.fragment_container, true)
            assertThat(navController.currentDestination?.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.currentDestination?.arguments?.size).isEqualTo(0)
            assertThat(navController.previousBackStackEntry?.destination?.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.previousBackStackEntry?.arguments).isNull()
            assertThat(navController.backStack.size).isEqualTo(3)
            assertThat(navController.backStack[0].destination.id).isEqualTo(R.id.main_activity_navigation)
            assertThat(navController.backStack[0].destination.arguments.size).isEqualTo(0)
            assertThat(navController.backStack[1].destination.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.backStack[1].destination.arguments.size).isEqualTo(0)
            assertThat(navController.backStack[2].destination.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.backStack[2].destination.arguments.size).isEqualTo(0)
            assertThat(navController.backStack[2].arguments?.getBoolean(RulesFragment.argKey)).isEqualTo(true)
        }
    }

    @Test
    fun rules_to_histories() {
        launchActivity { activity ->
            activity.router.toHistories(R.id.fragment_container)
            assertThat(navController.currentDestination?.id).isEqualTo(R.id.historiesFragment)
            assertThat(navController.currentDestination?.arguments?.size).isEqualTo(0)
            assertThat(navController.previousBackStackEntry?.destination?.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.previousBackStackEntry?.arguments).isNull()
            assertThat(navController.backStack.size).isEqualTo(3)
            assertThat(navController.backStack[0].destination.id).isEqualTo(R.id.main_activity_navigation)
            assertThat(navController.backStack[0].destination.arguments.size).isEqualTo(0)
            assertThat(navController.backStack[1].destination.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.backStack[1].destination.arguments.size).isEqualTo(0)
            assertThat(navController.backStack[2].destination.id).isEqualTo(R.id.historiesFragment)
            assertThat(navController.backStack[2].destination.arguments.size).isEqualTo(0)
    }
}