EDIT: Watch out! I have deleted the old repository reffered to in this question. See my own answer to the question for a possible solution and feel free to improve it!
I am refering to my post here. Now I came a little further. I am also refering to my two branches within my github Project:
- Experimental [branch no. 1] (repository deleted)
- Experimental [branch no. 2] (repository deleted)
In the old post I tried to swap components to test-components within an Instrumentation Test. This works now if I have an ApplicationComponent
, being in singleton scope. But it does not work if I have an ActivityComponent
with a self defined @PerActivity
scope. The problem is not the scope but the swapping of the Component to the TestComponent.
My ActivityComponent
has an ActivityModule
:
@PerActivity
@Component(modules = ActivityModule.class)
public interface ActivityComponent {
// TODO: Comment this out for switching back to the old approach
void inject(MainFragment mainFragment);
// TODO: Leave that for witching to the new approach
void inject(MainActivity mainActivity);
}
ActivityModule
provides a MainInteractor
@Module
public class ActivityModule {
@Provides
@PerActivity
MainInteractor provideMainInteractor () {
return new MainInteractor();
}
}
My TestActivityComponent
uses a TestActivityModule
:
@PerActivity
@Component(modules = TestActivityModule.class)
public interface TestActivityComponent extends ActivityComponent {
void inject(MainActivityTest mainActivityTest);
}
TestActvityModule
provides a FakeInteractor
:
@Module
public class TestActivityModule {
@Provides
@PerActivity
MainInteractor provideMainInteractor () {
return new FakeMainInteractor();
}
}
My MainActivity
has a getComponent()
method and a setComponent()
method. With the latter you can swap the component to a test component within the Instrumentation Test. Here is the activity:
public class MainActivity extends BaseActivity implements MainFragment.OnFragmentInteractionListener {
private static final String TAG = "MainActivity";
private Fragment currentFragment;
private ActivityComponent activityComponent;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
initializeInjector();
if (savedInstanceState == null) {
currentFragment = new MainFragment();
addFragment(R.id.fragmentContainer, currentFragment);
}
}
private void initializeInjector() {
Log.i(TAG, "injectDagger initializeInjector()");
activityComponent = DaggerActivityComponent.builder()
.activityModule(new ActivityModule())
.build();
activityComponent.inject(this);
}
@Override
public void onFragmentInteraction(final Uri uri) {
}
ActivityComponent getActivityComponent() {
return activityComponent;
}
@VisibleForTesting
public void setActivityComponent(ActivityComponent activityComponent) {
Log.w(TAG, "injectDagger Only call this method to swap test doubles");
this.activityComponent = activityComponent;
}
}
As you see this activity uses a MainFragment
. In onCreate()
of the fragment the component is injected:
public class MainFragment extends BaseFragment implements MainView {
private static final String TAG = "MainFragment";
@Inject
MainPresenter mainPresenter;
private View view;
public MainFragment() {
// Required empty public constructor
}
@Override
public void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "injectDagger onCreate()");
super.onCreate(savedInstanceState);
// TODO: That approach works
// ((AndroidApplication)((MainActivity) getActivity()).getApplication()).getApplicationComponent().inject(this);
// TODO: This approach is NOT working, see MainActvityTest
((MainActivity) getActivity()).getActivityComponent().inject(this);
}
}
And then in the test I swap the ActivityComponent
with the TestApplicationComponent
:
public class MainActivityTest{
@Rule
public ActivityTestRule<MainActivity> mActivityRule = new ActivityTestRule(MainActivity.class, true, false);
private MainActivity mActivity;
private TestActivityComponent mTestActivityComponent;
// TODO: That approach works
// private TestApplicationComponent mTestApplicationComponent;
//
// private void initializeInjector() {
// mTestApplicationComponent = DaggerTestApplicationComponent.builder()
// .testApplicationModule(new TestApplicationModule(getApp()))
// .build();
//
// getApp().setApplicationComponent(mTestApplicationComponent);
// mTestApplicationComponent.inject(this);
// }
// TODO: This approach does NOT work because mActivity.setActivityComponent() is called after MainInteractor has already been injected!
private void initializeInjector() {
mTestActivityComponent = DaggerTestActivityComponent.builder()
.testActivityModule(new TestActivityModule())
.build();
mActivity.setActivityComponent(mTestActivityComponent);
mTestActivityComponent.inject(this);
}
public AndroidApplication getApp() {
return (AndroidApplication) InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext();
}
// TODO: That approach works
// @Before
// public void setUp() throws Exception {
//
// initializeInjector();
// mActivityRule.launchActivity(null);
// mActivity = mActivityRule.getActivity();
// }
// TODO: That approach does not works because mActivity.setActivityComponent() is called after MainInteractor has already been injected!
@Before
public void setUp() throws Exception {
mActivityRule.launchActivity(null);
mActivity = mActivityRule.getActivity();
initializeInjector();
}
@Test
public void testOnClick_Fake() throws Exception {
onView(withId(R.id.edittext)).perform(typeText("John"));
onView(withId(R.id.button)).perform(click());
onView(withId(R.id.textview_greeting)).check(matches(withText(containsString("Hello Fake"))));
}
@Test
public void testOnClick_Real() throws Exception {
onView(withId(R.id.edittext)).perform(typeText("John"));
onView(withId(R.id.button)).perform(click());
onView(withId(R.id.textview_greeting)).check(matches(withText(containsString("Hello John"))));
}
}
The Activity test runs but the wrong Component
is used. This is because activities and fragments onCreate()
is run before the component is swapped.
As you can see I have an commented old approach were I bind an ApplicationComponent
to the application class. This works because I can build the dependency before starting the activity. But now with the ActivityComponent
I have to launch the activity before initializing the injector. Because otherwise I could not set
mActivity.setActivityComponent(mTestActivityComponent);
because mActivity
would be null if would launch the activity after the initialization of the injector. (See MainActivityTest
)
So how could I intercept the MainActivity
and the MainFragment
to use the TestActivityComponent
?
Now I found out by mixing some examples how to exchange an Activity-scoped component and a Fragment-scoped component. In this post I will show you how to do both. But I will describe in more detail how to swap a Fragment-scoped component during an InstrumentationTest. My total code is hosted on github. You can run the MainFragmentTest
class but be aware that you have to set de.xappo.presenterinjection.runner.AndroidApplicationJUnitRunner
as TestRunner in Android Studio.
Now I describe shortly what to do to swap an Interactor by a Fake Interactor. In the example I try to respect clean architecture as much as possible. But they may be some small things which break this architecture a bit. So feel free to improve.
So, let's start. At first you need an own JUnitRunner:
/**
* Own JUnit runner for intercepting the ActivityComponent injection and swapping the
* ActivityComponent with the TestActivityComponent
*/
public class AndroidApplicationJUnitRunner extends AndroidJUnitRunner {
@Override
public Application newApplication(ClassLoader classLoader, String className, Context context)
throws InstantiationException, IllegalAccessException, ClassNotFoundException {
return super.newApplication(classLoader, TestAndroidApplication.class.getName(), context);
}
@Override
public Activity newActivity(ClassLoader classLoader, String className, Intent intent)
throws InstantiationException, IllegalAccessException, ClassNotFoundException {
Activity activity = super.newActivity(classLoader, className, intent);
return swapActivityGraph(activity);
}
@SuppressWarnings("unchecked")
private Activity swapActivityGraph(Activity activity) {
if (!(activity instanceof HasComponent) || !TestActivityComponentHolder.hasComponentCreator()) {
return activity;
}
((HasComponent<ActivityComponent>) activity).
setComponent(TestActivityComponentHolder.getComponent(activity));
return activity;
}
}
In swapActivityGraph()
I create an alternative TestActivityGraph for the Activity before(!) the Activity is created when running the test. Then we have to create a TestFragmentComponent
:
@PerFragment
@Component(modules = TestFragmentModule.class)
public interface TestFragmentComponent extends FragmentComponent{
void inject(MainActivityTest mainActivityTest);
void inject(MainFragmentTest mainFragmentTest);
}
This component lives in a Fragment-scope. It has a module:
@Module
public class TestFragmentModule {
@Provides
@PerFragment
MainInteractor provideMainInteractor () {
return new FakeMainInteractor();
}
}
The original FragmentModule
looks like that:
@Module
public class FragmentModule {
@Provides
@PerFragment
MainInteractor provideMainInteractor () {
return new MainInteractor();
}
}
You see I use a MainInteractor
and a FakeMainInteractor
. They both look like that:
public class MainInteractor {
private static final String TAG = "MainInteractor";
public MainInteractor() {
Log.i(TAG, "constructor");
}
public Person createPerson(final String name) {
return new Person(name);
}
}
public class FakeMainInteractor extends MainInteractor {
private static final String TAG = "FakeMainInteractor";
public FakeMainInteractor() {
Log.i(TAG, "constructor");
}
public Person createPerson(final String name) {
return new Person("Fake Person");
}
}
Now we use a self-defined FragmentTestRule
for testing the Fragment independent from the Activity which contains it in production:
public class FragmentTestRule<F extends Fragment> extends ActivityTestRule<TestActivity> {
private static final String TAG = "FragmentTestRule";
private final Class<F> mFragmentClass;
private F mFragment;
public FragmentTestRule(final Class<F> fragmentClass) {
super(TestActivity.class, true, false);
mFragmentClass = fragmentClass;
}
@Override
protected void beforeActivityLaunched() {
super.beforeActivityLaunched();
try {
mFragment = mFragmentClass.newInstance();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
@Override
protected void afterActivityLaunched() {
super.afterActivityLaunched();
//Instantiate and insert the fragment into the container layout
FragmentManager manager = getActivity().getSupportFragmentManager();
FragmentTransaction transaction = manager.beginTransaction();
transaction.replace(R.id.fragmentContainer, mFragment);
transaction.commit();
}
public F getFragment() {
return mFragment;
}
}
That TestActivity
is very simple:
public class TestActivity extends BaseActivity implements
HasComponent<ActivityComponent> {
@Override
protected void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
FrameLayout frameLayout = new FrameLayout(this);
frameLayout.setId(R.id.fragmentContainer);
setContentView(frameLayout);
}
}
But now how to swap the components? There are several small tricks to achieve that. At first we need a holder class for holding the TestFragmentComponent
:
/**
* Because neither the Activity nor the ActivityTest can hold the TestActivityComponent (due to
* runtime order problems we need to hold it statically
**/
public class TestFragmentComponentHolder {
private static TestFragmentComponent sComponent;
private static ComponentCreator sCreator;
public interface ComponentCreator {
TestFragmentComponent createComponent(Fragment fragment);
}
/**
* Configures an ComponentCreator that is used to create an activity graph. Call that in @Before.
*
* @param creator The creator
*/
public static void setCreator(ComponentCreator creator) {
sCreator = creator;
}
/**
* Releases the static instances of our creator and graph. Call that in @After.
*/
public static void release() {
sCreator = null;
sComponent = null;
}
/**
* Returns the {@link TestFragmentComponent} or creates a new one using the registered {@link
* ComponentCreator}
*
* @throws IllegalStateException if no creator has been registered before
*/
@NonNull
public static TestFragmentComponent getComponent(Fragment fragment) {
if (sComponent == null) {
checkRegistered(sCreator != null, "no creator registered");
sComponent = sCreator.createComponent(fragment);
}
return sComponent;
}
/**
* Returns true if a custom activity component creator was configured for the current test run,
* false otherwise
*/
public static boolean hasComponentCreator() {
return sCreator != null;
}
/**
* Returns a previously instantiated {@link TestFragmentComponent}.
*
* @throws IllegalStateException if none has been instantiated
*/
@NonNull
public static TestFragmentComponent getComponent() {
checkRegistered(sComponent != null, "no component created");
return sComponent;
}
}
The second trick is to use the holder to register the component before the fragment is even created. Then we launch the TestActivity
with our FragmentTestRule
. Now comes the third trick which is timing-dependent and does not always run correctly. Directly after launching the activity we get the Fragment
instance by asking the FragmentTestRule
. Then we swap the component, using the TestFragmentComponentHolder
and inject the Fragment graph. The forth trick is we just wait for about 2 seconds for the Fragment to be created. And within the Fragment we make our component injection in onViewCreated()
. Because then we don't inject the component to early because onCreate()
and onCreateView()
are called before. So here is our MainFragment
:
public class MainFragment extends BaseFragment implements MainView {
private static final String TAG = "MainFragment";
@Inject
MainPresenter mainPresenter;
private View view;
// TODO: Rename and change types and number of parameters
public static MainFragment newInstance() {
MainFragment fragment = new MainFragment();
return fragment;
}
public MainFragment() {
// Required empty public constructor
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//((MainActivity)getActivity()).getComponent().inject(this);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
view = inflater.inflate(R.layout.fragment_main, container, false);
return view;
}
public void onClick(final String s) {
mainPresenter.onClick(s);
}
@Override
public void onViewCreated(final View view, @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
getComponent().inject(this);
final EditText editText = (EditText) view.findViewById(R.id.edittext);
Button button = (Button) view.findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(final View v) {
MainFragment.this.onClick(editText.getText().toString());
}
});
mainPresenter.attachView(this);
}
@Override
public void updatePerson(final Person person) {
TextView textView = (TextView) view.findViewById(R.id.textview_greeting);
textView.setText("Hello " + person.getName());
}
@Override
public void onDestroy() {
super.onDestroy();
mainPresenter.detachView();
}
public interface OnFragmentInteractionListener {
void onFragmentInteraction(Uri uri);
}
}
And all the steps (second to forth trick) which I described before can be found in the @Before
annotated setUp()
-Method in this MainFragmentTest
class:
public class MainFragmentTest implements
InjectsComponent<TestFragmentComponent>, TestFragmentComponentHolder.ComponentCreator {
private static final String TAG = "MainFragmentTest";
@Rule
public FragmentTestRule<MainFragment> mFragmentTestRule = new FragmentTestRule<>(MainFragment.class);
public AndroidApplication getApp() {
return (AndroidApplication) InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext();
}
@Before
public void setUp() throws Exception {
TestFragmentComponentHolder.setCreator(this);
mFragmentTestRule.launchActivity(null);
MainFragment fragment = mFragmentTestRule.getFragment();
if (!(fragment instanceof HasComponent) || !TestFragmentComponentHolder.hasComponentCreator()) {
return;
} else {
((HasComponent<FragmentComponent>) fragment).
setComponent(TestFragmentComponentHolder.getComponent(fragment));
injectFragmentGraph();
waitForFragment(R.id.fragmentContainer, 2000);
}
}
@After
public void tearDown() throws Exception {
TestFragmentComponentHolder.release();
mFragmentTestRule = null;
}
@SuppressWarnings("unchecked")
private void injectFragmentGraph() {
((InjectsComponent<TestFragmentComponent>) this).injectComponent(TestFragmentComponentHolder.getComponent());
}
protected Fragment waitForFragment(@IdRes int id, int timeout) {
long endTime = SystemClock.uptimeMillis() + timeout;
while (SystemClock.uptimeMillis() <= endTime) {
Fragment fragment = mFragmentTestRule.getActivity().getSupportFragmentManager().findFragmentById(id);
if (fragment != null) {
return fragment;
}
}
return null;
}
@Override
public TestFragmentComponent createComponent(final Fragment fragment) {
return DaggerTestFragmentComponent.builder()
.testFragmentModule(new TestFragmentModule())
.build();
}
@Test
public void testOnClick_Fake() throws Exception {
onView(withId(R.id.edittext)).perform(typeText("John"));
onView(withId(R.id.button)).perform(click());
onView(withId(R.id.textview_greeting)).check(matches(withText(containsString("Hello Fake"))));
}
@Test
public void testOnClick_Real() throws Exception {
onView(withId(R.id.edittext)).perform(typeText("John"));
onView(withId(R.id.button)).perform(click());
onView(withId(R.id.textview_greeting)).check(matches(withText(containsString("Hello John"))));
}
@Override
public void injectComponent(final TestFragmentComponent component) {
component.inject(this);
}
}
Except from the timing problem. This test runs in my environment in 10 of 10 test runs on an emulated Android with API Level 23. And it runs in 9 of 10 test runs on a real Samsung Galaxy S5 Neo device with Android 6.
As I wrote above you can download the whole example from github and feel free to improve if you find a way to fix the little timing problem.
That's it!