square / dagger

A fast dependency injector for Android and Java.
https://square.github.io/dagger/
Apache License 2.0
7.31k stars 3.07k forks source link

How to design a system using dagger with testing in mind #405

Closed jrstarke closed 10 years ago

jrstarke commented 10 years ago

I'm new to dagger, and I recently started using dagger in one of my own projects, because the concept of being able to handle the dependency injection differently for testing and production, thus being able to inject mock objects that I could use for testing was great.

I modified my application to follow the style laid out in the dagger simple-android example.

After setting it all up, I found that there was problems with injection, and I couldn't fully overload the injections from my production application with the testing logic.

I'm looking for advice on how to set this up in a way that my tests can actually inject differentially with mocks or other objects for testing as needed, and not be too kludgy. Currently, the MainActivityTest is injected correctly, but when we get to the MainActivity, it goes to the PhoneApplication and injects using it's object graph

I've included what I have below. Any help would be greatly appreciated!


Here is my PhoneApplication, based on the DemoApplication.

public class PhoneApplication extends Application {
    private ObjectGraph graph;

    @Override
    public void onCreate() {
        super.onCreate();

        graph = ObjectGraph.create(getModules().toArray());
    }

    protected List<Object> getModules() {
        return Arrays.asList(new AndroidModule(this), new PhoneModule());
    }

    public void inject(Object object) {
        graph.inject(object);
    }
}

And here's my AndroidModule

@Module(library = true, injects = MainActivity.class)
public class AndroidModule {
    private final Context context;

    public AndroidModule(Context context) {
        this.context = context;
    }

    /**
     * Allow the application context to be injected but require that it be
     * annotated with {@link ForApplication @Annotation} to explicitly
     * differentiate it from an activity context.
     */
    @Provides
    @Singleton
    @ForApplication
    Context provideApplicationContext() {
        return context;
    }

    @Provides
    @Singleton
    NotificationManager provideNotificationManager() {
        return (NotificationManager) context
                .getSystemService(Application.NOTIFICATION_SERVICE);
    }

    @Provides
    @Singleton
    LocalBroadcastManager provideLocalBroadcastManager() {
        return LocalBroadcastManager.getInstance(context);
    }

    @Provides
    @Singleton
    ContentResolver provideContentResolver() {
        return context.getContentResolver();
    }

}

Based on the example, I also set up my Activities to use a base Activity.

public abstract class ActionBarBaseActivity extends ActionBarActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        ((PhoneApplication) getApplication()).inject(this);
    }
}

Then within my MainActivity I have the following

public class MainActivity extends ActionBarBaseActivity {

...

    @Inject
    LocalBroadcastManager localBroadcastManager;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
...
        try {
            messageReceivedIntentFilter = new IntentFilter(
                    Constants.EVENT_MESSAGE_RECEIVED,
                    "vnd.android.cursor.dir/vnd."
                            + DataProviderContract.AUTHORITY + "."
                            + DataProviderContract.MESSAGES_TABLE_NAME);

            localBroadcastManager.registerReceiver(messageReceiver,
                    messageReceivedIntentFilter);
        } catch (MalformedMimeTypeException e) {
            Log.e(LOG_TAG,
                    "An error occurred registering an Intent for EVENT_MESSAGE_RECEIVED",
                    e);
        }
...
    }
...
}

This worked great and the injections slid into place really quickly, and I was ecstatic. Until I actually wanted to do some testing. The first test I wanted to perform was on my MainActivity.

in the onCreate method above, we inject with the LocalBroadcastManager from AndroidModule, instead of the one from MainActivityTest, because we don't currently have a way of telling the PhoneApplication or the Activities that they should use a different object graph.

public class MainActivityTest extends
        ActivityInstrumentationTestCase2<MainActivity> {

    @Inject
    NotificationManager notificationManager;

    @Inject
    ContentResolver contentResolver;

    @Inject
    MockContentResolver mockContentResolver;

    @Inject
    LocalBroadcastManager localBroadcastManager;

    private Context context;

    public MainActivityTest() {
        super(MainActivity.class);
    }

    @Module(injects = { MainActivityTest.class, MainActivity.class }, library = true, overrides = true)
    static class MockModule {
        Context context;

        public MockModule(Context context) {
            this.context = context;
        }

        @Provides
        @Singleton
        ContentResolver provideContentResolver() {
            return provideMockContentResolver();
        }

        @Provides
        @Singleton
        MockContentResolver provideMockContentResolver() {
            return new MockContentResolver();
        }

        @Provides
        @Singleton
        LocalBroadcastManager provideLocalBroadcastManager() {
            return Mockito.mock(LocalBroadcastManager.class);
        }
    }

    @Override
    protected void setUp() throws Exception {
        System.setProperty("dexmaker.dexcache", getInstrumentation()
                .getTargetContext().getCacheDir().getPath());

        context = getInstrumentation().getTargetContext();
        ObjectGraph graph = ObjectGraph.create(new AndroidModule(context),
                new MockModule(context));
        graph.inject(this);

        super.setUp();
    };

    @MediumTest
    @UiThreadTest
    public void testIncomingMessageReceiver_onReceive()
            throws MalformedMimeTypeException {

        ArgumentCaptor<BroadcastReceiver> receiverCaptor = ArgumentCaptor
                .forClass(BroadcastReceiver.class);
        Mockito.verify(localBroadcastManager, Mockito.atLeastOnce())
                .registerReceiver(receiverCaptor.capture(),
                        Mockito.any(IntentFilter.class));
    }
}

This is a really simple test to get me started. I know that in the onCreate, we're going to register a BroadcastReceiver, so lets just make sure it registered. Because the test has the mockLocalBroadcastManager, but the activity uses the production LocalBroadcastManager, the verify fails.

JakeWharton commented 10 years ago

Hi Jamie, welcome to the wonderful world of Dagger! Can you do us a favor and move this question to StackOverflow with the 'dagger' tag? That way it can be answered by the community and live on in a more searchable form (to hopefully help others).

Thanks!

jrstarke commented 10 years ago

Thanks @JakeWharton, It's now up at http://stackoverflow.com/questions/23177280/how-to-design-an-android-application-using-dagger-with-testing-in-mind