Test semplificati grazie al minimalismo del framework e all'architettura del software

Pubblicato: 2022-03-10
Riassunto rapido ↬ Come per molti altri argomenti nello sviluppo del software, il test e lo sviluppo basato su test sono spesso resi inutilmente complessi in teoria e implementazione, ponendo troppa enfasi sull'apprendimento di un'ampia gamma di framework di test. In questo articolo, rivisiteremo cosa significa test con una semplice analogia, esploreremo concetti nell'architettura del software che risulteranno direttamente in una ridotta necessità di test framework e alcuni argomenti sul perché potresti trarre vantaggio da un atteggiamento minimalista per il tuo processo di test .

Come molti altri sviluppatori Android, la mia incursione iniziale nei test sulla piattaforma mi ha portato a confrontarmi immediatamente con un gergo demoralizzante. Inoltre, i pochi esempi in cui mi sono imbattuto all'epoca (circa 2015) non presentavano casi d'uso pratici che potrebbero avermi portato a pensare che il rapporto costo/beneficio dell'apprendimento di uno strumento come Espresso per verificare che a TextView.setText( …) funzionava correttamente, era un investimento ragionevole.

A peggiorare le cose, non avevo una conoscenza pratica dell'architettura del software in teoria o pratica, il che significava che anche se mi fossi preso la briga di imparare questi framework, avrei scritto test per applicazioni monolitiche composte da alcune classi god , scritte nel codice spaghetti . La battuta finale è che costruire, testare e mantenere tali applicazioni è un esercizio di auto-sabotaggio indipendentemente dalla tua esperienza nel framework; tuttavia questa realizzazione diventa chiara solo dopo aver costruito un'applicazione modulare , ad accoppiamento lasco e altamente coesa .

Da qui arriviamo a uno dei principali punti di discussione in questo articolo, che qui riassumerò in parole povere: Tra i vantaggi primari dell'applicazione dei principi aurei dell'architettura del software (non preoccuparti, li discuterò con semplici esempi e language), è che il tuo codice può diventare più facile da testare. Ci sono altri vantaggi nell'applicazione di tali principi, ma il rapporto tra architettura del software e test è l'obiettivo di questo articolo.

Tuttavia, per il bene di coloro che desiderano capire perché e come testiamo il nostro codice, esploreremo prima il concetto di test per analogia; senza richiedere la memorizzazione di alcun gergo. Prima di approfondire l'argomento principale, esamineremo anche la domanda sul perché esistono così tanti framework di test, poiché esaminando questo potremmo iniziare a vedere i loro vantaggi, limiti e forse anche una soluzione alternativa.

Altro dopo il salto! Continua a leggere sotto ↓

Test: perché e come

Questa sezione non sarà una nuova informazione per nessun tester esperto, ma forse potresti comunque apprezzare questa analogia. Ovviamente sono un ingegnere del software, non un ingegnere missilistico, ma per un momento prenderò in prestito un'analogia che riguarda la progettazione e la costruzione di oggetti sia nello spazio fisico, sia nello spazio di memoria di un computer. Si scopre che mentre il mezzo cambia, il processo è in linea di principio lo stesso.

Supponiamo per un momento di essere ingegneri missilistici e il nostro compito è costruire il primo stadio* di booster razzo di una navicella spaziale. Supponiamo inoltre di aver elaborato un progetto funzionale per la prima fase per iniziare la costruzione e il collaudo in varie condizioni.

"Primo stadio" si riferisce ai booster che vengono lanciati quando il razzo viene lanciato per la prima volta

