Как переопределить модуль / зависимость в модульном тесте с помощью Dagger 2.0?


у меня есть простая активность Android С одной зависимости. Я вводю зависимость в действие onCreate такой:

Dagger_HelloComponent.builder()
    .helloModule(new HelloModule(this))
    .build()
    .initialize(this);

в своем ActivityUnitTestCase Я хочу переопределить зависимость с помощью Mockito mock. Я предполагаю, что мне нужно использовать тестовый модуль, который предоставляет макет, но я не могу понять, как добавить этот модуль в граф объектов.

В Кинжале 1.x это, по-видимому, сделано с чем-то вроде это:

@Before
public void setUp() {
  ObjectGraph.create(new TestModule()).inject(this);
}

что такое Кинжал 2.0 эквивалент выше?

вы можете увидеть мой проект и его модульный тест здесь на GitHub.

7 52

7 ответов:

вероятно, это больше обходной путь, что правильная поддержка переопределения тестового модуля, но это позволяет переопределить производственные модули с тестовым один. Фрагменты кода ниже показывает простой случай, когда у вас есть только один компонент и один модуль, но это должно работать для любого сценария. Это требует много шаблонного и кодового повторения, поэтому имейте это в виду. Я уверен, что в будущем будет лучший способ добиться этого.

я также создал проект с примерами для Эспрессо и Robolectric. Этот ответ основан на коде, содержащемся в проекте.

решение требует двух вещей:

  • обеспечить дополнительный сеттер для @Component
  • тестовый компонент должен расширять производственный компонент

предположим, что мы просто Application, как показано ниже:

public class App extends Application {

    private AppComponent mAppComponent;

    @Override
    public void onCreate() {
        super.onCreate();
        mAppComponent = DaggerApp_AppComponent.create();
    }

    public AppComponent component() {
        return mAppComponent;
    }

    @Singleton
    @Component(modules = StringHolderModule.class)
    public interface AppComponent {

        void inject(MainActivity activity);
    }

    @Module
    public static class StringHolderModule {

        @Provides
        StringHolder provideString() {
            return new StringHolder("Release string");
        }
    }
}

мы должны добавить дополнительный метод к App класса. Это позволяет нам заменить производство деталь.

/**
 * Visible only for testing purposes.
 */
// @VisibleForTesting
public void setTestComponent(AppComponent appComponent) {
    mAppComponent = appComponent;
}

как вы можете видеть StringHolder объект содержит значение "Release string". Этот объект вводится в MainActivity.

public class MainActivity extends ActionBarActivity {

    @Inject
    StringHolder mStringHolder;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ((App) getApplication()).component().inject(this);
    }
}

в наших тестах мы хотим предоставить StringHolder С "тестовая строка". Мы должны установить тестовый компонент в App перед MainActivity создается - потому что StringHolder вводят в onCreate обратный.

в компонентах Кинжала v2.0.0 можно расширить другие интерфейсы. Мы можем использовать это для создания нашего TestAppComponent, который выходитAppComponent.

@Component(modules = TestStringHolderModule.class)
interface TestAppComponent extends AppComponent {

}

теперь мы можем определить наши тестовые модули, например TestStringHolderModule. Последний шаг-установить тестовый компонент, используя ранее добавленный метод setter в App класса. Важно сделать это до создания.

((App) application).setTestComponent(mTestAppComponent);

кофе

для эспрессо я создал custom ActivityTestRule что позволяет поменять компонент перед созданием действия. Вы можете найти код для DaggerActivityTestRuleздесь.

образец теста с эспрессо:

@RunWith(AndroidJUnit4.class)
@LargeTest
public class MainActivityEspressoTest {

    public static final String TEST_STRING = "Test string";

    private TestAppComponent mTestAppComponent;

    @Rule
    public ActivityTestRule<MainActivity> mActivityRule =
            new DaggerActivityTestRule<>(MainActivity.class, new OnBeforeActivityLaunchedListener<MainActivity>() {
                @Override
                public void beforeActivityLaunched(@NonNull Application application, @NonNull MainActivity activity) {
                    mTestAppComponent = DaggerMainActivityEspressoTest_TestAppComponent.create();
                    ((App) application).setTestComponent(mTestAppComponent);
                }
            });

    @Component(modules = TestStringHolderModule.class)
    interface TestAppComponent extends AppComponent {

    }

    @Module
    static class TestStringHolderModule {

        @Provides
        StringHolder provideString() {
            return new StringHolder(TEST_STRING);
        }
    }

    @Test
    public void checkSomething() {
        // given
        ...

        // when
        onView(...)

        // then
        onView(...)
                .check(...);
    }
}

