【问题标题】:android jetpack navigation instrumented test fail on back navigationandroid jetpack 导航仪器测试在返回导航上失败
【发布时间】:2021-06-10 23:02:01
【问题描述】:

我使用 jetpack Navigation 组件 (androidx.navigation) 创建了一个简单的两个片段示例应用程序。第一个片段导航到第二个片段,它使用OnBackPressedDispatcher 覆盖后退按钮行为。

活动布局

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="@dimen/box_inset_layout_padding"
    tools:context=".navigationcontroller.NavigationControllerActivity">

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

        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph" />
</LinearLayout>

片段A:

class FragmentA : Fragment() {

    lateinit var buttonNavigation: Button

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val view = inflater.inflate(R.layout.fragment_a, container, false)
        buttonNavigation = view.findViewById<Button>(R.id.button_navigation)
        buttonNavigation.setOnClickListener { Navigation.findNavController(requireActivity(), R.id.nav_host).navigate(R.id.fragmentB) }
        return view
    }
}

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".navigationcontroller.FragmentA">
    
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="fragment A" />

    <Button
        android:id="@+id/button_navigation"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="go to B" />
</LinearLayout>

片段B:

class FragmentB : Fragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val view = inflater.inflate(R.layout.fragment_b, container, false)
        requireActivity().onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                val textView = view.findViewById<TextView>(R.id.textView)
                textView.setText("backbutton pressed, press again to go back")
                this.isEnabled = false
            }
        })
        return view
    }
}

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".navigationcontroller.FragmentA">
    
    <TextView
        android:id="@+id/textView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="fragment B" />
</FrameLayout>

当我手动测试应用程序时,FragmentB 中后退按钮的预期行为(第一次触摸更改文本而不导航,第二次导航返回)工作正常。 我添加了仪器测试来检查 FragmentB 中的后退按钮行为,这就是问题开始出现的地方:

class NavigationControllerActivityTest {

    lateinit var fragmentScenario: FragmentScenario<FragmentB>
    lateinit var navController: TestNavHostController

    @Before
    fun setUp() {
        navController = TestNavHostController(ApplicationProvider.getApplicationContext())

        fragmentScenario = FragmentScenario.launchInContainer(FragmentB::class.java)
        fragmentScenario.onFragment(object : FragmentScenario.FragmentAction<FragmentB> {
            override fun perform(fragment: FragmentB) {
                Navigation.setViewNavController(fragment.requireView(), navController)
                navController.setLifecycleOwner(fragment.viewLifecycleOwner)
                navController.setOnBackPressedDispatcher(fragment.requireActivity().getOnBackPressedDispatcher())
                navController.setGraph(R.navigation.nav_graph)
                // simulate backstack from previous navigation
                navController.navigate(R.id.fragmentA)
                navController.navigate(R.id.fragmentB)
            }
        })
    }

    @Test
    fun whenButtonClickedOnce_TextChangedNoNavigation() {
        Espresso.pressBack()
        onView(withId(R.id.textView)).check(matches(withText("backbutton pressed, press again to go back")))
        assertEquals(R.id.fragmentB, navController.currentDestination?.id)
    }

    @Test
    fun whenButtonClickedTwice_NavigationHappens() {
        Espresso.pressBack()
        Espresso.pressBack()
        assertEquals(R.id.fragmentA, navController.currentDestination?.id)
    }
}

不幸的是,虽然whenButtonClickedTwice_NavigationHappens 通过了,但whenButtonClickedOnce_TextChangedNoNavigation 由于文本未被更改而失败,就像从未调用过OnBackPressedCallback 一样。由于应用程序在手动测试期间运行良好,因此测试代码肯定有问题。谁能帮帮我?

