I have been wrestling with a legacy Android app, attempting to add testing and proper architecture to it. The app has a main LaunchActivity
which runs a series of checks on launch. Originally, the activity was using Dagger to, rather poorly, 'inject dependencies' that the activity would use to run checks.
I shifted gears to MVVM, so that I could test the view model separately, without instrumentation, and would only need to inject a mocked view model for UI tests. I followed this article to introduce the changes, including switching to using the new Dagger Android methods like AndroidInjection.inject
.
I want the tests to guide any changes as much as I can, so when I had the basic architecture working, I switched to writing UI tests. Now, having to inject a mock view model into the activity with Dagger was proving to be quite a task, but I think I have reached a workable solution.
I was already using a TestApp
with a custom instrumentation runner to use DexOpener
, which I changed to also implement HasActivityInjector
, much like the actual custom App
for my application (both extend Application
).
For Dagger, I created separate modules and a component for testing:
TestAppComponent
@Component(
modules = [
TestDepsModule::class,
TestViewModelModule::class,
TestAndroidContributorModule::class,
AndroidSupportInjectionModule::class
]
)
@Singleton
interface TestAppComponent {
@Component.Builder
interface Builder {
@BindsInstance
fun application(application: Application): Builder
fun testViewModelModule(testViewModelModule: TestViewModelModule): Builder
fun build(): TestAppComponent
}
fun inject(app: TestFieldIApp)
}
TestViewModelModule
@Module
class TestViewModelModule {
lateinit var mockLaunchViewModel: LaunchViewModel
@Provides
fun bindViewModelFactory(factory: TestViewModelFactory): ViewModelProvider.Factory {
return factory
}
@Provides
@IntoMap
@ViewModelKey(LaunchViewModel::class)
fun launchViewModel(): ViewModel {
if(!(::mockLaunchViewModel.isInitialized)) {
mockLaunchViewModel = mock(LaunchViewModel::class.java)
}
return mockLaunchViewModel
}
}
TestAndroidConributorModule
@Module
abstract class TestAndroidContributorModule {
@ContributesAndroidInjector
abstract fun contributeLaunchActivity(): LaunchActivity
}
Then, in the LaunchActivityTest
, I have:
@RunWith(AndroidJUnit4::class)
class LaunchActivityTest {
@Rule
@JvmField
val activityRule: ActivityTestRule<LaunchActivity> = ActivityTestRule(LaunchActivity::class.java, true, false)
lateinit var viewModel: LaunchViewModel
@Before
fun init() {
viewModel = mock(LaunchViewModel::class.java)
val testApp: TestLegacyApp = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as TestLegacyApp
val testViewModelModule: TestViewModelModule = TestViewModelModule()
testViewModelModule.mockLaunchViewModel = viewModel
DaggerTestAppComponent
.builder()
.application(testApp)
.testViewModelModule(testViewModelModule)
.build()
.inject(testApp)
}
@Test
fun whenHideInstructionsIsFalse_showsInstructions() {
`when`(viewModel.hideInstructions).thenReturn(false)
activityRule.launchActivity(null)
onView(withId(R.id.launch_page_slider)).check(matches(isDisplayed()))
onView(withId(R.id.launch_progress_view)).check(matches(not(isDisplayed())))
}
@Test
fun whenHideInstructionsIsTrue_doesNotShowInstructions() {
`when`(viewModel.hideInstructions).thenReturn(true)
activityRule.launchActivity(null)
onView(withId(R.id.launch_page_slider)).check(matches(not(isDisplayed())))
onView(withId(R.id.launch_progress_view)).check(matches(isDisplayed()))
}
}
The result is that the view model is being properly mocked, so everything else should work... But, when the Espresso tests are run, although the tests show that they have passed, there is a strange stack trace where the (passing) view assertions ought to be.
E/System: Unable to open zip file: /data/user/0/com.myapps.android.legacyapp/cache/qZb3CT3H.jar
E/System: java.io.FileNotFoundException: File doesn't exist: /data/user/0/com.myapps.android.legacyapp/cache/qZb3CT3H.jar
at java.util.zip.ZipFile.<init>(ZipFile.java:215)
at java.util.zip.ZipFile.<init>(ZipFile.java:152)
at java.util.jar.JarFile.<init>(JarFile.java:160)
at java.util.jar.JarFile.<init>(JarFile.java:97)
at libcore.io.ClassPathURLStreamHandler.<init>(ClassPathURLStreamHandler.java:47)
at dalvik.system.DexPathList$Element.maybeInit(DexPathList.java:702)
at dalvik.system.DexPathList$Element.findResource(DexPathList.java:729)
at dalvik.system.DexPathList.findResources(DexPathList.java:526)
at dalvik.system.BaseDexClassLoader.findResources(BaseDexClassLoader.java:174)
at java.lang.ClassLoader.getResources(ClassLoader.java:839)
at java.util.ServiceLoader$LazyIterator.hasNextService(ServiceLoader.java:349)
at java.util.ServiceLoader$LazyIterator.hasNext(ServiceLoader.java:402)
at java.util.ServiceLoader$1.hasNext(ServiceLoader.java:488)
at androidx.test.internal.platform.ServiceLoaderWrapper.loadService(ServiceLoaderWrapper.java:46)
at androidx.test.espresso.base.UiControllerModule.provideUiController(UiControllerModule.java:42)
at androidx.test.espresso.base.UiControllerModule_ProvideUiControllerFactory.provideUiController(UiControllerModule_ProvideUiControllerFactory.java:36)
at androidx.test.espresso.base.UiControllerModule_ProvideUiControllerFactory.get(UiControllerModule_ProvideUiControllerFactory.java:26)
at androidx.test.espresso.base.UiControllerModule_ProvideUiControllerFactory.get(UiControllerModule_ProvideUiControllerFactory.java:9)
at androidx.test.espresso.core.internal.deps.dagger.internal.DoubleCheck.get(DoubleCheck.java:51)
at androidx.test.espresso.DaggerBaseLayerComponent$ViewInteractionComponentImpl.viewInteraction(DaggerBaseLayerComponent.java:239)
at androidx.test.espresso.Espresso.onView(Espresso.java:84)
at com.myapps.android.legacyapp.tests.ui.launch.LaunchActivityTest.whenHideInstructionsIsFalse_showsInstructions(LaunchActivityTest.kt:64)
The statement in LaunchActivityTest
where the error traces to is:
onView(withId(R.id.launch_page_slider)).check(matches(isDisplayed()))
I can't figure out why the test is showing this error. I know it's something related to Dagger, because if I comment out building DaggerTestAppComponent
, there is no issue. But, without using this test component, I'm not sure how I can inject the mocked view model into the activity. Something is causing Dagger and Espresso to not play nicely, something, I think, related to this DaggerBaseLayerComponent
in the stack trace. But I have nothing else.
The only 'solution' I have presently is switching to a Fragment instead of an Activity, where I could skip the need for Dagger in tests altogether and follow this sample, but I'm really baffled as to why I'm getting this issue. I would greatly appreciate any help in finding out the reason.
This is neither based upon a non-stock ROM nor Espresso or Dagger, but it's a known issue,
which appears to stem from the androidx.test.runner.AndroidJUnitRunner
in combintion with ActivityScenario
or FragmentScenario
(as the stack-traces posted there may suggest).
Not using an ActivityTestRule
might currently be the only option to work around this. Just star the issue on the issue-tracker and get notified, when something moves forward concerning the issue.
Weirdly enough I sometimes managed to resolve this by just uninstalling all app and test related stuff on my device OR by just using a new emulator image and then re-running/installing everything in a clean new state. I'm guessing that it had something to do with my dynamic feature module setup and Android Studio not properly (re)installing all necessary APKs (where resource ID's might have been reshuffled after code changes?).
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With