Robolectric

это гораздо проще с Robolectric благодаря RuntimeEnvironment.application.

тесте с Robolectric:

@RunWith(RobolectricGradleTestRunner.class)
@Config(emulateSdk = 21, reportSdk = 21, constants = BuildConfig.class)
public class MainActivityRobolectricTest {

    public static final String TEST_STRING = "Test string";

    @Before
    public void setTestComponent() {
        AppComponent appComponent = DaggerMainActivityRobolectricTest_TestAppComponent.create();
        ((App) RuntimeEnvironment.application).setTestComponent(appComponent);
    }

    @Component(modules = TestStringHolderModule.class)
    interface TestAppComponent extends AppComponent {

    }

    @Module
    static class TestStringHolderModule {

        @Provides
        StringHolder provideString() {
            return new StringHolder(TEST_STRING);
        }
    }

    @Test
    public void checkSomething() {
        // given
        MainActivity mainActivity = Robolectric.setupActivity(MainActivity.class);

        // when
        ...

        // then
        assertThat(...)
    }
}

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

трюк для "расширения" модуля заключается в создании частичного макета и макете методов поставщика, которые вы хотите переопределить.

используя Mockito:

MyModule module = Mockito.spy(new MyModule());
Mockito.doReturn("mocked string").when(module).provideString();

MyComponent component = DaggerMyComponent.builder()
        .myModule(module)
        .build();

app.setComponent(component);

Я создал в этом суть Показать полностью образец.

EDIT

оказывается, вы можете сделать это даже без частичного макета, например:

MyComponent component = DaggerMyComponent.builder()
        .myModule(new MyModule() {
            @Override public String provideString() {
                return "mocked string";
            }
        })
        .build();

app.setComponent(component);

обходной путь, предложенный @tomrozb, очень хорош и поставил меня на правильный путь, но моя проблема заключалась в том, что он выставил setTestComponent() метод в производстве Application класса. Я смог заставить это работать немного по-другому, так что мое производственное приложение не должно ничего знать о моей тестовой среде.

TL; DR-расширение класса приложения с помощью тестового приложения, которое использует тестовый компонент и модуль. Затем создайте пользовательский тестовый бегун это выполняется в тестовом приложении вместо вашего рабочего приложения.


изменить: этот метод работает только для глобальных зависимостей (обычно помечается @Singleton). Если ваше приложение имеет компоненты с разной областью действия (например, для каждого действия), вам нужно будет либо создать подклассы для каждой области, либо использовать исходный ответ @tomrozb. Спасибо @tomrozb за указание на это!


в этом примере используется AndroidJUnitRunner тестов но это, вероятно, может быть адаптировано к Robolectric и другие.

во-первых, мое заявление производства. Это выглядит примерно так:

public class MyApp extends Application {
    protected MyComponent component;

    public void setComponent() {
        component = DaggerMyComponent.builder()
                .myModule(new MyModule())
                .build();
        component.inject(this);
    }

    public MyComponent getComponent() {
        return component;
    }

    @Override
    public void onCreate() {
        super.onCreate();
        setComponent();
    }
}

таким образом, мои действия и другие классы, которые используют @Inject просто нужно позвонить что-то вроде getApp().getComponent().inject(this); чтобы ввести себя в график зависимостей.

для полноты картины, вот мой компонент:

@Singleton
@Component(modules = {MyModule.class})
public interface MyComponent {
    void inject(MyApp app);
    // other injects and getters
}

и мой модуль:

@Module
public class MyModule {
    // EDIT: This solution only works for global dependencies
    @Provides @Singleton
    public MyClass provideMyClass() { ... }

    // ... other providers
}

для тестирования среда, расширьте тестовый компонент из производственного компонента. Это то же самое, что и в ответе @tomrozb.

@Singleton
@Component(modules = {MyTestModule.class})
public interface MyTestComponent extends MyComponent {
    // more component methods if necessary
}

и модуль испытания может быть все, что угодно вы хотите. Предположительно, вы будете обрабатывать свои насмешки и прочее здесь (я использую Mockito).

@Module
public class MyTestModule {
    // EDIT: This solution only works for global dependencies
    @Provides @Singleton
    public MyClass provideMyClass() { ... }

    // Make sure to implement all the same methods here that are in MyModule, 
    // even though it's not an override.
}

Итак, самая сложная часть. Создайте тестовый класс приложения, который расширяется от вашего рабочего класса приложения, и переопределите setComponent() метод для установки тестового компонента с тестовым модулем. Обратите внимание, что это может работать только если MyTestComponent является потомком элемента MyComponent.

