web-dev-qa-db-fra.com

Comment remplacer un module/une dépendance dans un test unitaire avec Dagger 2.0?

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 .

52
G. Lombard

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:

  • fournir un setter supplémentaire pour @Component
  • composant de test doit étendre le composant de production

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 DaggerActivityTestRuleici _. 

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(...)
    }
}
45
tomrozb

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);
22
vaughandroid

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 :)

8
yuval

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

_ {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.mySe‌​rverEndpoint()

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.

1
EpicPandaForce

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:

  1. 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);
      ...
     }
    }
    
  2. 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;
     }
    }    
    
  3. 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());
    }
    
0
Rain Vu

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

  ....
  }
0
Andrew Sneck