Prima di arrivare al processo, vorrei sottolineare perché preferisco questa analogia: non dovresti avere alcuna difficoltà a rispondere alla domanda sul perché ci preoccupiamo di testare il nostro progetto prima di metterlo in situazioni in cui sono in gioco vite umane. Anche se non cercherò di convincerti che testare le tue applicazioni prima del lancio potrebbe salvare vite (sebbene sia possibile a seconda della natura dell'applicazione), potrebbe salvare valutazioni, recensioni e il tuo lavoro. In senso lato, il test è il modo in cui ci assicuriamo che singole parti, diversi componenti e interi sistemi funzionino prima di utilizzarli in situazioni in cui è di fondamentale importanza che non falliscano.

Tornando all'aspetto come di questa analogia, introdurrò il processo mediante il quale gli ingegneri effettuano il test di un particolare progetto: la ridondanza . In linea di principio, la ridondanza è semplice: crea copie del componente da testare con le stesse specifiche di progettazione di quello che desideri utilizzare al momento del lancio. Testare queste copie in un ambiente isolato che controlla rigorosamente le precondizioni e le variabili. Sebbene ciò non garantisca che il razzo booster funzioni correttamente quando integrato nell'intera navetta, si può essere certi che se non funziona in un ambiente controllato, sarà molto improbabile che funzioni.

Supponiamo che delle centinaia, o forse migliaia di variabili rispetto alle quali sono state testate le copie del progetto del razzo, si riduca alla temperatura ambiente in cui verrà testato il booster del razzo . Dopo aver testato a 35° Celsius, vediamo che tutto funziona senza errori. Anche in questo caso, il razzo viene testato all'incirca a temperatura ambiente senza guasti. Il test finale sarà alla temperatura più bassa registrata per il sito di lancio, a -5°C. Durante questa prova finale, il razzo spara, ma dopo un breve periodo il razzo si accende e poco dopo esplode violentemente; ma fortunatamente in un ambiente controllato e sicuro.

A questo punto, sappiamo che le variazioni di temperatura sembrano essere almeno coinvolte nel test fallito, il che ci porta a considerare quali parti del razzo potrebbero essere influenzate negativamente dalle basse temperature. Nel corso del tempo, si è scoperto che un componente chiave, un O-ring di gomma che serve a bloccare il flusso di carburante da un compartimento all'altro, diventa rigido e inefficace se esposto a temperature prossime o inferiori allo zero.

È possibile che tu abbia notato che la sua analogia è vagamente basata sui tragici eventi del disastro dello space shuttle Challenger . Per chi non lo conoscesse, la triste verità (nella misura in cui le indagini si sono concluse) è che ci sono stati molti test falliti e avvertimenti da parte degli ingegneri, eppure preoccupazioni amministrative e politiche hanno spinto il lancio a procedere comunque. In ogni caso, che tu abbia memorizzato o meno il termine ridondanza , la mia speranza è che tu abbia colto il processo fondamentale per testare parti di qualsiasi tipo di sistema.

Per quanto riguarda il software

Mentre l'analogia precedente spiegava il processo fondamentale per testare i razzi (prendendomi molta libertà con i dettagli più fini), ora riassumerò in un modo che è probabilmente più rilevante per te e per me. Sebbene sia possibile testare il software solo avviando ai dispositivi una volta che si trova in qualsiasi tipo di stato distribuibile, suppongo invece che possiamo applicare prima il principio di ridondanza alle singole parti dell'applicazione.

Ciò significa che creiamo copie delle parti più piccole dell'intera applicazione (comunemente denominate Unità di software), impostiamo un ambiente di test isolato e vediamo come si comportano in base a qualsiasi variabile, argomento, evento e risposta che può verificarsi in fase di esecuzione. Il test è veramente semplice come quello in teoria, ma la chiave anche per arrivare a questo processo risiede nella creazione di applicazioni che siano fattibile. Ciò si riduce a due preoccupazioni che esamineremo nelle prossime due sezioni. La prima preoccupazione riguarda l' ambiente di test e la seconda riguarda il modo in cui strutturiamo le applicazioni.

Perché abbiamo bisogno di framework?

Per testare un software (d'ora in poi denominato Unit , sebbene questa definizione sia volutamente una semplificazione eccessiva), è necessario disporre di una sorta di ambiente di test che consenta di interagire con il software in fase di esecuzione. Affinché le applicazioni di costruzione vengano eseguite esclusivamente in un ambiente JVM ( Java Virtual Machine ), tutto ciò che è necessario per scrivere i test è un JRE ( Java Runtime Environment ). Prendi ad esempio questa semplicissima classe Calcolatrice :

 class Calculator { private int add(int a, int b){ return a + b; } private int subtract(int a, int b){ return a - b; } }

In assenza di framework, fintanto che abbiamo una classe test che contiene una funzione main per eseguire effettivamente il nostro codice, possiamo testarlo. Come ricorderete, la funzione main denota il punto di partenza dell'esecuzione di un semplice programma Java. Per quanto riguarda ciò per cui stiamo testando, inseriamo semplicemente alcuni dati di test nelle funzioni della calcolatrice e verifichiamo che stia eseguendo correttamente l'aritmetica di base:

 public class Main { public static void main(String[] args){ //create a copy of the Unit to be tested Calculator calc = new Calculator(); //create test conditions to verify behaviour int addTest = calc.add(2, 2); int subtractTest = calc.subtract(2, 2); //verify behaviour by assertion if (addTest == 4) System.out.println("addTest has passed."); else System.out.println("addTest has failed."); if (subtractTest == 0) System.out.println("subtractTest has passed."); else System.out.println("subtractTest has failed."); } }

Testare un'applicazione Android è ovviamente una procedura completamente diversa. Sebbene ci sia una funzione main sepolta in profondità all'interno della fonte del file ZygoteInit.java (i cui dettagli più fini non sono importanti qui), che viene invocata prima dell'avvio di un'applicazione Android sulla JVM , anche uno sviluppatore Android junior dovrebbe sapere che il sistema stesso è responsabile della chiamata di questa funzione; non lo sviluppatore . Invece, i punti di ingresso per le applicazioni Android sono la classe Application e tutte le classi Activity a cui il sistema può essere indirizzato tramite il file AndroidManifest.xml .

Tutto questo è solo un anticipo per il fatto che testare le unità in un'applicazione Android presenta un livello di complessità maggiore, rigorosamente perché il nostro ambiente di test deve ora tenere conto della piattaforma Android.

Addomesticare il problema dell'accoppiamento stretto

L'accoppiamento stretto è un termine che descrive una funzione, una classe o un modulo dell'applicazione che dipende da particolari piattaforme, framework, linguaggi e librerie. È un termine relativo, il che significa che il nostro esempio Calculator.java è strettamente accoppiato al linguaggio di programmazione Java e alla libreria standard, ma questa è l'estensione del suo accoppiamento. Sulla stessa linea, il problema di testare le classi che sono strettamente accoppiate alla piattaforma Android è che devi trovare un modo per lavorare con o intorno alla piattaforma.

Per le classi strettamente accoppiate alla piattaforma Android, hai due opzioni. Il primo è semplicemente distribuire le tue classi su un dispositivo Android (fisico o virtuale). Anche se suggerisco di testare la distribuzione del codice dell'applicazione prima di inviarlo alla produzione, si tratta di un approccio altamente inefficiente durante le fasi iniziali e intermedie del processo di sviluppo rispetto al tempo.

Una Unit , per quanto tecnica sia la definizione che preferisci, è generalmente considerata come una singola funzione in una classe (sebbene alcuni espandano la definizione per includere successive funzioni di supporto che vengono chiamate internamente dalla chiamata iniziale di funzione singola). Ad ogni modo, le unità devono essere piccole; costruire, compilare e distribuire un'intera applicazione per testare una singola unità significa perdere completamente il punto di testare in isolamento.

Un'altra soluzione al problema dell'accoppiamento stretto consiste nell'utilizzare framework di test per interagire o simulare (simulare) le dipendenze della piattaforma. Framework come Espresso e Robolectric offrono agli sviluppatori mezzi molto più efficaci per testare le unità rispetto all'approccio precedente; il primo è utile per i test eseguiti su un dispositivo (noti come "test strumentali" perché apparentemente chiamarli test del dispositivo non era abbastanza ambiguo) e il secondo è in grado di deridere il framework Android localmente su una JVM.

Prima di procedere a inveire contro tali framework invece dell'alternativa di cui parlerò tra breve, voglio essere chiaro che non intendo insinuare che non dovresti mai usare queste opzioni. Il processo che uno sviluppatore utilizza per costruire e testare le proprie applicazioni dovrebbe nascere da una combinazione di preferenze personali e un occhio per l'efficienza.

Per coloro che non amano la creazione di applicazioni modulari e ad accoppiamento libero, non avrai altra scelta che familiarizzare con questi framework se desideri avere un livello adeguato di copertura dei test. Molte applicazioni meravigliose sono state costruite in questo modo e non di rado vengo accusato di rendere le mie applicazioni troppo modulari e astratte. Sia che tu adotti il ​​mio approccio o decida di appoggiarti pesantemente sui framework, ti ​​saluto per aver dedicato tempo e sforzi per testare le tue applicazioni.

Mantieni i tuoi quadri a distanza di braccia

Per il preambolo finale della lezione fondamentale di questo articolo, vale la pena discutere del motivo per cui potresti voler avere un atteggiamento minimalista quando si tratta di utilizzare i framework (e questo vale per qualcosa di più del semplice test dei framework). Il sottotitolo sopra è una parafrasi del magnanimo insegnante di buone pratiche del software: Robert “Uncle Bob” C. Martin. Delle tante gemme che mi ha dato da quando ho studiato per la prima volta le sue opere, questa ha richiesto diversi anni di esperienza diretta per comprenderla.

Nella misura in cui ho capito di cosa tratta questa affermazione, il costo dell'utilizzo dei framework è nell'investimento di tempo necessario per apprenderli e mantenerli. Alcuni di essi cambiano abbastanza frequentemente e alcuni di essi non cambiano abbastanza frequentemente. Le funzioni diventano obsolete, i framework cessano di essere mantenuti e ogni 6-24 mesi arriva un nuovo framework per soppiantare l'ultimo. Pertanto, se riesci a trovare una soluzione implementabile come funzionalità di piattaforma o linguaggio (che tendono a durare molto più a lungo), tenderà a essere più resistente ai cambiamenti di vario tipo sopra menzionati.

Da un punto di vista più tecnico, framework come Espresso e, in misura minore, Robolectric , non possono mai essere eseguiti in modo efficiente come i semplici test JUnit , o anche il test senza framework precedente. Sebbene JUnit sia davvero un framework, è strettamente accoppiato alla JVM , che tende a cambiare a un ritmo molto più lento rispetto alla piattaforma Android vera e propria. Meno framework significa quasi sempre codice più efficiente in termini di tempo necessario per eseguire e scrivere uno o più test.

Da ciò, probabilmente puoi dedurre che ora discuteremo di un approccio che sfrutterà alcune tecniche che ci consentono di mantenere la piattaforma Android a debita distanza; per tutto il tempo permettendoci un'abbondante copertura del codice, l'efficienza dei test e l'opportunità di utilizzare ancora un framework qua o là quando se ne presenta la necessità.

L'arte dell'architettura

Per usare un'analogia sciocca, si potrebbe pensare a framework e piattaforme come colleghi prepotenti che prenderanno il controllo del tuo processo di sviluppo a meno che tu non stabilisca limiti appropriati con loro. I principi d'oro dell'architettura del software possono darti i concetti generali e le tecniche specifiche necessarie sia per creare che per far rispettare questi confini. Come vedremo tra poco, se vi siete mai chiesti quali siano i vantaggi dell'applicazione dei principi dell'architettura software nel vostro codice, alcuni direttamente e molti indirettamente rendono il vostro codice più facile da testare.

Separazione degli interessi

Separation Of Concerns è secondo me il concetto più universalmente applicabile e utile nell'architettura software nel suo insieme (senza voler dire che gli altri dovrebbero essere trascurati). La separazione delle preoccupazioni (SOC) può essere applicata, o completamente ignorata, in ogni prospettiva di sviluppo software di cui sono a conoscenza. Per riassumere brevemente il concetto, esamineremo il SOC quando applicato alle classi, ma tieni presente che il SOC può essere applicato alle funzioni attraverso un uso estensivo delle funzioni di supporto e può essere estrapolato a interi moduli di un'applicazione ("moduli" usati in il contesto di Android/Gradle).

Se hai dedicato molto tempo alla ricerca di modelli architetturali software per applicazioni GUI, probabilmente ti sarai imbattuto in almeno uno di: Model-View-Controller (MVC), Model-View-Presenter (MVP) o Model-View- ViewModel (MVVM). Avendo creato applicazioni in ogni stile, dirò in anticipo che non considero nessuna di esse l'unica opzione migliore per tutti i progetti (o anche le funzionalità all'interno di un singolo progetto). Ironia della sorte, il modello che il team Android ha presentato alcuni anni fa come approccio consigliato, MVVM, sembra essere il meno testabile in assenza di framework di test specifici per Android (supponendo che tu voglia utilizzare le classi ViewModel della piattaforma Android, di cui sono certamente un fan di).

In ogni caso, le specificità di questi modelli sono meno importanti delle loro generalità. Tutti questi modelli sono solo versioni diverse di SOC che enfatizzano una separazione fondamentale di tre tipi di codice a cui mi riferisco come: Dati , Interfaccia utente , Logica .

Quindi, in che modo esattamente la separazione di dati , interfaccia utente e logica ti aiuta a testare le tue applicazioni? La risposta è che estraendo la logica dalle classi che devono gestire le dipendenze piattaforma/framework in classi che possiedono poche o nessuna dipendenza piattaforma/framework, il test diventa facile e il framework minimo . Per essere chiari, in genere parlo di classi che devono eseguire il rendering dell'interfaccia utente, archiviare dati in una tabella SQL o connettersi a un server remoto. Per dimostrare come funziona, diamo un'occhiata a un'architettura semplificata a tre livelli di un'ipotetica applicazione Android.

La prima classe gestirà la nostra interfaccia utente. Per semplificare le cose, ho utilizzato un'attività per questo scopo, ma in genere opto per Frammenti invece come classi di interfaccia utente. In entrambi i casi, entrambe le classi presentano un accoppiamento stretto simile alla piattaforma Android :

 public class CalculatorUserInterface extends Activity implements CalculatorContract.IUserInterface { private TextView display; private CalculatorContract.IControlLogic controlLogic; private final String INVALID_MESSAGE = "Invalid Expression."; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); controlLogic = new DependencyProvider().provideControlLogic(this); display = findViewById(R.id.textViewDisplay); Button evaluate = findViewById(R.id.buttonEvaluate); evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } }); //..bindings for the rest of the calculator buttons } @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } }

