android / architecture-components-samples

Samples for Android Architecture Components.
https://d.android.com/arch
Apache License 2.0
23.42k stars 8.29k forks source link

Mock ViewModel in Activity for UI Testing with Espresso #266

Closed nuxzero closed 6 years ago

nuxzero commented 6 years ago

I'm try to setup UI testing similar GithubBrowserSample and look like sample project have only mock ViewModel for Fragment but for Activity it doesn't.

So, on my code activityRule.activity.viewModelFactory = createViewModelFor(viewModel) it doesn't set before onCreate() in Activity, meant it doesn't use mock ViewModel.

class MainActivityTest {

    val viewModel = mock(MainViewModel::class.java)

    @Rule
    @JvmField
    val activityRule = ActivityTestRule<MainActivity>(MainActivity::class.java, true, true)

    private val liveData = MutableLiveData<Resource<Object>>()

    @Before
    open fun setUp() {
        activityRule.activity.viewModelFactory = createViewModelFor(viewModel)
        `when`(viewModel.liveData).thenReturn(liveData)
        viewModel.liveData?.observeForever(mock(Observer::class.java) as Observer<Resource<Object>>)
        liveData.postValue(Resource.success(Object()))
    }

    fun <T : ViewModel> createViewModelFor(model: T): ViewModelProvider.Factory =
        object : ViewModelProvider.Factory {
            override fun <T : ViewModel> create(modelClass: Class<T>): T {
                if (modelClass.isAssignableFrom(model.javaClass)) {
                    return model as T
                }
                throw IllegalArgumentException("unexpected model class " + modelClass)
            }
        }
}

Can someone help me about this issue please?

brillmt commented 6 years ago

@nuxzero did you find a solution for this?

nuxzero commented 6 years ago

@brillmt Not yet

kekefigure commented 6 years ago

Use the following class to create the activity instance: https://developer.android.com/reference/android/support/test/runner/intercepting/SingleActivityFactory.html Then you can create different factories for mocked/injected activities. Example with dagger2 injection:


private SingleActivityFactory<MainActivity> injectedFactory = new SingleActivityFactory<MainActivity>(MainActivity.class) {
        @Override
        protected MainActivity create(Intent intent) {
            MainActivity activity = new MainActivity();
            activity.viewModelFactory = testApp.daggerTestAppComponent.vmFactory();
            return activity;
        }
 };

@Rule
public ActivityTestRule<MainActivity> mActivityTestRule = new ActivityTestRule<>(injectedFactory, false, false);
chasel commented 6 years ago

@kekefigure can you explain testApp.daggerTestAppComponent.vmFactory() detail ?

kekefigure commented 6 years ago

Of course. Let's say you have a AppComponent class in your application module, then you define a TestAppComponent(which extends AppComponent) for your instrumented tests.

TestAppComponent.java

@Singleton
@Component(modules = {
...
})
public interface TestAppComponent extends AppComponent {
    @Component.Builder
    interface Builder {
        @BindsInstance
        Builder application(Application application);

        TestAppComponent build();
    }

    void inject(TestApp myApp);

   //you can acces this instance from the created TestAppComponent 
    ViewModelProvider.Factory vmFactory();
}

TestApp.java

public class TestApp {
public TestAppComponent daggerTestAppComponent;
    @Override
    public void onCreate() {
        super.onCreate();
        daggerTestAppComponent = DaggerTestAppComponent.builder().application(this).build();
    }
}

In your test classes:

 testApp = ((TestApp) InstrumentationRegistry.getTargetContext().getApplicationContext());
//you can get the vmFactory instance with:
testApp.daggerTestAppComponent.vmFactory();

Note: This solution can cause errors in dagger2 dependency graph, so its just a workaround.

danielwilson1702 commented 6 years ago

I can't see what I'm doing wrong, does this look ok? Would I be correct in saying that this test factory overrides the normal injected one? The activity vm factory and the test one have different hash codes, I was thinking that means they are still different objects when they shouldn't be.

Build.gradle has test runner info: testInstrumentationRunner "core.sdk.util.MyTestRunner"

kaptAndroidTest "com.google.dagger:dagger-android-processor:${versions.dagger}" androidTestImplementation "com.android.support.test:runner:${versions.runner}"

class MyTestRunner : AndroidJUnitRunner() {
    override fun newApplication(cl: ClassLoader, className: String, context: Context): Application {
        return super.newApplication(cl, TestApp::class.java.name, context)
    }
}
@OpenForTesting
class App : Application(), HasActivityInjector {
    @Inject
    lateinit var activityDispatchingAndroidInjector: DispatchingAndroidInjector<Activity>

    override fun onCreate() {
        super.onCreate()

        DaggerAppComponent.builder()
                .application(this)
                .baseUrl(BuildConfig.BASE_URL)
                .build()
                .inject(this)
    }

    override fun activityInjector(): DispatchingAndroidInjector<Activity> {
        return activityDispatchingAndroidInjector
    }
}
class TestApp : App()
{
    lateinit var daggerTestAppComponent: TestAppComponent

