Android, MVP, Dagger e i test

MVP significa Model View Presenter

.. che è un pattern molto popolare ultimamente tra gli sviluppatori Android.

Non voglio scrivere un’altra guida a proposito dell’MVP su android. Altri hanno fatto un lavoro sicuramente migliore, per esempio:

Sono state dette molte cose riguardo l’MVP (e altri pattern simili). Ad esempio:

  • isola la business logic dall’interfaccia utente
  • rende più semplice e veloce il test della business logic
  • impedisce di avere una god class nel Fragment o nella Activity che gestisce ogni cosa
  • rende più semplice la manutenzione dell’app

In ogni caso, la prima cosa che si nota quando si usa l’MVP, è un grande senso di ordine.

In tutte le (poche) app che ho scritto prima, sono sempre finito per avere il ben noto paciugone nel Fragment o nell’Activity che mescola la logica dell’interfaccia e la business logic.

Nel definire le responsabilità della View e del Presenter all’interno di MVP, si va a definire implicitamente l’interfaccia tra quei due componenti (e il model), e ogni cosa trova il suo posto.

Ogni evento di tocco, di drag, e eventuali eventi del lifecycle sono solo eventi che vengono comunicati al presenter, che a sua volta decide come utilizzarli.

Questo post parla della mia esperienza col pattern MVP, Dagger 2 e il testing

Data la definizione precisa dell’interfaccia tra la View e il Presenter, immaginavo ingenuamente che il test dei due componenti (utilizzando rispettivamente Espresso e gli unit test) avvenisse in maniera naturale.

Come spesso accade, la realtà è decisamente differente da quello che uno si aspetta e legge sui blog. In questo post proverò a riassumere tutti i problemi che ho incontrato durante il percorso e le soluzioni che ho provato ad applicare.

Per descrivere meglio i concetti, ho scritto un piccolo esempio che è disponibile nel mio repo github.

La struttura è la stessa che si può trovare cercando su Google esempi per Dagger / MVP, ad esempio qua.

L’unica cosa che ho aggiunto è una coppia di Component / Module locali che uso per fare inject soltanto degli oggetti richiesti da quel particolare set di classi (la View e il Presenter).

Questo significa che oltre ai Component / Moduli globali, usati per fare injection di cose come lo storage o l’interfaccia verso il backend, ci saranno dei Component / Moduli locali utilizzati, ad esempio, per fare injection del Presenter dentro la View.

La parte facile: testare il Presenter

Le dipendenze sono risolte passando gli oggetti di cui ha bisogno come parametri del costruttore:

@Before
public void setup() {
    mMockView = mock(MainView.class);
    mMockStorage = mock(KeyValueStorage.class);
    mToTest = new MainPresenterImpl(mMockView, mMockStorage);
}

Dato che non c’è nessuna magia di injection coinvolta qua, possiamo semplicemente fare un mock della View e di tutti gli altri oggetti necessari al Presenter e scrivere gli Unit Tests per un’istanza del presenter.

Inoltre, tutte le dipendenze con modelli esterni / sorgenti di dati (come Retrofit) possono essere testate osservando il comportamento del Presenter.

La parte “che pensavo fosse più facile”: testare la view

Un approccio molto usato è quello di non testare la View rispetto a un presenter, ma piuttosto rispetto ad un Presenter che è stato iniettato con delle “dipendenze esterne” mockate, come per esempio lo storage o il client di un endpoint rest.

In ogni modo, quello che voglio ottenere qua è poter testare la view pilotando il comportamento del presenter con cui interagisce.

Con una così forte separazione dei ruoli, mi aspettavo che fosse semplice sostituire il Presenter con un mock e testare la View con Espresso.

Injectare il mock di un presenter

Dato che il presenter è fornito dal modulo locale e injectato nella View da Dagger, è necessario trovare un modo per sostituire il modulo in modo da fornire il presenter “mocked” in supporto dei test.

Usando il sistema comunemente diffuso di costruire il modulo locale

DaggerMainComponent.builder()
    .applicationComponent(app.getComponent())
    .mainModule(new MainModule(this))
    .build().inject(this);

L’unico modo di sostituire il presenter che è fornito dal “vero” MainModule è quello di usare i build flavours, come mostrato ad esempio su Android testing codelab.

Ad ogni modo, io volevo sfruttare la potenza di Dagger 2 per sostituire il presenter con un mock.

La chiave per rimpiazzare una dipendenza è sostituire l’Application object

Aggiungendo un factory method che restituisce un’instanza del modulo nell’Application:

DaggerMainComponent.builder()
    .applicationComponent(app.getComponent())
    .mainModule(app.getMainModule(this))
    .build().inject(this);