Come puoi vedere, l' attività ha due funzioni: in primo luogo, poiché è il punto di ingresso di una determinata funzionalità di un'applicazione Android , funge da sorta di contenitore per gli altri componenti della funzionalità. In parole povere, un contenitore può essere pensato come una sorta di classe radice a cui gli altri componenti sono infine legati tramite riferimenti (o campi di membri privati ​​in questo caso). Inoltre gonfia, lega i riferimenti e aggiunge listener al layout XML (l'interfaccia utente).

Test della logica di controllo

Piuttosto che avere l' attività in possesso di un riferimento a una classe concreta nel back-end, lo facciamo parlare con un'interfaccia di tipo CalculatorContract.IControlLogic. Discuteremo perché questa è un'interfaccia nella prossima sezione. Per ora, capisci solo che qualsiasi cosa si trovi dall'altra parte di quell'interfaccia dovrebbe essere qualcosa come un presentatore o un controller . Poiché questa classe controllerà le interazioni tra l' attività front-end e il back-end Calculator , ho scelto di chiamarla CalculatorControlLogic :

 public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } } public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } }

Ci sono molte cose sottili nel modo in cui è progettata questa classe che rendono più facile il test. In primo luogo, tutti i suoi riferimenti provengono dalla libreria standard Java o dalle interfacce definite all'interno dell'applicazione. Ciò significa che testare questa classe senza alcun framework è un gioco da ragazzi e potrebbe essere eseguito localmente su una JVM . Un altro piccolo ma utile suggerimento è che tutte le diverse interazioni di questa classe possono essere chiamate tramite un'unica funzione generica handleInput(...) . Ciò fornisce un unico punto di ingresso per testare ogni comportamento di questa classe.

