Table of contents
Dagger 2 brings us @Component and @Module – annotations which imply less boilerplate code, but unfortunately, make our project less testable. In this article, I will show a project, which is using Dagger 2 and is testable with frameworks like Mockito, Espresso or Robolectric.
Anyone who has ever tried testing an Android project that is using Dagger 1 knows, that this is quite possible to override existing modules and replace them with test ones, e.g.:
@NonNull @Override protected Object[] getModules() { return new Object[] {new TestMainModule()}; }
Dagger 2 brings us @Component and @Module, annotations which imply less boilerplate code, but unfortunately, makes our project less testable. In this article, I’d like to show the project which is using Dagger 2 and is testable with frameworks like Mockito, Espresso or Robolectric.
Complete source code can be found on Droids On Roids Github
Making project
So let’s assume we want to make calculator. In this project we have application class, simply activity with view and it’s presenter and calculator class which holds our logic:MainActivity.java
package pl.droidsonroids.calculator.main; public class MainActivity extends AppCompatActivity implements MainView { @Bind(R.id.first_editText) EditText firstEditText; @Bind(R.id.second_editText) EditText secondEditText; @Bind(R.id.result_editText) EditText resultEditText; @Bind(R.id.spinner) Spinner spinner; @Inject MainPresenter presenter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); init(); } private void init() { ButterKnife.bind(this); CalculatorApplication.getInstance().getMainGraph(this).inject(this); presenter.init(); initSpinner(); } private void initSpinner() { ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(this, R.array.chars, android.R.layout.simple_spinner_item); adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); spinner.setAdapter(adapter); } @Override public void showResult(float result) { resultEditText.setText(String.valueOf(result)); } @Override public void showError() { resultEditText.setText("Error!"); } @OnClick(R.id.ok_button) public void onOkButtonClicked() { presenter.makeCalculation(firstEditText.getText().toString(), secondEditText.getText().toString(), spinner.getSelectedItem().toString()); } @Override protected void onDestroy() { presenter.destroy(); super.onDestroy(); } }
MainPresenterImpl.java
package pl.droidsonroids.calculator.main; public class MainPresenterImpl implements MainPresenter { public final Calculator calculator; private final MainView view; public Subscription subscription; public MainPresenterImpl(Calculator calculator, MainView view) { this.calculator = calculator; this.view = view; } @Override public void init() { subscription = empty(); } @Override public void makeCalculation(String firstNumber, String secondNumber, String selectedSign) { subscription = calculator.calculate(firstNumber, secondNumber, selectedSign) .subscribe(view::showResult, throwable -> view.showError()); } @Override public void destroy() { subscription.unsubscribe(); } }
Calculator.java
package pl.droidsonroids.calculator.data; public class Calculator { public float makeCalculation(final float firstNumberFloat, final float secondNumberFloat, final String sign) { float result = 0f; switch (sign) { case Sign.PLUS: result = firstNumberFloat + secondNumberFloat; break; case Sign.MINUS: result = firstNumberFloat - secondNumberFloat; break; case Sign.MULTIPLY: result = firstNumberFloat * secondNumberFloat; break; case Sign.DIVIDE: result = firstNumberFloat / secondNumberFloat; break; } return result; } public Observable<Float> calculate(final String firstNumber, final String secondNumber, final String sign) { return Observable.create(observer -> observer.onNext(makeCalculation(Float.valueOf(firstNumber), Float.valueOf(secondNumber), sign))); } public static class Sign { public static final String PLUS = "+"; public static final String MINUS = "-"; public static final String MULTIPLY = "*"; public static final String DIVIDE = "/"; } }
There’s also application class in which we’re holding graphs.
CalculatorApplication.java
package pl.droidsonroids.calculator; public class CalculatorApplication extends Application { private static CalculatorApplication sInstance; private AppGraph appGraph; @Override public void onCreate() { super.onCreate(); sInstance = this; appGraph = AppGraph.Initializer.init(this); } public static CalculatorApplication getInstance() { return sInstance; } public MainGraph getMainGraph(MainView view) { return MainGraph.Initializer.init(appGraph, view); } }
Our application works now as a simple calculator with four operations.
We have our activity class with presenter that we’re injecting into:
public class MainActivity extends AppCompatActivity implements MainView { @Inject MainPresenter presenter; ... @OnClick(R.id.ok_button) public void onOkButtonClicked() { presenter.makeCalculation(firstEditText.getText().toString(), secondEditText.getText().toString(), spinner.getSelectedItem().toString()); } ... }
We’d like to do some instrumentation tests with Espresso (no problem), and activity’s unit tests (big problem). Because we want to test only activity behavior in unit test, we’d like to replace injections with mocks. Before we’ll do it, let’s quickly get through dagger modules.
Dependency Injection Modules and Components
In project we’ll have two modules – AppModule (provides dependencies for whole application) and MainModule (provides dependencies for MainActivity) and two components (which are bridges between modules and classes which uses injections) – AppGraph and MainGraph.
Until now nothing’s really different than in the other projects. But if you look at the source code, you’ll see there are additional four classes – TestAppModule, TestAppGraph, TestMainModule and TestMainGraph. That’s the key for module overriding in Dagger 2. We need to create test equivalent for every Dagger class, e.g.:
AppModule.java
package pl.droidsonroids.calculator.dagger; @Module public class AppModule { @Provides @Singleton public Calculator provideCalculator() { return new Calculator(); } }
TestAppModule.java
package pl.droidsonroids.calculator.dagger; @Module public class TestAppModule { @Provides @Singleton public Calculator provideCalculator() { return mock(Calculator.class); } }
In our application class to define which mode we’re using now and to initialize proper graph we need to add boolean variable. After changing this class looks like:
CalculatorApplication.java
package pl.droidsonroids.calculator; public class CalculatorApplication extends Application { private static CalculatorApplication sInstance; private AppGraph appGraph; private boolean isTestMode = false; @Override public void onCreate() { super.onCreate(); sInstance = this; appGraph = AppGraph.Initializer.init(this); } @VisibleForTesting public void initTestMode() { appGraph = TestAppGraph.Initializer.init(this); isTestMode = true; } public static CalculatorApplication getInstance() { return sInstance; } public MainGraph getMainGraph(MainView view) { if (isTestMode) return TestMainGraph.Initializer.init(appGraph); else return MainGraph.Initializer.init(appGraph, view); } }
Now we can init our test mode and use our activity with test dependencies in test cases:
MainActivityTest.java
package pl.droidsonroids.calculator.main; @RunWith(RobolectricGradleTestRunner.class) @Config(constants = BuildConfig.class, sdk = 21) public class MainActivityTest { private MainActivity activity; @Before public void setUp() throws Exception { ((CalculatorApplication) RuntimeEnvironment.application).initTestMode(); activity = Robolectric.setupActivity(MainActivity.class); verify(activity.presenter).init(); } @Test public void testShowingResult() throws Exception { EditText result = (EditText) activity.findViewById(R.id.result_editText); activity.showResult(12f); assertThat(result.getText().toString()).isEqualTo("12.0"); } }