【问题讨论】:

    标签: android android-espresso back-button android-jetpack-navigation instrumented-test


    【解决方案1】:

    如果您尝试测试您的 OnBackPressedCallback 逻辑,最好直接执行此操作,而不是尝试测试 Navigation 与默认活动的 OnBackPressedDispatcher 之间的交互。

    这意味着您希望通过注入OnBackPressedDispatcher 来打破活动的OnBackPressedDispatcher (requireActivity().onBackPressedDispatcher) 和Fragment 之间的硬依赖关系,从而允许您提供特定于测试的实例:

    class FragmentB(val onBackPressedDispatcher: OnBackPressedDispatcher) : Fragment() {
    
        override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
            val view = inflater.inflate(R.layout.fragment_b, container, false)
            onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
                override fun handleOnBackPressed() {
                    val textView = view.findViewById<TextView>(R.id.textView)
                    textView.setText("backbutton pressed, press again to go back")
                    this.isEnabled = false
                }
            })
            return view
        }
    }
    

    这使您可以拥有生产代码provide a FragmentFactory

    class MyFragmentFactory(val activity: FragmentActivity) : FragmentFactory() {
        override fun instantiate(classLoader: ClassLoader, className: String): Fragment =
            when (loadFragmentClass(classLoader, className)) {
                FragmentB::class.java -> FragmentB(activity.onBackPressedDispatcher)
                else -> super.instantiate(classLoader, className)
            }
    }
    
    // Your activity would use this via:
    override fun onCreate(savedInstanceState: Bundle?) {
        supportFragmentManager.fragmentFactory = MyFragmentFactory(this)
        super.onCreate(savedInstanceState)
        // ...
    }
    

    这意味着您可以编写如下测试:

    class NavigationControllerActivityTest {
    
        lateinit var fragmentScenario: FragmentScenario<FragmentB>
        lateinit var onBackPressedDispatcher: OnBackPressedDispatcher
        lateinit var navController: TestNavHostController
    
        @Before
        fun setUp() {
            navController = TestNavHostController(ApplicationProvider.getApplicationContext())
    
            // Create a test specific OnBackPressedDispatcher,
            // giving you complete control over its behavior
            onBackPressedDispatcher = OnBackPressedDispatcher()
    
            // Here we use the launchInContainer method that
            // generates a FragmentFactory from a constructor,
            // automatically figuring out what class you want
            fragmentScenario = launchFragmentInContainer {
                FragmentB(onBackPressedDispatcher)
            }
            fragmentScenario.onFragment(object : FragmentScenario.FragmentAction<FragmentB> {
                override fun perform(fragment: FragmentB) {
                    Navigation.setViewNavController(fragment.requireView(), navController)
                    navController.setGraph(R.navigation.nav_graph)
                    // Set the current destination to fragmentB
                    navController.setCurrentDestination(R.id.fragmentB)
                }
            })
        }
    
        @Test
        fun whenButtonClickedOnce_FragmentInterceptsBack() {
            // Assert that your FragmentB has already an enabled OnBackPressedCallback
            assertTrue(onBackPressedDispatcher.hasEnabledCallbacks())
    
            // Now trigger the OnBackPressedDispatcher
            onBackPressedDispatcher.onBackPressed()
            onView(withId(R.id.textView)).check(matches(withText("backbutton pressed, press again to go back")))
    
            // Check that FragmentB has disabled its Callback
            // ensuring that the onBackPressed() will do the default behavior
            assertFalse(onBackPressedDispatcher.hasEnabledCallbacks())
        }
    }
    

    这避免了测试 Navigation 的代码,而是专注于测试您的代码,特别是您与 OnBackPressedDispatcher 的交互。

    【讨论】:

    • 您能解释一下调用 FragmentScenario.launchInContainer {...} 时使用的 Kotlin 功能吗?它有一个参数 - 尾随 lambda。唯一可以接受单个参数的 launchInContainer 版本是具有默认值的版本,但它需要传递 fragmentClass: Class 因为它没有默认值。为什么在为 factory 提供 lambda 时可以跳过这个强制性参数?另外,FragmentFactory 不是 SAM 接口而是一个类,怎么能从 lambda 转换过来?
    • 另外,这段代码不能在我的环境中编译,我得到“以下函数都不能使用提供的参数调用”错误。我正在使用 kotlin gradle 插件 1.4.31 和 androidx.fragment:fragment-testing:1.3.1。我该如何解决这个问题?
    • 抱歉,我已更新代码以使用正确的 launchFragmentInContainer extension method,它采用 crossinline instantiate: () -&gt; F 作为尾随 lambda。
    【解决方案2】:

    FragmentB 的OnBackPressedCallback 被忽略的原因是OnBackPressedDispatcher 如何对待它的OnBackPressedCallbacks。它们作为命令链运行,这意味着最近注册的启用的将“吃掉”该事件,因此其他人将不会收到它。因此,最近在FragmentScenario.onFragment() 中注册了回调(由lifecycleOwner 启用,因此每当Fragment 至少处于生命周期STARTED 状态时。由于在按下后退按钮时片段在测试期间可见,因此始终启用回调时间),将优先于之前在FragmentB.onCreateView() 中注册的一个。 所以TestNavHostController的回调必须在FragmentB.onCreateView()执行之前添加。

    这会导致@Before方法的测试代码发生变化:

    @Before
    fun setUp() {
        navController = TestNavHostController(ApplicationProvider.getApplicationContext())
    
        fragmentScenario = FragmentScenario.launchInContainer(FragmentB::class.java, initialState = Lifecycle.State.CREATED)
        fragmentScenario.onFragment(object : FragmentScenario.FragmentAction<FragmentB> {
            override fun perform(fragment: FragmentB) {
                navController.setLifecycleOwner(fragment.requireActivity())
                navController.setOnBackPressedDispatcher(fragment.requireActivity().getOnBackPressedDispatcher())
                navController.setGraph(R.navigation.nav_graph)
                // simulate backstack from previous navigation
                navController.navigate(R.id.fragmentA)
                navController.navigate(R.id.fragmentB)
            }
        })
        fragmentScenario.moveToState(Lifecycle.State.RESUMED)
        fragmentScenario.onFragment(object : FragmentScenario.FragmentAction<FragmentB> {
            override fun perform(fragment: FragmentB) {
                Navigation.setViewNavController(fragment.requireView(), navController)
            }
        })
    }
    

    最重要的变化是在CREATED 状态(而不是默认的RESUMED)启动Fragment,以便能够在onCreateView() 之前对其进行修补。

    另外,请注意Navigation.setViewNavController() 在将片段移动到RESUMED 状态后以单独的onFragment() 运行 - 它接受View 参数,因此不能在onCreateView() 之前使用它

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2020-04-12
      • 1970-01-01
      • 1970-01-01
      • 2021-05-19
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多