測試導覽功能

請務必在發布前測試應用程式的導覽邏輯,以確認應用程式可正常運作。

導覽元件可處理不同目的地之間的導覽作業、傳遞引數,以及支援 FragmentManager 的所有工作。這些功能已通過嚴格測試,因此無需在應用程式中重新測試。不過,務必測試片段中應用程式專用程式碼與其 NavController 之間的互動。本指南將介紹幾種常見導覽情境及其測試方式。

測試片段導覽

如要單獨測試片段與 NavController 的互動情形,Navigation 2.3 及以上版本提供 TestNavHostController,能提供 API 以設定當前目的地,並在 NavController.navigate() 作業之後驗證返回堆疊。

您可以在應用程式模組的 build.gradle 檔案中新增下列依附元件,從而將導覽測試成果新增至專案:

Groovy

dependencies {
  def nav_version = "2.4.2"

  androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
}

Kotlin

dependencies {
  val nav_version = "2.4.2"

  androidTestImplementation("androidx.navigation:navigation-testing:$nav_version")
}

假設您要製作一款益智問答遊戲。這個遊戲開始會進入「title_screen」畫面,當使用者按一下「遊戲」時,會導覽至「in_game」畫面。

呈現「title_screen」畫面的片段可能如下所示:

Kotlin

class TitleScreen : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ) = inflater.inflate(R.layout.fragment_title_screen, container, false)

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        view.findViewById<Button>(R.id.play_btn).setOnClickListener {
            view.findNavController().navigate(R.id.action_title_screen_to_in_game)
        }
    }
}

Java

public class TitleScreen extends Fragment {

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater,
            @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_title_screen, container, false);
    }

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        view.findViewById(R.id.play_btn).setOnClickListener(v -> {
            Navigation.findNavController(view).navigate(R.id.action_title_screen_to_in_game);
        });
    }
}

如要測試當使用者點按「遊戲」後,應用程式是否能夠正確將使用者導覽至「in_game」畫面,則需要驗證該片段是否能夠將 NavController 正確移動至 R.id.in_game 畫面。

結合使用 FragmentScenarioEspressoTestNavHostController,您可以重新建立測試該情境所需的條件,如以下範例所示:

Kotlin

@RunWith(AndroidJUnit4::class)
class TitleScreenTest {

    @Test
    fun testNavigationToInGameScreen() {
        // Create a TestNavHostController
        val navController = TestNavHostController(
            ApplicationProvider.getApplicationContext())

        // Create a graphical FragmentScenario for the TitleScreen
        val titleScenario = launchFragmentInContainer<TitleScreen>()

        titleScenario.onFragment { fragment ->
            // Set the graph on the TestNavHostController
            navController.setGraph(R.navigation.trivia)

            // Make the NavController available via the findNavController() APIs
            Navigation.setViewNavController(fragment.requireView(), navController)
        }

        // Verify that performing a click changes the NavController’s state
        onView(ViewMatchers.withId(R.id.play_btn)).perform(ViewActions.click())
        assertThat(navController.currentDestination?.id).isEqualTo(R.id.in_game)
    }
}

Java

@RunWith(AndroidJUnit4.class)
public class TitleScreenTestJava {

    @Test
    public void testNavigationToInGameScreen() {

        // Create a TestNavHostController
        TestNavHostController navController = new TestNavHostController(
            ApplicationProvider.getApplicationContext());

        // Create a graphical FragmentScenario for the TitleScreen
        FragmentScenario<TitleScreen> titleScenario = FragmentScenario.launchInContainer(TitleScreen.class);

        titleScenario.onFragment(fragment ->
                // Set the graph on the TestNavHostController
                navController.setGraph(R.navigation.trivia);

                // Make the NavController available via the findNavController() APIs
                Navigation.setViewNavController(fragment.requireView(), navController)
        );

        // Verify that performing a click changes the NavController’s state
        onView(ViewMatchers.withId(R.id.play_btn)).perform(ViewActions.click());
        assertThat(navController.currentDestination.id).isEqualTo(R.id.in_game);
    }
}

以上範例建立 TestNavHostController 的例項,並將其指派給片段。接著使用 Espresso 驅動 UI,並驗證是否執行了適當的導覽動作。

和真正的 NavController 一樣,必須呼叫 setGraph 才能初始化 TestNavHostController。在本範例中,要測試的片段是圖表的起始目的地。TestNavHostController 提供 setCurrentDestination 方法,讓您能夠設定當前目的地 (同時還可選擇設定該目的地的引數),以便在測試開始之前 NavController 處於正確狀態。