    override fun onCreate() {
        super.onCreate()
        daggerTestAppComponent = DaggerTestAppComponent.builder()
                .application(this)
                .baseUrl(BuildConfig.BASE_URL)
                .build()
    }
}
@Singleton
@Component(modules = [AndroidInjectionModule::class, AppModule::class, NetworkModule::class, ActivityBuilder::class])
interface AppComponent {

    @Component.Builder
    interface Builder {
        @BindsInstance
        fun application(application: Application): Builder
        @BindsInstance
        fun baseUrl(@Named("baseUrl") baseUrl: String): Builder
        fun build(): AppComponent
    }

    fun inject(app: App)
    fun Repository(): Repository
}
@Singleton
@Component(modules = [AndroidInjectionModule::class, AppModule::class, NetworkModule::class, ActivityBuilder::class])
interface TestAppComponent {

    @Component.Builder
    interface Builder {
        @BindsInstance
        fun application(application: Application): Builder
        @BindsInstance
        fun baseUrl(@Named("baseUrl") baseUrl: String): Builder
        fun build(): TestAppComponent
    }

    fun inject(app: TestApp)
    fun vmFactory(): ViewModelProvider.Factory
}
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {

    private val email = "*********@gmail.com"
    private val password = "*****"

    private val injectedFactory = object : SingleActivityFactory<LoginActivity>(LoginActivity::class.java) {
        override fun create(intent: Intent): LoginActivity {
            val activity = LoginActivity()
            val testApp = InstrumentationRegistry.getTargetContext().applicationContext as TestApp
            activity.viewModelFactory = testApp.daggerTestAppComponent.vmFactory()
            return activity
        }
    }

    @Suppress("MemberVisibilityCanBePrivate")
    @get:Rule
    val activityRule = ActivityTestRule(injectedFactory,false, false)

    private lateinit var viewModel:LoginViewModel
    private val user = MutableLiveData<Resource<User>>()

    @Before
    @Throws(Throwable::class)
    fun init() {
        viewModel = Mockito.mock(LoginViewModel::class.java)
        `when`(viewModel.user).thenReturn(user)
        doNothing().`when`(viewModel).setLogin(anyString(), anyString())

        val intent = Intent(InstrumentationRegistry.getTargetContext(), LoginActivity::class.java)
        activityRule.launchActivity(intent)
        EspressoTestUtil.disableProgressBarAnimations(activityRule)
    }

    @Test
    fun loading(){
        //When: we are in a loading state
        user.postValue(Resource.loading(null))
        //Then: our progress bar is showing, with login text
        onView(withId(R.id.progress_bar)).check(matches(isDisplayed()))
    }
}
kekefigure commented 6 years ago

If you use the same vm factory as in the normal app module, then the normal factory gets injected every time by the AppInjector(which then overrides the mock instance).

If you want to use the normal vm factory and mocks just for specific test cases, then you need to register an ActivityLifecycleCallbacks in the TestApp class.

TestApp.class

public class TestApp {
  public ViewModelProvider.Factory viewModelFactory;
    TestApp.this.registerActivityLifecycleCallbacks({
   ....
            public void onActivityCreated(Activity activity, Bundle savedInstanceState) {

                if (activity instanceof LoginActivity) {
                    LoginActivity loginActivity = (LoginActivity) activity;

                    if (viewModelFactory != null) {
                        loginActivity.viewModelFactory = viewModelFactory;
                    }
                }
            }
   })

}

In tests where mock instance needed:

@Before
public void init() {
    //reset before every test, so it doesn't override the injected one
    testApp.viewModelFactory = null;
    when(mockedLoginViewModelInstance).thenReturn(...);
}

//test where you need a mocked instance
@Test
public void someTest(){
    testAppInstance.viewModelFactory = ViewModelUtil.createFor(mockedLoginViewModelInstance);
}

So basically every test uses the same vm factory as in your normal app, but you can specify a mock instance where it is needed.

Hope it helps!

chasel commented 6 years ago

@kekefigure where is under package the TestAppComponent and TestApp class ? or androidTest ?

kekefigure commented 6 years ago

Both class are belong to androidTest. TestAppComponent extends the AppComponent and TestApp extends the custom Application class.

chasel commented 6 years ago

@kekefigure i haven't understand, can you open source the sample in the case ?

kekefigure commented 6 years ago

@chasel can you create a sample project which is based on your project structure?

chasel commented 6 years ago
screen shot 2018-06-04 at 13 51 08 screen shot 2018-06-04 at 13 52 55

@kekefigure ViewModelFactory can't inject in the RegChannelNewActivityTest class.

danielwilson1702 commented 6 years ago

Just wanted to mention I did manage to do this thanks to @kekefigure 's good answer. It still had problems and I have a theory a good reason for Google now advocating a single Activity approach to development is partially due to the weird edge cases and non trivial boiler plate to test VMs in Activities when Dagger is involved.

The answer required mocking the ViewModel, App, App Injector and VM Factory and overriding onActivityCreated in the App Injector, and while the tests were fine, it broke the production code at which point I stopped fighting it and converted everything to a bloomin' Fragment so I don't have to worry about this any more 😄

aumarbello commented 5 years ago

@danielwilson1702 Can you please post your final working code? I'm currently trying to mock an activity's viewmodel and haven't been able to achieve it.