Possiamo quindi usare un test runner custom che fornisce una sottoclasse dell’applicazione dichiarata nel Manifest.

public class EspressoTestRunner extends AndroidJUnitRunner {
    @Override
    public Application newApplication(ClassLoader cl, String className, Context context) throws
            IllegalAccessException, ClassNotFoundException, InstantiationException {
        return super.newApplication(cl, TestMvpApplication.class.getName(), context);
    }
}

e dichiararlo nel build.gradle:

android {
    ...
    defaultConfig {
        ...
        testInstrumentationRunner 'com.whiterabbit.windlocator.EspressoTestRunner'
        ...
    }
}

A questo punto l’oggetto Applicazione è quello responsabile di fornire tutti i moduli. Utilizzando una sottoclasse, possiamo pilotare cosa viene fornito per essere injectato:

public class TestMvpApplication extends MvpApplication {
    private MainModule mMainModule;

    // By usint this two method we can drive whatever module we want during the tests
    // (and with that, drive what classes inject)
    @Override
    public MainModule getMainModule(MainView view) {
        return mMainModule;
    }

    public void setMainModule(MainModule m) {
        mMainModule = m;
    }
}

Il metodo di setup dei test diventa qualcosa del tipo

@Before
public void setUp() throws Exception {
    // a mock module with the mock presenter to be injected..
    MainModule m = mock(MainModule.class);
    mMockPresenter = mock(MainPresenter.class);

    when(m.provideMainView()).thenReturn(mock(MainView.class)); // this is needed to fool dagger
    when(m.provideMainPresenter(any(MainView.class), any(KeyValueStorage.class)))
        .thenReturn(mMockPresenter);

    Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
    TestMvpApplication app
        = (TestMvpApplication) instrumentation.getTargetContext().getApplicationContext();

    // forced to the application object
    app.setMainModule(m);
}

Il mock del modulo è necessario, per fornire il mock del presenter.

Il mock del modulo è quindi passato all’Application. Inoltre, per far si che tutto funzioni, si noti come il mock del modulo deve fornire una copia della view (anche un mock) che non verrà mai usato.

Adesso possiamo finalmente scrivere un test:

@Test
public void testButtonClick() {
    activity.launchActivity(new Intent());
    onView(withId(R.id.main_button)).perform(click());
    verify(mMockPresenter).onButtonClicked();
}

Dopo tutta questa fatica, possiamo “testare solo la view”, nel senso che non dobbiamo testare anche il comportamento del presenter nei confronti di un mock dello storage o dell’endpoint rest.

Andiamo a testare la View rispetto all’interfaccia verso il Presenter.

Manca ancora un pezzo del puzzle: come fare se vogliamo testare il comportamento della view quando viene invocata dal presenter?
Nell’esempio, la View fornisce un metodo per impostare il testo visualizzato.

Ingenuamente, potremmo pensare che sia sufficiente chiamare il metodo con qualcosa tipo:

activity.getActivity().showValue("23");

Ma la verità è che i test di Espresso girano in un thread diverso da quello principale. Facendo in quel modo, avremmo l’errore:
Only the original thread that created a view hierarchy can touch its views

Un modo per ovviare a questo problema è quello di chiamare i metodi nel thread principale:

activity.getActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
    activity.getActivity().showValue("23");
}});

Perché non usare l’annotazione @UiThreadTest?

Semplicemente perché sarebbe risultato in un’altra eccezione. StartActivity non può essere invocata sul thread principale.

Per riassumere

  • Modificare l’applicazione in modo che fornisca i Moduli che injectano i Presenter
  • Modificare il TestRunner per fornire un’Application differente
  • Fare in modo che l’Application “di test” fornisca un modulo mock che fornisce il mock del Presenter
  • Test!

Conclusione

Il pattern MVP isola la view (che deve essere il più stupida possibile) dal presenter.

Testando la view con un presenter mock, i test della view verranno prosciugati da qualsiasi tipo di logica ci si aspetta di trovare nel presenter. Si andrà a testare solo che l’interfaccia tra la View e il Presenter funzioni come atteso.

Facendo così, il processo di sviluppo sarà concentrato sulla business logic contenuta interamente nel Presenter, testabile a questo punto direttamente sulla macchina con degli unit test. Il ciclo di TDD sarà sicuramente più stretto.

PS: la versione originale di questo articolo è presente sul mio blog personale in inglese.

Federico Paolinelli

Senior Software Architect per List Group, dove mi occupo di software per i mercati finanziari usando tecnologie proprietarie. Da anni interessato ad Android, su cui spendo buona parte del mio tempo libero cercando di tenermi aggiornato, curando il mio blog fedepaol.github.io e pubblicando qualche app. In passato ho contribuito a Firefox per Android.