Search code examples
androidui-testingandroid-architecture-navigationandroid-mvvm

Testing navigation action in navigation component in Android


Recently I have integrated navigation component into a project which is build upon single activity model. I have tried to add ui tests based on the tutorials in android docs. However it did not work when I did a little modification to that tutorial. I have following code in my fragment.

val bundle = Bundle().apply {
   putString(UserTrackingConstants.VIEW, UserTrackingConstants.HOME)
}

btnSignUp.setOnClickListener {
findNavController().navigate(R.id.action_from_home_fragment_to_registration_fragment,bundle)
}

So, whenever btnSignUp is clicked it will call the following navigation action. It works perfect. Then I added the following test.

@Test
    fun testRegisterButtonClicked() {
        val mockNavController = mock(NavController::class.java)
        val bundle = bundleOf(
                UserTrackingConstants.VIEW to UserTrackingConstants.HOME
        )
        val scenario = launchFragmentInContainer<HomeFragment>()
        scenario.onFragment { fragment ->
            Navigation.setViewNavController(fragment.requireView(), mockNavController)
        }
        onView(withId(R.id.btnSignUp))
                .perform(click())
        verify(mockNavController).navigate(R.id.action_from_home_fragment_to_registration_fragment, bundle)
    }

And it is throwing the following exception

E/TestRunner: ----- begin exception -----
    Argument(s) are different! Wanted:
    navController.navigate(
        2131296310,
        Bundle[{view=home}]
    );
    -> at HomeFragmentTest.testRegisterButtonClicked(HomeFragmentTest.kt:87)
    Actual invocation has different arguments:
    navController.navigate(
        2131296310,
        Bundle[{view=home}]
    );
    -> at HomeFragment$setupObservers$2.onClick(HomeFragment.kt:84)

        at HomeFragmentTest.testRegisterButtonClicked(HomeFragmentTest.kt:87)
        at java.lang.reflect.Method.invoke(Native Method)
        at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
        at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
        at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
        at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
        at androidx.test.internal.runner.junit4.statement.RunBefores.evaluate(RunBefores.java:80)
        at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
        at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
        at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
        at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
        at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
        at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
        at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
        at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
        at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
        at androidx.test.ext.junit.runners.AndroidJUnit4.run(AndroidJUnit4.java:104)
        at org.junit.runners.Suite.runChild(Suite.java:128)
        at org.junit.runners.Suite.runChild(Suite.java:27)
        at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
        at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
        at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
        at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
        at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
        at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
        at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
        at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
        at androidx.test.internal.runner.TestExecutor.execute(TestExecutor.java:56)
        at androidx.test.runner.AndroidJUnitRunner.onStart(AndroidJUnitRunner.java:392)
        at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:2189)
    ----- end exception -----

Argument(s) are different! Wanted:
navController.navigate(
2131296310,
Bundle[{view=home}]
);
-> at HomeFragmentTest.testRegisterButtonClicked(HomeFragmentTest.kt:87)
Actual invocation has different arguments:
navController.navigate(
2131296310,
Bundle[{view=home}]
);
-> at HomeFragment$setupObservers$2.onClick(HomeFragment.kt:84)

at HomeFragmentTest.testRegisterButtonClicked(HomeFragmentTest.kt:87)
at java.lang.reflect.Method.invoke(Native Method)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at androidx.test.internal.runner.junit4.statement.RunBefores.evaluate(RunBefores.java:80)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
at androidx.test.ext.junit.runners.AndroidJUnit4.run(AndroidJUnit4.java:104)
at org.junit.runners.Suite.runChild(Suite.java:128)
at org.junit.runners.Suite.runChild(Suite.java:27)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at androidx.test.internal.runner.TestExecutor.execute(TestExecutor.java:56)
at androidx.test.runner.AndroidJUnitRunner.onStart(AndroidJUnitRunner.java:392)
at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:2189)

I do not get anything useful from the logcat. It seems like arguments are the same and it should pass. It is working fine if I do not pass bundle. I followed the docs in the following link https://developer.android.com/guide/navigation/navigation-testing. My question is does anyone experience such exceptions and how to test navigation component with bundle?


Solution

  • The reason you've experienced such weird test results is that Bundle class does not override equals() and toString() methods, so Bundle instances are compared by reference (and the references are obviously different for your expected bundle and the actual one) – and since Mockito relies on these methods to verify that objects are the same, your test case will fail.

    To fix this behavior, you could use custom matcher for Mockito.verify():

    public class BundleEquals implements ArgumentMatcher, ContainsExtraTypeInfo, Serializable {
    
        @Nullable
        private final Bundle expected;
    
        public BundleEquals(@Nullable Bundle expected) {
            this.expected = expected;
        }
    
        @Override
        public boolean matches(Bundle actual) {
            if (expected == null && actual == null) {
                return true;
            }
    
            if (expected == null || actual == null) {
                return false;
            }
    
            return areBundlesEqual(expected, actual);
        }
    
        private boolean areBundlesEqual(@NonNull Bundle expected, @NonNull Bundle actual) {
            if (expected.size() != actual.size()) {
                return false;
            }
    
            if (!expected.keySet().containsAll(actual.keySet())) {
                return false;
            }
    
            for (String key : expected.keySet()) {
                @Nullable Object expectedValue = expected.get(key);
                @Nullable Object actualValue = actual.get(key);
    
                if (expectedValue instanceof Bundle && actualValue instanceof Bundle) {
                    if (!areBundlesEqual((Bundle) expectedValue, (Bundle) actualValue)) {
                        return false;
                    }
                } else if (!Objects.equals(expectedValue, actualValue)) {
                    return false;
                }
            }
    
            return true;
        }
    
        public String toStringWithType() {
            String clazz = expected != null ? expected.getClass().getSimpleName() : null;
            return "(" + clazz + ") " + describe(expected);
        }
    
        private String describe(Object object) {
            return ValuePrinter.print(object);
        }
    
        @Override
        public boolean typeMatches(Object actual) {
            return expected != null && actual != null && actual.getClass() == expected.getClass();
        }
    
        public String toString() {
            return describe(expected);
        }
    
    }

    Note: ContainsExtraTypeInfo and Serializable implementations are not required here, but they'll provide you with detailed expected and actual arguments description when test is failed due to bundles mismatch,

    To apply the matcher, you should use something like this:

    verify(mockNavController).navigate(eq(R.id.action_from_home_fragment_to_registration_fragment), argThat(new BundleEquals(bundle));
    

    Note that eq() is needed here for Mockito not to confuse different matchers: leaving your action id unwrapped would fail verification.