Si noti inoltre che nella funzione evaluateExpression() sto restituendo una classe di tipo Optional<String> dal back-end. Normalmente userei ciò che i programmatori funzionali chiamano Both Monad , o come preferisco chiamarlo, Result Wrapper . Qualunque sia il nome stupido che usi, è un oggetto in grado di rappresentare più stati diversi attraverso una singola chiamata di funzione. Optional è un costrutto più semplice che può rappresentare un null o un valore del tipo generico fornito. In ogni caso, poiché al back-end potrebbe essere assegnata un'espressione non valida, vogliamo fornire alla classe ControlLogic alcuni mezzi per determinare il risultato dell'operazione di back-end; tenendo conto sia del successo che del fallimento. In questo caso, null rappresenterà un errore.

Di seguito è riportato un esempio di classe di test che è stata scritta utilizzando JUnit e una classe che in gergo di test è chiamata Fake :

 public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } } public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } }

Come puoi vedere, non solo questa suite di test può essere eseguita molto rapidamente, ma non ci è voluto molto tempo per scrivere. In ogni caso, esamineremo ora alcune cose più sottili che hanno reso molto facile scrivere questa classe di test.

Il potere dell'astrazione e dell'inversione delle dipendenze

Ci sono altri due concetti importanti che sono stati applicati a CalculatorControlLogic che lo hanno reso banalmente facile da testare. In primo luogo, se ti sei mai chiesto quali sono i vantaggi dell'utilizzo di interfacce e classi astratte (denominate collettivamente abstractions ) in Java, il codice sopra è una dimostrazione diretta. Poiché la classe da testare fa riferimento ad astrazioni anziché a classi concrete , siamo stati in grado di creare duplicati di test falsi per l' interfaccia utente e il back-end dall'interno della nostra classe di test. Finché questi doppi di test implementano le interfacce appropriate, a CalculatorControlLogic non potrebbe importare di meno che non siano la cosa reale.

