I’ve recently started a project with the sole purpose of demo-ing a modular clean architecture to my team. In this architecture, we have different modules per feature. For example, the demo project had a list fragment and a details fragment and each one lived in a separate module.
Dagger Android was also used to inject dependencies to both fragments, which means that I will usually have something like this:
class ListFragment: Fragment(R.layout.fragment_list) {
fun onAttached(....) {
AndroidSupportInjection.inject(this)
super.onAttach(...)
}
}
This meant that I had to figure out a way to make AndroidSupportInjection.inject
work.
How does AndroidSupportInjection.inject
work?
If we dig into the source code of this function, you will quickly see another private function named findHasAndroidInjectorForFragment
. Examining this code tells us that Dagger looks into possible parents of the calling fragment to find out which one holds an AndroidInjector
for it.
private static HasAndroidInjector findHasAndroidInjectorForFragment(Fragment fragment) {
Fragment parentFragment = fragment;
while ((parentFragment = parentFragment.getParentFragment()) != null) {
if (parentFragment instanceof HasAndroidInjector) {
return (HasAndroidInjector) parentFragment;
}
}
Activity activity = fragment.getActivity();
if (activity instanceof HasAndroidInjector) {
return (HasAndroidInjector) activity;
}
if (activity.getApplication() instanceof HasAndroidInjector) {
return (HasAndroidInjector) activity.getApplication();
}
throw new IllegalArgumentException(
String.format("No injector was found for %s", fragment.getClass().getCanonicalName()));
}
This information is usually stored in another class: DispatchingAndroidInjector
, if we look more into it we find that it stores this information in a Map
.
// DispatchingAndroidInjector constructor
DispatchingAndroidInjector(
Map<Class<?>, Provider<AndroidInjector.Factory<?>>> injectorFactoriesWithClassKeys,
Map<String, Provider<AndroidInjector.Factory<?>>> injectorFactoriesWithStringKeys) {
this.injectorFactories = merge(injectorFactoriesWithClassKeys, injectorFactoriesWithStringKeys);
}
So when a fragment calls AndroidSupportInjection.inject
, Dagger will look into the nearest parent that can provide AndroidInjector
for this fragment and then uses this AndroidInjector
to inject dependencies.
It’s important to bear in mind that Dagger could potentially have more logic than this, but that’s all we need know for setting up instrumentation tests.
Setting up our test
After looking into how the AndroidSupportInjection.inject
behaves, let’s have a look at building an instrumentation test setup that will allow us to test any dagger injected fragment.
Creating a Host Activity
The logic of findHasAndroidInjectorForFragment
tells us that the fragment under test needs to have a parent. This parent needs to implement HasAndroidInjector
and provide a DispatchingAndroidInjector
for dagger. So a starting point for this test setup would be implementing a TestActivity
which would host the fragment under test.
class TestActivity : FragmentActivity(), HasAndroidInjector {
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
override fun androidInjector(): AndroidInjector<Any> {
return dispatchingAndroidInjector
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(
FragmentContainerView(this).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
id = R.id.test_fragment_container
}
)
}
}
Don’t forget to create an AndroidManifest.xml
in androidTest/
folder and declare TestActivity
.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="me.amryousef.sample.lib.ui.testing">
<application>
<activity android:theme="@style/Theme.MyApp" android:name="me.amryousef.lib.ui.test.TestActivity" />
</application>
</manifest>
Next, we need to add some logic to pass in the type of fragment under test, logic to inject dependencies in this fragment and finally, logic to display the fragment and test it.
ActivityScenario
, in androidx.test:core:1.0.0
, is a simple tool we can use to launch and interact with an Activity
in instrumentation. This means that our fragment test can start with:
val fragment = ListFragment()
val scenario = ActivityScenario.launch(TestActivity::class.java)
scenario.onActivity { activity ->
activity.supportFragmentManager.beginTransaction()
.replace(R.id.test_fragment_container, fragment)
.commitAllowingStateLoss()
}
As it stands, this logic will throw an exception since dispatchingAndroidInjector
in TestActivity
hasn’t been initialised yet. However, since we have access to the activity instance and we understand what dagger expects, we can easily add this part.
Faking Dagger Injection
Let’s start by creating DispatchingAndroidInjector
and for this we can use DispatchingAndroidInjector_Factory
to build it.
DispatchingAndroidInjector_Factory
constructor looks like
DispatchingAndroidInjector_Factory(
Provider<Map<Class<?>, Provider<AndroidInjector.Factory<?>>>> injectorFactoriesWithClassKeysProvider,
Provider<Map<String, Provider<AndroidInjector.Factory<?>>>> injectorFactoriesWithStringKeysProvider)
This might seem complicated but it’s actually pretty simple so let’s break it down.
The constructor takes two providers. Each provider provides a Map. Both provided maps has a provider of an AndroidInjector.Factory
which dagger will use to create AndroidInjector
.
Provider
is just a simple interface with get
method
public interface Provider<T> {
/**
* Provides a fully-constructed and injected instance of {@code T}.
*
* @throws RuntimeException if the injector encounters an error while
* providing an instance. For example, if an injectable member on
* {@code T} throws an exception, the injector may wrap the exception
* and throw it to the caller of {@code get()}. Callers should not try
* to handle such exceptions as the behaviour may vary across injector
* implementations and even different configurations of the same injector.
*/
T get();
}
So for our TestActivity
, we need Provider<SOME_KEY, Provider<AndroidInject.Factory<ListFragment>>>
. The SOME_KEY
here represents the keys dagger uses in its code which are Class<?>
and String
. We can start by building Provider<AndroidInject.Factory<ListFragment>>
val provider: Provider<AndroidInjector.Factory<*>> = Provider {
AndroidInjector.Factory<ListFragment> {
AndroidInjector<ListFragment> { instance ->
instance.injector()
}
}
}
We can then create the two maps dagger is expecting:
val stringKeyMap = mutableMapOf(ListFragment::class.java.name to provider)
val classKeyMap = mutableMapOf(ListFragment::class.java to provider)
And finally create the DispatchingAndroidInjector
:
val dispatchingAndroidInjector = DispatchingAndroidInjector_Factory<Any>(stringKeyMap, classKeyMap).get()
Tying it all together with the ActivityScenario
it can look like:
val scenario = ActivityScenario.launch(TestActivity::class.java)
scenario.onActivity { activity ->
val provider: Provider<AndroidInjector.Factory<*>> = Provider {
AndroidInjector.Factory<ListFragment> {
AndroidInjector<ListFragment> { instance ->
instance.injector()
}
}
}
val stringKeyMap = mutableMapOf(ListFragment::class.java.name to provider)
val classKeyMap = mutableMapOf(ListFragment::class.java to provider)
activity.dispatchingAndroidInjector = DispatchingAndroidInjector_Factory<Any>(stringKeyMap, classKeyMap).get()
}
val fragment = ListFragment()
scenario.onActivity { activity ->
activity.supportFragmentManager.beginTransaction()
.replace(R.id.test_fragment_container, fragment)
.commitAllowingStateLoss()
}
To make this logic more reusable we can:
- Create
ActivityScenarioWrapper
- Split dagger injection setup into its own generic function
- Split displaying the fragment logic into its own generic function
Which leads us to an implementation looking like this:
class ActivityScenarioWrapper {
val scenario = ActivityScenario.launch(TestActivity::class.java)
inline fun <reified F : Fragment> injectFragment(
crossinline injector: F.() -> Unit
) {
val provider: Provider<AndroidInjector.Factory<*>> = Provider {
AndroidInjector.Factory<F> {
AndroidInjector<F> { instance ->
instance.injector()
}
}
}
scenario.onActivity {
it.dispatchingAndroidInjector = DispatchingAndroidInjector_Factory<Any>(
{ mutableMapOf(F::class.java to provider) },
{ mutableMapOf(F::class.java.canonicalName to provider) }
).get()
}
}
fun displayFragment(fragment: Fragment) {
scenario.onActivity {
it.supportFragmentManager.beginTransaction()
.replace(R.id.test_fragment_container, fragment)
.commitAllowingStateLoss()
}
}
}
Writing Fragment Test
We can write a working fragment test. However, it looks verbose and cumbersome to write.
@RunWith(AndroidJUnit4::class)
class ListFragmentTest {
@Test
fun firstTest() {
val scenarioWrapper = ActivityScenarioWrapper()
scenarioWrapper.injectFragment<ListFragment> {
// Perform Injection
}
scenarioWrapper.displayFragment(ListFragment())
// Test Logic
}
}
We can simplify all this by wrapping it in our fragmentTest
function which we can reuse to setup tests for any dagger injected fragment.
inline fun <reified F : Fragment> fragmentTest(
bundle: Bundle? = null,
crossinline injector: (fragment: F) -> Unit,
crossinline test: (scenario: ActivityScenario<TestActivity>) -> Unit
) {
val scenarioWrapper = ActivityScenarioWrapper()
scenarioWrapper.scenario.onActivity {
activityScenario.injectFragment<F> {
injector(this)
}
}
scenarioWrapper.scenario.onActivity {
val fragment = F::class.java.newInstance().apply {
arguments = bundle
}
scenarioWrapper.displayFragment(fragment)
}
test(activityScenario.scenario)
scenarioWrapper.scenario.close()
}
You can find the complete solution and examples of how I used this setup in my sample repo on Github. All the logic you’ve seen here is under lib-ui-test
module.