Как вы переопределяете модуль/зависимость в unit test с помощью 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);
}
Что такое Dagger 2.0 эквивалент выше?
Вы можете увидеть мой проект и unit test здесь, на GitHub.
Ответы
Ответ 1
Вероятно, это скорее обходной путь, который обеспечивает надлежащую поддержку для тестирования модуля, но он позволяет переопределять производственные модули с тестовым. В приведенных ниже фрагментах кода показан простой случай, когда у вас есть только один компонент и один модуль, но это должно работать для любого сценария. Для этого требуется много повторений шаблонов и кодов, поэтому имейте это в виду. Я уверен, что в будущем будет лучший способ достичь этого.
Я также создал проект с примерами для Espresso и 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
"Test string". Мы должны установить тестовый компонент в классе App
до создания MainActivity
, потому что StringHolder
вводится в обратном вызове onCreate
.
В Dagger v2.0.0 компоненты могут расширять другие интерфейсы. Мы можем использовать это, чтобы создать наш TestAppComponent
, который расширяет AppComponent
.
@Component(modules = TestStringHolderModule.class)
interface TestAppComponent extends AppComponent {
}
Теперь мы можем определить наши тестовые модули, например. TestStringHolderModule
. Последний шаг - установить тестовый компонент с использованием ранее добавленного метода setter в классе App
. Важно сделать это до того, как будет создана активность.
((App) application).setTestComponent(mTestAppComponent);
Эспрессо
Для Espresso я создал пользовательский 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(...)
}
}
Ответ 2
Как справедливо говорит @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);
Я создал этот смысл здесь, чтобы показать полный пример.
ИЗМЕНИТЬ
Оказывается, вы можете сделать это даже без частичного макета, например:
MyComponent component = DaggerMyComponent.builder()
.myModule(new MyModule() {
@Override public String provideString() {
return "mocked string";
}
})
.build();
app.setComponent(component);
Ответ 3
Обходной путь, предложенный @tomrozb, очень хорош и поставил меня на правильный путь, но моя проблема заключалась в том, что он раскрыл метод setTestComponent()
в классе PRODUCTION Application
. Мне удалось заставить это работать немного по-другому, так что моему производственному приложению ничего не нужно знать о моей тестовой среде.
TL; DR - Расширьте свой класс приложения тестовым приложением, которое использует ваш тестовый компонент и модуль. Затем создайте собственный тестовый бегун, который запускается в тестовом приложении, а не в рабочем приложении.
EDIT: этот метод работает только для глобальных зависимостей (обычно с @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 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, вам также нужно щелкнуть "Редактировать конфигурацию" в меню "Запуск" и ввести имя вашего тестового бегуна в разделе "Специфический контролер".
И это! Надеюсь, эта информация поможет кому-то:)
Ответ 4
Кажется, я нашел еще один способ, и он работает до сих пор.
Сначала интерфейс компонента, который не является самим компонентом:
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;
}
}
Ответ 5
ЭТО ОТВЕТ ОБОЛОЧЕН. ПРОЧИТАЙТЕ НИЖЕ ИЗМЕНЕНИЯ.
Достаточно неудобно, вы не можете распространяться из модуля, или вы получите следующую ошибку компиляции:
Error:(24, 21) error: @Provides methods may not override another method.
Overrides: Provides
retrofit.Endpoint hu.mycompany.injection.modules.application.domain.networking.EndpointModule.myServerEndpoint()
Значит, вы не можете просто расширить модуль "макет" и заменить исходный модуль. Нет, это не так просто. И учитывая, что вы разрабатываете свои компоненты таким образом, чтобы они напрямую связывали модули по классам, вы не можете просто сделать "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.
Ответ 6
Можете ли вы, ребята, проверить мое решение, я включил пример подкомпонента: https://github.com/nongdenchet/android-mvvm-with-tests. Спасибо @vaughandroid, я позаимствовал ваши основные методы. Вот главный момент:
-
Я создаю класс для создания подкомпонента. Мое пользовательское приложение также будет содержать экземпляр этого класса:
// 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);
...
}
}
-
У меня есть пользовательская TestApplication, которая расширяет класс MyApplication выше. Этот класс содержит два метода для замены корневого компонента и построителя:
public class TestApplication extends MyApplication {
public void setComponent(AppComponent appComponent) {
this.mAppComponent = appComponent;
}
public void setComponentBuilder(ComponentBuilder componentBuilder) {
this.mComponentBuilder = componentBuilder;
}
}
-
Наконец, я попытаюсь высмеять или заглушить зависимость модуля и построителя, чтобы обеспечить фальшивую зависимость от активности:
@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());
}
Ответ 7
С помощью Dagger2 вы можете передать конкретный модуль (TestModule) к компоненту с помощью сгенерированного конструктора api.
ApplicationComponent appComponent = Dagger_ApplicationComponent.builder()
.helloModule(new TestModule())
.build();
Обратите внимание, что Dagger_ApplicationComponent является сгенерированным классом с новой аннотацией @Component.