NavHostFragment 將使用的 NavHostController 例項不同,當您呼叫 navigate() 時,TestNavHostController 不會觸發下層的 navigate() 行為 (如 FragmentNavigator 執行的 FragmentTransaction) - 它只會更新 TestNavHostController 的狀態。

使用 FragmentScenario 測試 NavigationUI

在上一範例中,在片段移至其生命週期的 RESUMED 狀態後,會呼叫提供給 titleScenario.onFragment() 的回呼。此時已建立並附加片段的檢視畫面,處於生命週期中較晚的階段,可能無法正確進行測試。舉例來說,當在片段中使用具有檢視畫面的 NavigationUI 時 (例如具有由片段控制的 Toolbar),您可在片段進入 RESUMED 狀態前,使用 NavController 呼叫設定方法。因此,您需要採取方法在生命週期的早期階段設定 TestNavHostController

擁有其自有 Toolbar 的片段按以下方式寫入:

Kotlin

class TitleScreen : Fragment(R.layout.fragment_title_screen) {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val navController = view.findNavController()
        view.findViewById<Toolbar>(R.id.toolbar).setupWithNavController(navController)
    }
}

Java

public class TitleScreen extends Fragment {
    public TitleScreen() {
        super(R.layout.fragment_title_screen);
    }

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        NavController navController = Navigation.findNavController(view);
        view.findViewById(R.id.toolbar).setupWithNavController(navController);
    }
}

此處需要呼叫 onViewCreated() 時建立的 NavController。使用之前的 onFragment() 方法會在生命週期的較晚階段設定 TestNavHostController,導致 findNavController() 呼叫失敗。

FragmentScenario 提供 FragmentFactory 介面,可用於註冊生命週期事件的回呼。可將其與 Fragment.getViewLifecycleOwnerLiveData() 結合使用,在 onCreateView() 之後立即接收回呼,如以下範例所示:

Kotlin

val scenario = launchFragmentInContainer {
    TitleScreen().also { fragment ->

        // In addition to returning a new instance of our Fragment,
        // get a callback whenever the fragment’s view is created
        // or destroyed so that we can set the NavController
        fragment.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
            if (viewLifecycleOwner != null) {
                // The fragment’s view has just been created
                navController.setGraph(R.navigation.trivia)
                Navigation.setViewNavController(fragment.requireView(), navController)
            }
        }
    }
}

Java

FragmentScenario<TitleScreen> scenario =
FragmentScenario.launchInContainer(
       TitleScreen.class, null, new FragmentFactory() {
    @NonNull
    @Override
    public Fragment instantiate(@NonNull ClassLoader classLoader,
            @NonNull String className,
            @Nullable Bundle args) {
        TitleScreen titleScreen = new TitleScreen();

        // In addition to returning a new instance of our fragment,
        // get a callback whenever the fragment’s view is created
        // or destroyed so that we can set the NavController
        titleScreen.getViewLifecycleOwnerLiveData().observeForever(new Observer<LifecycleOwner>() {
            @Override
            public void onChanged(LifecycleOwner viewLifecycleOwner) {

                // The fragment’s view has just been created
                if (viewLifecycleOwner != null) {
                    navController.setGraph(R.navigation.trivia);
                    Navigation.setViewNavController(titleScreen.requireView(), navController);
                }

            }
        });
        return titleScreen;
    }
});

使用這項技術時,可在呼叫 onViewCreated() 之前使用 NavController,讓片段能夠使用 NavigationUI 方法而不會異常終止。

測試與反向堆疊項目的互動

與反向堆疊項目互動時,TestNavHostController 讓您能夠使用從 NavHostController 處沿用而來的 API,將控制器連結至自己的測試 LifecycleOwnerViewModelStoreOnBackPressedDispatcher

例如,當測試的片段使用導覽範圍受到限定的 ViewModel 時,您必須在 TestNavHostController 上呼叫 setViewModelStore

Kotlin

val navController = TestNavHostController(ApplicationProvider.getApplicationContext())

// This allows fragments to use by navGraphViewModels()
navController.setViewModelStore(ViewModelStore())

Java

TestNavHostController navController = new TestNavHostController(ApplicationProvider.getApplicationContext());

// This allows fragments to use new ViewModelProvider() with a NavBackStackEntry
navController.setViewModelStore(new ViewModelStore())