mercoledì 14 settembre 2011

Test Driven Development - Metodo e esempi

Il temine Test Driven Development (TDD) indica la modalità di sviluppo software "guidata dai test", quindi non bisogna concentrarsi su come implementare il codice ma su quali test posso mettere in pista per testare una determinata funzionalità o use-case, solo allora scriverò il mio codice applicativo facendomi appunto guidare dai test previsti.
Per capirci, se ho uno use case del tipo

"l'utente che accede al sistema con utente e password verrà inoltrato verso la pagina di sua competenza in base al ruolo che egli ricopre (amministratore, editore, autore)"

dovrò:
  • simulare l'inserimento di utente e password,
  • validare i dati con una qualche classe dao per accedere alla mia base dati (reale o fittizia),
  • capire quale ruolo è associato all’utente,
  • decidere quale sarà la landing-page.
Dal punto di vista della scrittura del codice a me vengono in mente questi passi:
  • implementare una classe pojo per memorizzare i dati dell’utente,
  • implementare una classe dao per accedere al database,
  • implementare una classe manager per verificare il ruolo dell’utente.
Con queste classi a disposizione potrò già verificare il modulo di login della mia applicazione.

Due concetti fondamentali: unit tests and refactoring.
Ragionare per Unit Test presuppone che il nostro codice sia scritto in modo da rilasciare di volta in volta dei piccoli pezzi completamente funzionanti che permettano appunto di testare un "giro completo" per quella particolare funzionalità.
Il refactoring consiste invece nel ripulire il codice una volta che abbiamo testato la funzionalità con successo. Ovvero, adesso che funziona posso concentrarmi nel rendere il codice più semplice ed elegante eliminando duplicazioni e ridondanza.

Vantaggi del TDD
  • Posso concentrarmi sullo sviluppo di piccoli pezzi che andrò subito a testare.
  • Fasi di sviluppo piccole e incrementali.
  • Sono sempre in grado di effettuare dei test di non regressione sul codice precedentemente realizzato.
  • Maggiore comprensione dei requisiti richiesti.
  • Codice più modulare e ben progettato.
Il Cammino del Test Driven Developer

  1. Analizzare gli use-case
  2. Definire e creare i test
  3. Implementare il codice
  4. Eseguire i test sul codice
  5. Se i test hanno esito positivo effettuare il refactoring del codice
  6. Valutare la possibilità di applicare dei design patterns
Ovviamente all'aumentare dell'esperienza mi aspetto che i design patterns siano già in mente e vengano adottati a prescindere dai test e dal refactoring del codice.

Unit test vs Functional test
Gli unit test sono test dal punto di vista del programmatore e non dell'utente finale. I test funzionali invece, dimostrando l'aderenza di una funzionalità alle specifiche, sono orientati verso il punto di vista dell'utente finale.
Lo unit test mi permette di verificare piccoli moduli del mio software (unità) e mi da la ragionevole certezza che l'applicazione in generale possa funzionare bene una volta che si rilascia il pacchetto vero e proprio.
Il test funzionale prevede il passaggio di un input e la restituzione di un output al nostro software ma quello che succede in mezzo viene oscurato con il principio della "black box". Non sapremo mai se tutto quello che avviene in mezzo si comporta esattamente come dovrebbe.
Quante volta abbiamo testato un applicazione web passando i parametri alla servlet e verificando che l'output in uscita fosse corretto? e quante volte abbiamo scoperto a posteriori che la procedura interna che salvava sul database si perdeva dei campi per strada impostando dei valori NULL? ecco appunto...