In secondo luogo, CalculatorControlLogic ha ricevuto le sue dipendenze tramite il costruttore (sì, questa è una forma di Dependency Injection ), invece di creare le proprie dipendenze. Pertanto, non ha bisogno di essere riscritto quando viene utilizzato in un ambiente di produzione o test, il che è un vantaggio per l'efficienza.

Dependency Injection è una forma di Inversion Of Control , che è un concetto difficile da definire in un linguaggio semplice. Sia che tu usi l' iniezione di dipendenza o un modello di localizzazione del servizio , entrambi ottengono ciò che Martin Fowler (il mio insegnante preferito su questi argomenti) descrive come "il principio di separare la configurazione dall'uso". Ciò si traduce in classi più facili da testare e più facili da costruire in isolamento l'una dall'altra.

Test della logica di calcolo

Infine, arriviamo alla classe ComputationLogic , che dovrebbe approssimare un dispositivo IO come un adattatore a un server remoto o un database locale. Dal momento che non abbiamo bisogno di nessuno di questi per un semplice calcolatore, sarà solo responsabile di incapsulare la logica richiesta per convalidare e valutare le espressioni che gli diamo:

 public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }

Non c'è molto da dire su questa classe poiché in genere ci sarebbe un accoppiamento stretto con una particolare libreria di back-end che presenterebbe problemi simili a una classe strettamente accoppiata ad Android. Tra un momento discuteremo cosa fare con tali classi, ma questa è così facile da testare che potremmo anche provare:

 public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }

