J'ai un simple activité Android avec une seule dépendance. J'injecte la dépendance dans la onCreate
de l'activité comme ceci:
Dagger_HelloComponent.builder()
.helloModule(new HelloModule(this))
.build()
.initialize(this);
Dans ma ActivityUnitTestCase
, je veux remplacer la dépendance par une maquette Mockito. Je suppose que je dois utiliser un module spécifique au test qui fournit la maquette, mais je ne vois pas comment ajouter ce module au graphe d'objets.
Dans Dagger 1.x, ceci est apparemment fait avec quelque chose comme ceci :
@Before
public void setUp() {
ObjectGraph.create(new TestModule()).inject(this);
}
Quel est l'équivalent de Dagger 2.0 de ce qui précède?
Vous pouvez voir mon projet et son test unitaire ici sur GitHub .
Il s’agit probablement d’une solution de contournement plutôt que d’une prise en charge appropriée pour le remplacement des modules de test, mais elle permet de remplacer les modules de production par un test. Les extraits de code ci-dessous illustrent un cas simple dans lequel vous ne disposez que d'un composant et d'un module, mais cela devrait fonctionner dans n'importe quel scénario. Cela nécessite beaucoup de répétition et de code, soyez donc conscient de cela. Je suis sûr qu'il y aura un meilleur moyen d'y parvenir à l'avenir.
J'ai également créé un projet avec des exemples pour Espresso et Robolectric }. Cette réponse est basée sur le code contenu dans le projet.
La solution nécessite deux choses:
@Component
Supposons que nous ayons simple Application
comme ci-dessous:
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");
}
}
}
Nous devons ajouter une méthode supplémentaire à la classe App
. Cela nous permet de remplacer le composant de production.
/**
* Visible only for testing purposes.
*/
// @VisibleForTesting
public void setTestComponent(AppComponent appComponent) {
mAppComponent = appComponent;
}
Comme vous pouvez le constater, l’objet StringHolder
contient la valeur "Libérer la chaîne". Cet objet est injecté à la 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);
}
}
Dans nos tests, nous voulons fournir StringHolder
avec "Test string". Nous devons définir le composant de test dans la classe App
avant la création de MainActivity
- car StringHolder
est injecté dans le rappel onCreate
.
Dans Dagger v2.0.0, les composants peuvent étendre d'autres interfaces. Nous pouvons en tirer parti pour créer notre TestAppComponent
qui étendAppComponent
.
@Component(modules = TestStringHolderModule.class)
interface TestAppComponent extends AppComponent {
}
Nous sommes maintenant en mesure de définir nos modules de test, par exemple. TestStringHolderModule
. La dernière étape consiste à définir le composant de test à l'aide de la méthode de définition précédemment ajoutée dans la classe App
. Il est important de le faire avant la création de l'activité.
((App) application).setTestComponent(mTestAppComponent);
Expresso
Pour Espresso, j'ai créé la variable personnalisée ActivityTestRule
qui permet d’échanger le composant avant la création de l’activité. Vous pouvez trouver le code pour DaggerActivityTestRule
ici _.
Exemple de test avec Espresso:
@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
C'est beaucoup plus facile avec Robolectric grâce au RuntimeEnvironment.application
.
Exemple de test avec 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(...)
}
}
Comme @EpicPandaForce le dit à juste titre, vous ne pouvez pas étendre les modules. Cependant, j’ai proposé une solution de contournement sournoise qui, selon moi, évite une bonne partie des choses, ce dont souffrent les autres exemples.
L'astuce pour "étendre" un module consiste à créer une maquette partielle et à masquer les méthodes de fournisseur que vous souhaitez remplacer.
Utiliser Mockito :
MyModule module = Mockito.spy(new MyModule());
Mockito.doReturn("mocked string").when(module).provideString();
MyComponent component = DaggerMyComponent.builder()
.myModule(module)
.build();
app.setComponent(component);
J'ai créé ce Gist ici pour montrer un exemple complet.
MODIFIER
Il s'avère que vous pouvez le faire même sans maquette partielle, comme ceci:
MyComponent component = DaggerMyComponent.builder()
.myModule(new MyModule() {
@Override public String provideString() {
return "mocked string";
}
})
.build();
app.setComponent(component);
La solution de contournement proposée par @tomrozb est très bonne et me met sur la bonne voie, mais le problème était qu'elle exposait une méthode setTestComponent()
dans la classe PRODUCTION Application
. J'ai pu obtenir un fonctionnement légèrement différent, de sorte que mon application de production ne doit absolument rien connaître de mon environnement de test.
TL; DR - Étendez votre classe d’application avec une application de test utilisant votre composant et votre module de test. Créez ensuite un programme d'exécution de test personnalisé qui s'exécute sur l'application de test au lieu de votre application de production.
EDIT: Cette méthode ne fonctionne que pour les dépendances globales (généralement marquées avec @Singleton
). Si votre application comporte des composants ayant une étendue différente (par exemple, par activité), vous devrez soit créer des sous-classes pour chaque étendue, soit utiliser la réponse originale de @ tomrozb. Merci à @tomrozb pour l'avoir signalé!
Cet exemple utilise le programme d'exécution AndroidJUnitRunner , mais cela pourrait probablement être adapté à Robolectric et à d'autres.
Tout d'abord, mon application de production. Cela ressemble à quelque chose comme ça:
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();
}
}
De cette façon, mes activités et les autres classes qui utilisent @Inject
doivent simplement appeler quelque chose comme getApp().getComponent().inject(this);
pour s’injecter dans le graphique de dépendance.
Pour être complet, voici mon composant:
@Singleton
@Component(modules = {MyModule.class})
public interface MyComponent {
void inject(MyApp app);
// other injects and getters
}
Et mon module:
@Module
public class MyModule {
// EDIT: This solution only works for global dependencies
@Provides @Singleton
public MyClass provideMyClass() { ... }
// ... other providers
}
Pour l'environnement de test, étendez votre composant de test à partir de votre composant de production. C'est la même chose que dans la réponse de @ tomrozb.
@Singleton
@Component(modules = {MyTestModule.class})
public interface MyTestComponent extends MyComponent {
// more component methods if necessary
}
Et le module de test peut être ce que vous voulez. Vraisemblablement, vous vous occuperez de vos moqueries et de vos affaires ici (j'utilise 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.
}
Alors maintenant, la partie la plus délicate. Créez une classe d'application de test qui s'étend à partir de votre classe d'application de production et substituez la méthode setComponent()
pour définir le composant de test avec le module de test. Notez que cela ne peut fonctionner que si MyTestComponent
est un descendant de 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)
}
}
Assurez-vous d'appeler setComponent()
sur l'application avant de commencer vos tests pour vous assurer que le graphique est correctement configuré. Quelque chose comme ça:
@Before
public void setUp() {
MyTestApp app = (MyTestApp) getInstrumentation().getTargetContext().getApplicationContext();
app.setComponent()
((MyTestComponent) app.getComponent()).inject(this)
}
Enfin, la dernière pièce manquante consiste à remplacer votre TestRunner par un lanceur de test personnalisé. Dans mon projet, j’utilisais la variable AndroidJUnitRunner
, mais il semble que vous puissiez faire de même avec 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);
}
}
Vous devrez également mettre à jour votre variable testInstrumentationRunner
, comme ceci:
testInstrumentationRunner "com.mypackage.TestRunner"
Et si vous utilisez Android Studio, vous devrez également cliquer sur Modifier la configuration dans le menu Exécuter et entrer le nom de votre programme d’essai sous «Gestionnaire d’instruments spécifique».
Et c'est tout! Espérons que cette information aide quelqu'un :)
Il semble que j'ai trouvé un autre moyen et que cela fonctionne jusqu'à présent.
Tout d'abord, une interface de composant qui n'est pas un composant en soi:
MyComponent.Java
interface MyComponent {
Foo provideFoo();
}
Ensuite, nous avons deux modules différents: un module réel et un module testant.
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;
}
}
Et nous avons deux composants pour utiliser ces deux modules:
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();
}
En application nous faisons ceci:
MyComponent component = DaggerMyRealComponent.create();
<...>
Foo foo = component.getFoo();
Dans le code de test, nous utilisons:
TestModule testModule = new TestModule();
testModule.setFoo(someMockFoo);
MyComponent component = DaggerMyTestComponent.builder()
.testModule(testModule).build();
<...>
Foo foo = component.getFoo(); // will return someMockFoo
Le problème est que nous devons copier toutes les méthodes de MyModule dans TestModule, mais cela peut être fait en ayant MyModule dans TestModule et en utilisant les méthodes de MyModule à moins qu'elles ne soient définies directement de l'extérieur. Comme ça:
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;
}
}
_ {CETTE REPONSE IS OBSOLETE. LIRE CI-DESSOUS DANS EDIT.}
De manière assez décevante, vous ne pouvez pas étendre à partir d'un module, ou vous obtiendrez l'erreur de compilation suivante:
Error:(24, 21) error: @Provides methods may not override another method.
Overrides: Provides
retrofit.Endpoint hu.mycompany.injection.modules.application.domain.networking.EndpointModule.myServerEndpoint()
Cela signifie que vous ne pouvez pas simplement étendre un "module factice" et remplacer votre module d'origine. Non, ce n'est pas si facile. Et si vous concevez vos composants de manière à ce qu'ils lient directement les modules par classe, vous ne pouvez pas non plus créer simplement un "TestComponent", car cela impliquerait de réinventer le tout à partir de zéro. , et vous auriez à créer un composant pour chaque variation! Clairement, ce n'est pas une option.
Donc, à plus petite échelle, j'ai fini par créer un "fournisseur" que je donne au module, qui détermine si je sélectionne le type de maquette ou le type de production.
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: Apparemment, comme le message d'erreur l'indique, vous NE POUVEZ PAS écraser une autre méthode à l'aide d'une méthode annotée @Provides
, mais cela ne signifie pas que vous ne pouvez pas écraser une méthode annotée @Provides
:(
Toute cette magie était pour rien! Vous pouvez simplement étendre un module sans mettre @Provides
sur la méthode et cela fonctionne ... Reportez-vous à la réponse de @vaughandroid.
Pouvez-vous vérifier ma solution, j'ai inclus l'exemple de sous-composant: https://github.com/nongdenchet/Android-mvvm-with-tests . Merci @vaughandroid, j'ai emprunté vos méthodes primordiales. Voici le point principal:
Je crée une classe pour créer un sous-composant. Mon application personnalisée contiendra également une instance de cette classe:
// 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);
...
}
}
J'ai un TestApplication personnalisé qui étend la classe MyApplication ci-dessus. Cette classe contient deux méthodes pour remplacer le composant racine et le générateur:
public class TestApplication extends MyApplication {
public void setComponent(AppComponent appComponent) {
this.mAppComponent = appComponent;
}
public void setComponentBuilder(ComponentBuilder componentBuilder) {
this.mComponentBuilder = componentBuilder;
}
}
Enfin, je vais essayer de simuler ou de réduire la dépendance du module et du générateur afin de créer une dépendance fictive à l'activité:
@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());
}
J'ai la solution pour Robolectric 3. + .
J'ai MainActivity que je veux tester sans injection pour créer:
public class MainActivity extends BaseActivity{
@Inject
public Configuration configuration;
@Inject
public AppStateService appStateService;
@Inject
public LoginService loginService;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.processIntent(getIntent()); // this is point where pass info from test
super.onCreate(savedInstanceState)
...
}
...
}
Suivant mon BaseActivity:
public class BaseActivity extends AppCompatActivity {
protected Logger mLog;
protected boolean isTestingSession = false; //info about test session
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
if (!isTestingSession) { // check if it is in test session, if not enable injectig
AndroidInjection.inject(this);
}
super.onCreate(savedInstanceState);
}
// method for receive intent from child and scaning if has item TESTING with true
protected void processIntent(Intent intent) {
if (intent != null && intent.getExtras() != null) {
isTestingSession = intent.getExtras().getBoolean("TESTING", false);
}
}
enfin mon testclass:
@Before
public void setUp() throws Exception {
...
// init mocks...
loginServiceMock = mock(LoginService.class);
locServiceMock = mock(LocationClientService.class);
fakeConfiguration = new ConfigurationUtils(new ConfigurationXmlParser());
fakeConfiguration.save(FAKE_XML_CONFIGURATION);
appStateService = new AppStateService(fakeConfiguration, locServiceMock, RuntimeEnvironment.application);
// prepare activity
Intent intent = new Intent(RuntimeEnvironment.application, MainActivity.class);
intent.putExtra("TESTING", true);
ActivityController<MainActivity> activityController = Robolectric.buildActivity(MainActivity.class, intent); // place to put bundle with extras
// get the activity instance
mainActivity = activityController.get();
// init fields which should be injected
mainActivity.appStateService = appStateService;
mainActivity.loginService = loginServiceMock;
mainActivity.configuration = fakeConfiguration;
// and whoala
// now setup your activity after mock injection
activityController.setup();
// get views etc..
actionButton = mainActivity.findViewById(R.id.mainButtonAction);
NavigationView navigationView = mainActivity.findViewById(R.id.nav_view);
....
}