Framework java di riferimento
JUnit è lo standard di fatto (http://www.junit.org)
Definisce un linguaggio standard per scrivere ed eseguire i test. Usa la reflection per esaminare i test e il codice da testare, questo permette al framework di eseguire qualunque classe o metodo e verificare i risultati di tale esecuzione.
Gli oggetti di base utilizzati per la definizione dei test sono i seguenti:

  • TestCase: Classe astratta per implementare un test specifico.
  • TestSuite: Classe per organizzare e gestire gruppi di test singoli.
  • Assertions: Classe per testare particolari risultati attesi (assertNotNull(..), assertEquals(..), assertSame(..), etc.)
  • TestRunner: Classe che esegue i test in ambiente grafico o text-based.
  • Failure: Classe che indica il fallimento di un risultato atteso (i.e., assertNotNull(..) returned false)
  • Error: Classe di Eccezione che indica il blocco del test.
Al momento JUnit è arrivato alla versione 4 e supporta le Annotation, cosa che semplifica ulteriormente la scrittura delle classi di test.

Classi Mock, Stub o Skeleton
Ma come faccio a testare un piccolo modulo applicativo se non ho ancora sviluppato tutti i pezzi che servono allo scopo?
La risposta è "implementando delle classi fittizie che simulano le operazioni previste per i moduli non ancora pronti".
Queste particolari classi prendono il nome di classi Mock, Stub o Skeleton proprio perchè rappresentano l'involucro di partenza per una classe complessa e implementano le logiche previste in maniera semplice o simulata. Quello che importa è che le interfacce (i metodi) per accedere al nostro stub siano già quelle che prevediamo di usare in produzione.

Tornando al test per la login potremmo avere una classe mock che simula la lettura sul database e restituisce le credenziali dell'utente e il ruolo di accesso. In questo modo non saremo obbligati da subito a concentrarci sui dettagli di come effettuare la connessione al database, quali tabelle leggere e altre cose del genere e potremo invece testare il comportamento del nostro modulo nel caso in cui le credenziali siano corrette o meno.
Ho volutamente semplificato la differenza tra Stub e Mock object. Il concetto è quello sopra espresso ma in generale uno Stub rimane invariato per tutti i test che andrò ad eseguire, quindi si comporta come una costante all’interno del mio test mentre un Mock, grazie anche all’ausilio dei framework, può essere una classe parametro modificabile al variare del mio test.
Ad esempio grazie al pattern di injection offerto da Spring posso configurare le classi mock come parametri all’interno del file beans.xml che definisce la mia intera applicazione e impostare dei “bypass” per cortocircuitare determinati componenti.

Best practice per i test
  • Semplicità: i singoli test dovrebbero essere il più semplice possibile in modo da essere facili da comprendere.
  • Facilità di esecuzione: se sono facili da eseguire verranno lanciati più frequentemente e con meno fatica.
  • Minimizzazione delle dipendenze: evitare che il nostro test sia legato a troppe classi o package. Questo concetto riporta al punto precedente.
  • Autoconsistenza: se servono degli oggetti con i quali interagire, la mia suite li alloca e poi li distrugge.
  • Documentazione: i test vanno documentati come fossero classi di un normale package.
La dura realtà
Tutto quello di cui abbiamo discusso fino ad ora funziona ed è corretto, il problema nasce quando dobbiamo prendere atto della complessità reale del nostro progetto di tipo Enterprise.
Le classi Mock consentono infatti di simulare il comportamento atteso dal nostro codice di produzione, ma quello che diventa complicato è il testing di un’applicazione enterprise dove occorre interagire con l’application server e i suoi componenti. Pensiamo ad esempio a una servlet che utilizza un oggetto dao il quale a sua volta sfrutta il connection pool dell’application server per accedere alla base dati. E’ realistico pensare di realizzare oggetti Mock con questo livello di complessità?
La risposta è no.

Esistono dei framework che ci permettono ad esempio di simulare l’esecuzione dei test all’interno di un container java, vedi ad esempio Cactus di Jakarta ( http://jakarta.apache.org/cactus/). Anche con questi framework a disposizione direi che il lavoro “test driven” non risulta particolarmente semplice e rapido, in certi casi trovo comunque che sia meglio implementare i componenti remoti e cercare di semplificarne la logica o simularli in maniera semplificata appoggiandosi magari a tecnologie più snelle rispetto a Java che riducono l’effort di implementazione, uno tra i tanti PHP.
Se ad esempio volessi verificare che il mio modulo effettua correttamente il redirect su una pagina mediante un oggetto HttpClient, potrei generare le landing-pages in php evitando di dover fare tutto il lavoro su componenti java. E’ solo una delle possibili strade ed è quella che in certi casi io ho intrapreso.
I puristi potranno dirmi che se usi Java non ha senso mischiarlo con PHP. Io trovo invece che se qualcosa ti semplifica la vita non puoi ignorarla a priori o per partito preso.

Una nota su Cactus: scopro solo adesso che a partire da agosto 2011 Cactus è stato messo in “soffitta” da Apache per carenza di sviluppatori (http://attic.apache.org/projects/jakarta-cactus.html)

Il mio codice di esempio
Ecco qui le classi per effettuare il semplice test di login di cui abbiamo discusso.

package it.lcianci.junit.sample;

/**
* Classe pojo per salvare i dati utente
*
* @author lcianci
*
*/
public class User {
   
   private String user;
   private String password;
   private String role;
   
   public User() {
       this.user     = "NO_USER";
       this.password = "NO_PASSWORD";
       this.role     = RoleAccessManager.NO_ROLE;
   }
   
   public User(String user, String password, String role) {
       this.user     = user;
       this.password = password;
       this.role     = role;
   }
   
   public void setUser(String user) {
       this.user = user;
   }
   public String getUser() {
       return user;
   }
   
   public void setPassword(String password) {
       this.password = password;
   }
   public String getPassword() {
       return password;
   }
   public void setRole(String role) {
       this.role = role;
   }
   public String getRole() {
       return role;
   }
}
package it.lcianci.junit.sample;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
/**
* Classe mock per simulare i dati sul database
*
* @author lcianci
*
*/
public class MockDAO {
   
   private List users;
   
   public MockDAO() {
       users = new ArrayList();
       users.add(new User("lcianci","p4ssw0rd","admin"));
       users.add(new User("luca","p4ssw0rd","editor"));
       users.add(new User("luca.cianci","p4ssw0rd","author"));
   }
   
   private boolean openConnection() {return true;}
   
   private boolean closeConnection() {return true;}
   
   private User findUser(User u) {
       User u1 = null;
       
       Iterator i = users.iterator();
       while(i.hasNext()) {
           u1 = i.next();
           if(u1.getUser().equals(u.getUser())) break;
           else u1 = null;
       }
       
       if(u1 == null) u1 = new User();
       
       return u1;
   }
   
   public User checkUser(User u) {
       User u1 = null;
       
       if(u != null) {        
           u1 = findUser(u);            
       }
       
       return u1;
   }
}
package it.lcianci.junit.sample;
/**
* Classe manager per gestire il controllo dei permessi
*
* @author lcianci
*
*/
public class RoleAccessManager {
   
   public static final String NO_ROLE = "NO_ROLE";
   public static final String ADMIN   = "admin";
   public static final String EDITOR  = "editor";
   public static final String AUTHOR  = "author";
   
   private boolean grantedUser;
   private String role;
   
   public RoleAccessManager(User user) {        
       role        = NO_ROLE;
       grantedUser = false;
       
       if(user != null) {
           role        = user.getRole();
           grantedUser = true;
       }    
   }
   public String getPageAccess() {
       String page = "error.jsp";
       
       if(grantedUser) {
           if(role.equals(ADMIN)) {
               page = "admin.jsp";                
           }
           else if(role.equals(EDITOR)) {
               page = "editor.jsp";
           }
           else if(role.equals(AUTHOR)) {
               page = "author.jsp";
           }
       }        
       
       return page;
   }
}
package it.lcianci.junit.sample;
import junit.framework.TestCase;
import org.junit.Before;
/**
* Classe di Test
*
* @author lcianci
*
*/
public class LoginTest extends TestCase {
   private User u1,u2,u3,u4;
   @Before
   public void setUp() throws Exception {
       u1 = new User("lcianci","p4ssw0rd",null);
       u2 = new User("luca","p4ssw0rd",null);
       u3 = new User("luca.cianci","p4ssw0rd",null);
       u4 = new User("lucacianci","p4ssw0rd",null);
   }
   
   public void testAdminLogin() {
       User user;
       
       MockDAO dao = new MockDAO();
       user = dao.checkUser(u1);
       RoleAccessManager ram = new RoleAccessManager(user);
       String page = ram.getPageAccess();
       
       assertEquals("admin.jsp", page);
       assertEquals(RoleAccessManager.ADMIN, user.getRole());
   }
   
   public void testEditorLogin() {
       User user;
       
       MockDAO dao = new MockDAO();
       user = dao.checkUser(u2);
       RoleAccessManager ram = new RoleAccessManager(user);
       String page = ram.getPageAccess();
       
       assertEquals("editor.jsp", page);
       assertEquals(RoleAccessManager.EDITOR, user.getRole());
   }
   
   public void testAuthorLogin() {
       User user;
       
       MockDAO dao = new MockDAO();
       user = dao.checkUser(u3);
       RoleAccessManager ram = new RoleAccessManager(user);
       String page = ram.getPageAccess();
       
       assertEquals("author.jsp", page);
       assertEquals(RoleAccessManager.AUTHOR, user.getRole());
   }
   
   public void testInvalidLogin() {
       User user;
       
       MockDAO dao = new MockDAO();
       user = dao.checkUser(u4);
       RoleAccessManager ram = new RoleAccessManager(user);
       String page = ram.getPageAccess();
       
       assertEquals("error.jsp", page);
       assertEquals(RoleAccessManager.NO_ROLE, user.getRole());
   }
}
package it.lcianci.junit.sample;
import junit.framework.Test;
import junit.framework.TestSuite;
/**
* Classe TestSuite
*
* @author lcianci
*
*/
public class AllTests {
   public static Test suite() {
       TestSuite suite = new TestSuite("Test for it.lcianci.junit.sample");
   
       suite.addTestSuite(LoginTest.class);
       
       return suite;
   }
}

Riferimenti
  • Test Driven Development: A J2EE Example by Russell Gold, Thomas Hammell and Tom Snyder, ISBN:1590593278, Apress.
  • Test-Driven Development By Example by Kent Beck, ISBN: 0-321-14653-0 Addison Wesley.

Nessun commento:

Posta un commento