How do you override a module/dependency in a unit

2019-01-08 06:06发布

I have a simple Android activity with a single dependency. I inject the dependency into the activity's onCreate like this:

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

In my ActivityUnitTestCase I want to override the dependency with a Mockito mock. I assume I need to use a test-specific module which provides the mock, but I can't figure out how to add this module to the object graph.

In Dagger 1.x this is apparently done with something like this:

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

What's the Dagger 2.0 equivalent of the above?

You can see my project and its unit test here on GitHub.

8条回答
何必那么认真
2楼-- · 2019-01-08 06:17

THIS ANSWER IS OBSOLETE. READ BELOW IN EDIT.

Disappointingly enough, you cannot extend from a Module, or you'll get the following compilation error:

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

Meaning you can't just extend a "mock module" and replace your original module. Nope, it's not that easy. And considering you design your components in such a way that it directly binds the Modules by class, you can't really just make a "TestComponent" either, because that'd mean you have to reinvent everything from scratch, and you'd have to make a component for every variation! Clearly that is not an option.

So on the smaller scale, what I ended up doing is making a "provider" that I give to the module, which determines whether I select the mock or the production type.

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: Apparently as the error message says, you CAN'T override another method using a @Provides annotated method, but that doesn't mean you can't override an @Provides annotated method :(

All that magic was for naught! You can just extend a Module without putting @Provides on the method and it works... Refer to @vaughandroid 's answer.

查看更多
女痞
3楼-- · 2019-01-08 06:20

Probably this is more a workaround that proper support for test module overriding, but it allows to override production modules with test one. The code snippets below shows simple case when you have just one component and one module, but this should work for any scenario. It requires a lot of boilerplate and code repetition so be aware of this. I'm sure there'll be a better way to achieve this in the future.

I've also created a project with examples for Espresso and Robolectric. This answer is based on code contained in the project.

The solution requires two things:

  • provide additional setter for @Component
  • test component must extend the production component

Assume we've simple Application like below:

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

We've to add additional method to App class. This allows us to replace the production component.

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

As you can see the StringHolder object contains "Release string" value. This object is injected to the 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);
    }
}

In our tests we want to provide StringHolder with "Test string". We've to set the test component in App class before the MainActivity is created - because StringHolder is injected in the onCreate callback.

In Dagger v2.0.0 components can extend other interfaces. We can leverage this to create our TestAppComponent which extends AppComponent.

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

}

Now we're able to define our test modules e.g. TestStringHolderModule. The last step is to set the test component using previously added setter method in App class. It's important to do this before the activity is created.

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

Espresso

For Espresso I've created custom ActivityTestRule which allows to swap the component before the activity is created. You can find code for DaggerActivityTestRule here.

Sample test with 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

It's much easier with Robolectric thanks to the RuntimeEnvironment.application.

Sample test with 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(...)
    }
}
查看更多
你好瞎i
4楼-- · 2019-01-08 06:22

Can you guys check out my solution, I have included subcomponent example: https://github.com/nongdenchet/android-mvvm-with-tests. Thank you @vaughandroid, I have borrowed your overriding methods. Here is the main point:

  1. I create a class to create subcomponent. My custom application will also hold an instance of this class:

    // 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. I have a custom TestApplication that extends the MyApplication class above. This class contains two methods to replace the root component and the builder:

    public class TestApplication extends MyApplication {
     public void setComponent(AppComponent appComponent) {
      this.mAppComponent = appComponent;
     }
    
     public void setComponentBuilder(ComponentBuilder componentBuilder) {
      this.mComponentBuilder = componentBuilder;
     }
    }    
    
  3. Finally I will try to mock or stub the dependency of module and builder to provide fake dependency to the activity:

    @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());
    }
    
查看更多
贼婆χ
5楼-- · 2019-01-08 06:30

With Dagger2, you can pass a specific module (the TestModule there) to a component using the generated builder api.

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

Please note that, Dagger_ApplicationComponent is a generated class with the new @Component annotation.

查看更多
男人必须洒脱
6楼-- · 2019-01-08 06:31

I have solution for Roboletric 3.+.

I have MainActivity which i want to test without injection on create:

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

Next my BaseActivty:

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

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

  ....
  }
查看更多
地球回转人心会变
7楼-- · 2019-01-08 06:40

As @EpicPandaForce rightly says, you can't extend Modules. However, I came up with a sneaky workaround for this which I think avoids a lot of the boilerplate which the other examples suffer from.

The trick to 'extending' a Module is to create a partial mock, and mock out the provider methods which you want to override.

Using Mockito:

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

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

app.setComponent(component);

I created this gist here to show a full example.

EDIT

It turns out you can do this even without a partial mock, like so:

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

app.setComponent(component);
查看更多
登录 后发表回答