public class MyTestApp extends MyApp {

    // Make sure to call this method during setup of your tests!
    @Override
    public void setComponent() {
        component = DaggerMyTestComponent.builder()
                .myTestModule(new MyTestModule())
                .build();
        component.inject(this)
    }
}

убедитесь, что вы называете setComponent() на приложение, прежде чем начать свои тесты, чтобы убедиться, что график настроен правильно. Что-то вроде этого:

@Before
public void setUp() {
    MyTestApp app = (MyTestApp) getInstrumentation().getTargetContext().getApplicationContext();
    app.setComponent()
    ((MyTestComponent) app.getComponent()).inject(this)
}

и, наконец, последний недостающий кусок, чтобы переопределить TestRunner с пользовательских тестов. В моем проекте я использовал AndroidJUnitRunner но похоже, что вы можете сделайте то же самое с Robolectric.

public class TestRunner extends AndroidJUnitRunner {
    @Override
    public Application newApplication(@NonNull ClassLoader cl, String className, Context context)
            throws InstantiationException, IllegalAccessException, ClassNotFoundException {
        return super.newApplication(cl, MyTestApp.class.getName(), context);
    }
}

вы также будете иметь чтобы обновить ваш testInstrumentationRunner gradle, вот так:

testInstrumentationRunner "com.mypackage.TestRunner"

и если вы используете Android Studio, вам также нужно будет нажать кнопку изменить конфигурацию в меню Выполнить и ввести имя вашего тестового бегуна в разделе "Specific instrumentation runner".

и это все! Надеюсь, эта информация кому-то поможет :)

кажется, я нашел еще один способ, и он работает до сих пор.

во-первых, интерфейс компонента, который не является самим компонентом:

компонент MyComponent.java

interface MyComponent {
    Foo provideFoo();
}

тогда у нас есть два разных модуля: фактический и тестовый.

MyModule.java

@Module
class MyModule {
    @Provides
    public Foo getFoo() {
        return new Foo();
    }
}

TestModule.java

@Module
class TestModule {
    private Foo foo;
    public void setFoo(Foo foo) {
        this.foo = foo;
    }

    @Provides
    public Foo getFoo() {
        return foo;
    }
}

и у нас есть два компонента, чтобы использовать эти два модули:

MyRealComponent.java

@Component(modules=MyModule.class)
interface MyRealComponent extends MyComponent {
    Foo provideFoo(); // without this dagger will not do its magic
}

MyTestComponent.java

@Component(modules=TestModule.class)
interface MyTestComponent extends MyComponent {
    Foo provideFoo();
}

в приложении мы делаем это:

MyComponent component = DaggerMyRealComponent.create();
<...>
Foo foo = component.getFoo();

в то время как в тестовом коде мы используем:

TestModule testModule = new TestModule();
testModule.setFoo(someMockFoo);
MyComponent component = DaggerMyTestComponent.builder()
    .testModule(testModule).build();
<...>
Foo foo = component.getFoo(); // will return someMockFoo

проблема в том, что мы должны скопировать все методы MyModule в TestModule, но это можно сделать, имея MyModule внутри TestModule и использовать методы MyModule, если они не установлены непосредственно извне. Как это:

TestModule.java

@Module
class TestModule {
    MyModule myModule = new MyModule();
    private Foo foo = myModule.getFoo();
    public void setFoo(Foo foo) {
        this.foo = foo;
    }

    @Provides
    public Foo getFoo() {
        return foo;
    }
}

ЭТОТ ОТВЕТ УСТАРЕЛ. ЧИТАЙТЕ НИЖЕ В ПРАВКЕ.

к сожалению, ты не может распространяться из модуля, или вы получите следующую ошибку компиляции:

Error:(24, 21) error: @Provides methods may not override another method.
Overrides: Provides 
    retrofit.Endpoint hu.mycompany.injection.modules.application.domain.networking.EndpointModule.mySe‌​rverEndpoint()

означает, что вы не можете просто расширить "макет модуля" и заменить исходный модуль. Нет, это не так просто. И учитывая, что вы разрабатываете свои компоненты таким образом, что он напрямую связывает модули по классам, вы не можете просто сделать "TestComponent" либо, потому что это означало бы, что вы должны изобрести все С нуля, и вы должны были бы сделать компонент для каждой вариации! Ясно, что это не вариант.

Итак, в меньшем масштабе то, что я в конечном итоге сделал, - это создание "поставщика", который я даю модулю, который определяет, выбираю ли я макет или тип производства.

public interface EndpointProvider {
    Endpoint serverEndpoint();
}

public class ProdEndpointProvider implements EndpointProvider {