Le classi più semplici da testare sono quelle a cui viene semplicemente assegnato un valore o un oggetto e ci si aspetta che restituiscano un risultato senza la necessità di chiamare alcune dipendenze esterne. In ogni caso, arriva un punto in cui, non importa quanta magia dell'architettura software applichi, dovrai comunque preoccuparti delle classi che non possono essere disaccoppiate da piattaforme e framework. Fortunatamente, c'è ancora un modo in cui possiamo impiegare l'architettura del software per: nel peggiore dei casi rendere queste classi più facili da testare e, nel migliore dei casi, così banalmente semplice che il test può essere eseguito a colpo d'occhio .

Oggetti umili e viste passive

I due nomi precedenti si riferiscono a uno schema in cui un oggetto che deve parlare con dipendenze di basso livello è semplificato così tanto che probabilmente non ha bisogno di essere testato. Sono stato introdotto per la prima volta a questo modello tramite il blog di Martin Fowler sulle variazioni di Model-View-Presenter. Successivamente, attraverso i lavori di Robert C. Martin, mi è stata introdotta l'idea di trattare alcune classi come Humble Objects , il che implica che questo modello non deve essere limitato alle classi dell'interfaccia utente (sebbene non intendo dire che Fowler mai implicava una tale limitazione).

Qualunque cosa tu scelga di chiamare questo modello, è deliziosamente semplice da capire e, in un certo senso, credo che in realtà sia solo il risultato dell'applicazione rigorosa del SOC alle tue classi. Sebbene questo modello si applichi anche alle classi back-end, utilizzeremo la nostra classe dell'interfaccia utente per dimostrare questo principio in azione. La separazione è molto semplice: le classi che interagiscono con le dipendenze dalla piattaforma e dal framework, non pensano da sole (da cui i soprannomi Humble e Passive ). Quando si verifica un evento, l'unica cosa che fanno è inoltrare i dettagli di questo evento a qualsiasi classe logica stia ascoltando:

 //from CalculatorActivity's onCreate() function: evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } });

