Как я могу поменять местами тестовые двойники в рамках Activity или Fragment с помощью Dagger 2?

РЕДАКТИРОВАТЬ: Будьте осторожны! Я удалил старый репозиторий, на который ссылается этот вопрос. Посмотрите мой собственный ответ на вопрос о возможном решении и не стесняйтесь улучшать его!

Я имею в виду свой пост здесь. Теперь я пришел немного дальше. Я также имею в виду две мои ветки в моем проекте github:

  • Экспериментальный [отрасль №. 1] (репозиторий удален)
  • Экспериментальный [отрасль №. 2] (репозиторий удален)

В старом посте я пытался поменять компоненты на тестовые компоненты в инструментальном тесте. Теперь это работает, если у меня есть ApplicationComponent, находящийся в одноэлементной области. Но это не работает, если у меня есть ActivityComponent с самоопределяемой областью действия @PerActivity. Проблема заключается не в области действия, а в замене компонента на TestComponent.

У моего ActivityComponent есть 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 обеспечивает MainInteractor

@Module
public class ActivityModule {
    @Provides
    @PerActivity
    MainInteractor provideMainInteractor () {
        return new MainInteractor();
    }
}

Мой TestActivityComponent использует TestActivityModule:

@PerActivity
@Component(modules = TestActivityModule.class)
public interface TestActivityComponent extends ActivityComponent {
    void inject(MainActivityTest mainActivityTest);
}

TestActvityModule предоставляет FakeInteractor :

@Module
public class TestActivityModule {
    @Provides
    @PerActivity
    MainInteractor provideMainInteractor () {
        return new FakeMainInteractor();
    }
}

Мой MainActivity имеет метод getComponent() и метод setComponent(). С последним вы можете поменять компонент на тестовый компонент в инструментальном тесте. Вот активность:

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;
    }
} 

Как вы видите, это действие использует MainFragment. В onCreate() фрагмента вводится компонент:

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);
    }
}

А затем в тесте я меняю местами ActivityComponent на 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"))));
    }

}

Тест активности выполняется, но используется неверный Component. Это связано с тем, что действия и фрагменты onCreate() запускаются до замены компонента.

Как видите, у меня есть прокомментированный старый подход, в котором я привязываю ApplicationComponent к классу приложения. Это работает, потому что я могу построить зависимость перед запуском действия. Но теперь с ActivityComponent мне нужно запустить активность перед инициализацией инжектора. Потому что иначе я не мог бы установить

mActivity.setActivityComponent(mTestActivityComponent);

потому что mActivity будет нулевым, если запустит активность после инициализации инжектора. (См. MainActivityTest)

Итак, как я мог перехватить MainActivity и MainFragment, чтобы использовать TestActivityComponent?


person unlimited101    schedule 08.11.2016    source источник
comment
Вы можете превратить это в действительно хороший вопрос, спросив: «Как я могу поменять местами тестовые двойники в рамках действия или фрагмента». В большинстве примеров, которые я видел до сих пор, есть инструкции по замене компонента области приложения, но ничего для мест внедрения ниже.   -  person David Rawson    schedule 15.11.2016
comment
Да, ты прав. Спасибо.   -  person unlimited101    schedule 16.11.2016


Ответы (1)


Теперь, смешав несколько примеров, я узнал, как обменивать компонент с областью действия и компонент с областью фрагмента. В этом посте я покажу вам, как сделать и то, и другое. Но я опишу более подробно, как поменять местами компонент с областью действия Fragment во время InstrumentationTest. Весь мой код размещен на github. Вы можете запустить класс MainFragmentTest, но имейте в виду, что вы должны установить de.xappo.presenterinjection.runner.AndroidApplicationJUnitRunner как TestRunner в Android Studio.

Теперь я вкратце опишу, что нужно сделать, чтобы поменять Interactor на Fake Interactor. В этом примере я стараюсь максимально соблюдать чистую архитектуру. Но это могут быть некоторые мелочи, которые немного ломают эту архитектуру. Так что смело совершенствуйтесь.

Итак, начнем. Сначала вам понадобится собственный 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;
    }
}

В swapActivityGraph() я создаю альтернативный TestActivityGraph для действия до того, как (!) действие будет создано при запуске теста. Затем мы должны создать TestFragmentComponent:

@PerFragment
@Component(modules = TestFragmentModule.class)
public interface TestFragmentComponent extends FragmentComponent{
    void inject(MainActivityTest mainActivityTest);

    void inject(MainFragmentTest mainFragmentTest);
}

Этот компонент находится в области фрагментов. Он имеет модуль:

@Module
public class TestFragmentModule {
    @Provides
    @PerFragment
    MainInteractor provideMainInteractor () {
        return new FakeMainInteractor();
    }
}

Оригинальный FragmentModule выглядит так:

@Module
public class FragmentModule {
    @Provides
    @PerFragment
    MainInteractor provideMainInteractor () {
        return new MainInteractor();
    }
}

Видите ли, я использую MainInteractor и FakeMainInteractor. Они оба выглядят так:

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");
    }
}

Теперь мы используем самоопределяемый FragmentTestRule для тестирования фрагмента независимо от действия, которое содержит его в производстве:

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;
    }
}

Это TestActivity очень просто:

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);
    }
}

Но как теперь поменять местами компоненты? Для этого есть несколько небольших хитростей. Сначала нам нужен класс держателя для хранения 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;
    }
}

Второй прием заключается в использовании держателя для регистрации компонента до создания фрагмента. Затем запускаем TestActivity с нашим FragmentTestRule. Теперь идет третий трюк, который зависит от времени и не всегда работает правильно. Непосредственно после запуска активности мы получаем экземпляр Fragment, запрашивая FragmentTestRule. Затем мы меняем компонент, используя TestFragmentComponentHolder, и вводим граф фрагментов. Четвертая хитрость заключается в том, что мы просто ждем около 2 секунд, пока будет создан фрагмент. И внутри Фрагмента мы делаем инъекцию нашего компонента в onViewCreated(). Потому что тогда мы не внедряем компонент раньше, потому что onCreate() и onCreateView() вызываются раньше. Итак, вот наш 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);
    }
}

И все шаги (со второго по четвертый трюк), которые я описал ранее, можно найти в @Before аннотированном setUp()-методе в этом MainFragmentTest классе:

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);
    }
}

Кроме проблемы со временем. Этот тест выполняется в моей среде в 10 из 10 тестовых прогонов на эмулированном Android с уровнем API 23. И он выполняется в 9 из 10 тестовых прогонов на реальном устройстве Samsung Galaxy S5 Neo с Android 6.

Как я писал выше, вы можете загрузить весь пример с github и не стесняйтесь улучшать его, если найдете способ чтобы исправить небольшую проблему со временем.

Вот и все!

person unlimited101    schedule 17.11.2016