    @Override
    public Endpoint serverEndpoint() {
        return new ServerEndpoint();
    }
}


public class TestEndpointProvider implements EndpointProvider {
    @Override
    public Endpoint serverEndpoint() {
        return new TestServerEndpoint();
    }
}

@Module
public class EndpointModule {
    private Endpoint serverEndpoint;

    private EndpointProvider endpointProvider;

    public EndpointModule(EndpointProvider endpointProvider) {
        this.endpointProvider = endpointProvider;
    }

    @Named("server")
    @Provides
    public Endpoint serverEndpoint() {
        return endpointProvider.serverEndpoint();
    }
}

EDIT: по-видимому, как говорится в сообщении об ошибке, вы не можете переопределить другой метод с помощью @Provides аннотированный метод, но это не значит, что вы не можете переопределить @Provides аннотированный метод :(

все это волшебство было напрасно! Вы можете просто расширить модуль, не ставя @Provides на способ и он работает... Обратитесь к @vaughandroid 'ы ответ.

можете ли вы, ребята, проверить мое решение, я включил пример подкомпонента:https://github.com/nongdenchet/android-mvvm-with-tests. Спасибо @vaughandroid, я позаимствовал ваши переопределяющие методы. Вот главный момент:

  1. Я создаю класс для создания субкомпонента. Мое пользовательское приложение также будет содержать экземпляр этого класса:

    // The builder class
    public class ComponentBuilder {
     private AppComponent appComponent;
    
     public ComponentBuilder(AppComponent appComponent) {
      this.appComponent = appComponent;
     }
    
     public PlacesComponent placesComponent() {
      return appComponent.plus(new PlacesModule());
     }
    
     public PurchaseComponent purchaseComponent() {
      return appComponent.plus(new PurchaseModule());
     }
    }
    
    // My custom application class
    public class MyApplication extends Application {
    
     protected AppComponent mAppComponent;
     protected ComponentBuilder mComponentBuilder;
    
     @Override
     public void onCreate() {
      super.onCreate();
    
      // Create app component
      mAppComponent = DaggerAppComponent.builder()
              .appModule(new AppModule())
              .build();
    
      // Create component builder
      mComponentBuilder = new ComponentBuilder(mAppComponent);
     }
    
     public AppComponent component() {
      return mAppComponent;
     }
    
     public ComponentBuilder builder() {
      return mComponentBuilder;
     } 
    }
    
    // Sample using builder class:
    public class PurchaseActivity extends BaseActivity {
     ...    
     @Override
     protected void onCreate(Bundle savedInstanceState) {
      ...
      // Setup dependency
      ((MyApplication) getApplication())
              .builder()
              .purchaseComponent()
              .inject(this);
      ...
     }
    }
    
  2. у меня есть пользовательский TestApplication, который расширяет MyApplication класс выше. Этот класс содержит два метода для замены корневого компонента и строителя:

    public class TestApplication extends MyApplication {
     public void setComponent(AppComponent appComponent) {
      this.mAppComponent = appComponent;
     }
    
     public void setComponentBuilder(ComponentBuilder componentBuilder) {
      this.mComponentBuilder = componentBuilder;
     }
    }    
    
  3. наконец, я попытаюсь высмеять или заглушить зависимость модуля и строителя, чтобы обеспечить поддельную зависимость от активности:

    @MediumTest
    @RunWith(AndroidJUnit4.class)
    public class PurchaseActivityTest {
    
     @Rule
     public ActivityTestRule<PurchaseActivity> activityTestRule =
         new ActivityTestRule<>(PurchaseActivity.class, true, false);
    
     @Before
     public void setUp() throws Exception {
     PurchaseModule stubModule = new PurchaseModule() {
         @Provides
         @ViewScope
         public IPurchaseViewModel providePurchaseViewModel(IPurchaseApi purchaseApi) {
             return new StubPurchaseViewModel();
         }
     };
    
     // Setup test component
     AppComponent component = ApplicationUtils.application().component();
     ApplicationUtils.application().setComponentBuilder(new ComponentBuilder(component) {
         @Override
         public PurchaseComponent purchaseComponent() {
             return component.plus(stubModule);
         }
     });
    
     // Run the activity
     activityTestRule.launchActivity(new Intent());
    }
    

С помощью Dagger2 вы можете передать определенный модуль (TestModule там) компоненту с помощью сгенерированного API builder.

ApplicationComponent appComponent = Dagger_ApplicationComponent.builder()
                .helloModule(new TestModule())
                .build();

обратите внимание, что Dagger_ApplicationComponent-это сгенерированный класс с новой аннотацией @Component.