La classe logica, che dovrebbe essere banalmente facile da testare, è quindi responsabile del controllo dell'interfaccia utente in modo molto fine. Invece di chiamare una singola funzione generica updateUserInterface(...) sulla classe user interface e lasciarla fare il lavoro di un aggiornamento in blocco, l' user interface (o un'altra classe simile) possiederà funzioni piccole e specifiche che dovrebbero essere facili da nominare e implementare:

 //Interface functions of CalculatorActivity: @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } //…

In linea di principio, questi due esempi dovrebbero darti abbastanza per capire come implementare questo modello. L'oggetto che possiede la logica è accoppiato in modo lasco e l'oggetto che è strettamente accoppiato a fastidiose dipendenze diventa quasi privo di logica.

Now, at the start of this subsection, I made the statement that these classes become arguably unnecessary to test, and it is important we look at both sides of this argument. In an absolute sense, it is impossible to achieve 100% test coverage by employing this pattern, unless you still write tests for such humble / passive classes. It is also worth noting that my decision to use a Calculator as an example App, means that I cannot escape having a gigantic mass of findViewById(...) calls present in the Activity. Giant masses of repetitive code are a common cause of typing errors, and in the absence of some Android UI testing frameworks, my only recourse for testing would be via deploying the feature to a device and manually testing each interaction. Ahia.

It is at this point that I will humbly say that I do not know if 100% code coverage is absolutely necessary. I do not know many developers who strive for absolute test coverage in production code, and I have never done so myself. One day I might, but I will reserve my opinions on this matter until I have the reference experiences to back them up. In any case, I would argue that applying this pattern will still ultimately make it simpler and easier to test tightly coupled classes; if for no reason other than they become simpler to write.

Another objection to this approach, was raised by a fellow programmer when I described this approach in another context. The objection was that the logic class (whether it be a Controller , Presenter , or even a ViewModel depending on how you use it), becomes a God class.

While I do not agree with that sentiment, I do agree that the end result of applying this pattern is that your Logic classes become larger than if you left more decisions up to your user interface class.

This has never been an issue for me as I treat each feature of my applications as self-contained components, as opposed to having one giant controller for managing multiple user interface screens. In any case, I think this argument holds reasonably true if you fail to apply SOC to your front end or back end components. Therefore, my advice is to apply SOC to your front end and back end components quite rigorously.

Further Considerations

After all of this discussion on applying the principles of software architecture to reduce the necessity of using a wide-array of testing frameworks, improve the testability of classes in general, and a pattern which allows classes to be tested indirectly (at least to some degree), I am not actually here to tell you to stop using your preferred frameworks.

For those curious, I often use a library to generate mock classes for my Unit tests (for Java I prefer Mockito , but these days I mostly write Kotlin and prefer Mockk in that language), and JUnit is a framework which I use quite invariably. Since all of these options are coupled to languages as opposed to the Android platform, I can use them quite interchangeably across mobile and web application development. From time to time (if project requirements demand it), I will even use tools like Robolectric , MockWebServer , and in my five years of studying Android, I did begrudgingly use Espresso once.

My hope is that in reading this article, anyone who has experienced a similar degree of aversion to testing due to paralysis by jargon analysis , will come to see that getting started with testing really can be simple and framework minimal .

Ulteriori letture su SmashingMag:

  • Sliding In And Out Of Vue.js
  • Designing And Building A Progressive Web Application Without A Framework
  • Framework CSS o griglia CSS: cosa dovrei usare per il mio progetto?
  • Utilizzo di Flutter di Google per uno sviluppo mobile davvero multipiattaforma