martedì 20 settembre 2011

Test Driven Development: Tutorial DigitalWallet

In questo articolo proveremo a realizzare un piccolo progetto adottando la metodologia Test Driven.
Lavoriamo in ambiente Java e utilizziamo JUnit come framework per i nostri test.

Supponiamo di voler sviluppare l’infrastruttura di base per un progetto di Wallet elettronico, ovvero un portafoglio digitale che mi permetta di memorizzare conti e carte di credito.
Ecco in dettaglio alcuni dei requisiti o potenziali use-case a nostra disposizione:

  1. L’utente accede al proprio Wallet mediante login e password.
  2. Sono previsti due ruoli d’accesso principali al sistema: utente base o amministratore del sistema.
  3. L'utente associa una o più carte di pagamento al proprio Wallet.
  4. L’utente può associare anche un conto corrente o altri strumenti di pagamento.
  5. I pagamenti avvengono interfacciandosi, con un formato noto, ad un servizio di terze parti.
  6. Il Wallet non deve caricare immediatamente i dati sensibili relativi agli strumenti di pagamento. Questi ultimi devono essere recuperati esclusivamente quando servono.
  7. L’utente, una volta all’interno del suo Wallet, potrà visualizzare l'elenco delle carte e dei conti inseriti.
  8. L'utente deve poter inserire dei beneficiari per poter trasferire del denaro.
  9. i beneficiari devono avere anche loro un wallet elettronico di appoggio.
  10. Un wallet è identificato da un codice alfa-numerico univoco.
Ecco quindi la possibile roadmap di test da eseguire:
  • Test base su login e profilazione.
  • Test base sul Wallet: creazione e aggiunta degli strumenti di pagamento.
  • Test di caricamento del Wallet.
  • Test per le operazioni di pagamento con gli strumenti presenti nel Wallet.
Test per login e profilazione

Partiamo quindi dai primi due requisiti e poniamoci la domanda “quali test posso immaginare per questi use-case?”
La profilazione mediante login direi che è stata già affronta nel mio precedente articolo, quindi potremmo partire da quell’esempio tanto per non buttare via nulla.

Riassumiamo brevemente. Volevamo simulare l’accesso ad un’area riservata inserendo utente e password come credenziali ed ottenendo in uscita il ruolo e la pagina di nostra competenza.
Avevamo creato un POJO per i dati utente:


public class User {
   
   private String user;
   private String password;
   private String role;
   private WalletID walletId;
   public User() { … }    
   public User(String user,String password,String role,WalletID walletId) { … }
   
   // Metodi Setter e Getter
   ...
}


Ho aggiunto al mio utente un oggetto WalletID che identifica univocamente il suo portafoglio elettronico.

Una classe Mock per simulare i dati sul mio database:

public class MockDAO {
   
   private List users;
   
   public MockDAO() { … }    
   private boolean openConnection() {return true;}    
   private boolean closeConnection() {return true;}
   private User findUser(User u) { … }    
   public User checkUser(User u) { … }
}

Una classe Manager per gestire il controllo di accesso in base ai ruoli utente:

public class RoleAccessManager {
   
   public static final String NO_ROLE = "NO_ROLE";
   public static final String ADMIN   = "admin";
   public static final String EDITOR  = "user";
   
   private boolean grantedUser;
   private String role;
   
   public RoleAccessManager(User user) { … }
   public String getPageAccess() { … }
}

Ho modificato quest’ultima classe in modo da gestire solo due ruoli, amministratore e utente standard così come previsto dal requisito 2.
>> Refactoring: naturalmente potremmo discutere di come fare il refactoring introducendo una classe Role che mi permetta di gestire quanti ruoli voglio in maniera dinamica, ma al momento atteniamoci alle specifiche in nostro possesso e prendiamo per buona la soluzione poco elegante gestita con le costanti stringa.

Con queste classi così composte abbiamo creato una semplice suite di test per verificare i tre casi possibili di accesso: amministratore, utente e login errata.
La nostra classe manager restituisce il nome della pagina di competenza mentre il ruolo dell’utente e il WalletID vengono letti dal nostro database e memorizzati nella classe pojo di competenza (User).
Assumiamo che il WalletID venga utilizzato, in seguito, per caricare effettivamente i dati del portafoglio elettronico (requisiti 6 e 10). Poichè voglio effettuare dei test già in fase di login sul corretto caricamento di questo oggetto e dato che solo io posso decidere le regole che rendono due WalletID identici, occorre effettuare l’override del metodo equals così da metterci al sicuro per i futuri controlli di uguaglianza:

public class WalletID {
   
   private long walletId;
   
   public WalletID(long walletId) {
       this.walletId = walletId;
   }
   
   public long getWalletId() {
       return walletId;
   }
   
   public boolean equals(Object other) {
       boolean check = false;
       
       if(other instanceof WalletID) {
           if(walletId == ((WalletID)other).getWalletId()) check = true;
       }        
       
       return check;
   }
}

Ecco ad esempio il test per la login da amministratore:

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());
       assertNotNull(user.getWalletId());
       assertEquals(new WalletID(1),user.getWalletId());
       assertNotSame(new WalletID(5), user.getWalletId());
   
}

Con questi test controllo che l’amministratore abbia sempre un portafoglio associato, che questo è sempre diverso da null, che è sempre uguale a quello atteso e che è sempre diverso da un altro ipotetico. Un discorso analogo vale anche per i test di login utente base.

In questo modo abbiamo già implementato il codice per i primi due requisiti facendoci guidare dai nostri test. Una volta verificato che il modulo di login funziona e restuisce il corretto WalletID in base al ruolo dell’utente e alle credenziali inserite, possiamo passare oltre.

Test di base sul Wallet: creazione e aggiunta degli strumenti di pagamento

Dato che dobbiamo gestire un portafoglio elettronico avrò bisogno di una classe principale che lo rappresenti e dato che la mia lista dei requisiti parla di inserire strumenti di pagamento e beneficiari, un buon punto di partenza potrebbe essere il seguente:

public class DigitalWallet {
   WalletID walletId;
   ArrayList tools;
   ArrayList recipients;
   
   public DigitalWallet(WalletID walletId) {
       this.walletId = walletId;
       tools       = new ArrayList();
       recipients = new ArrayList();
   }
   
   public WalletID getWalletId() {
       return walletId;
   }
   public void addTool(PaymentTool tool) {
       tools.add(tool);
   }
   
   public void addRecipient(PaymentRecipient recipient) {
       recipients.add(recipient);
   }    
}

Stando ai requisiti 3 e 4 devo poter gestire un elenco non omogeneo di strumenti di pagamento: carte di credito e conti correnti. Successivamente potrei anche voler gestire delle pre-pagate e chissà quali altri strumenti di pagamento.
>> Design: l’interfaccia PaymentTool mi consente di gestire dignitosamente questa situazione. Sarei potuto partire con due classi base CreditCard e BankAccount e poi fare successivamente il refactoring per passare all’interfaccia, ma questo approccio non ha senso se si ha già una buona idea in mente. E’ naturale che al crescere dell’esperienza saremo in grado di impostare il progetto in modo ben strutturato e quindi il refactoring non sempre rientrerà tra le nostre attività.

La mia interfaccia espone un solo metodo che verrà utilizzato per estrarre i dati nel formato previsto dal sistema di pagamento al quale mi dovrò interfacciare. Semplifichiamo supponendo che il formato sia una banale stringa di campi separati dal carattere pipe “|” :

public interface PaymentTool {
   
   public String useForPayment();
}

In questo modo mi porto a casa anche il requisito 5.

L’oggetto PaymentRecipient è invece una semplice classe pojo che, per semplicità, mi permette di memorizzare il nome utente e il suo WalletID così da poterli successivamente visualizzare in un elenco e chissà cosa altro. Questo pone le basi per i requisiti 8 e 9.

Passiamo quindi ai primi test sull’oggetto DigitalWallet. Cominciamo col caricarne uno vuoto e effettuare su di esso l’operazioni di base addTool che aggiunge un nuovo strumento di pagamento.
Decido che un nuovo PaymentTool può essere inserito nel DigitalWallet solo se non è già presente e per effettuare questo tipo di controlli implemento alcuni metodi a contorno: findTool e canAdd.
Ecco quindi la classe di test per il mio DigitalWallet:

public class DigitalWalletTest extends TestCase {
   public DigitalWallet emptyWallet;
   
   @Before
   public void setUp() throws Exception {
       emptyWallet = new DigitalWallet(new WalletID(1));
   }
   
   @Test
   public void testAddTool() {
       assertFalse(emptyWallet.addTool(null));
       
       assertTrue(emptyWallet.addTool(new CreditCard("12345","Luca Cianci","20121231")));
       assertFalse(emptyWallet.addTool(new CreditCard("12345","Luca Cianci","20121231")));
       
       assertTrue(emptyWallet.addTool(new CreditCard("67891","Luca Cianci","20121231")));
       assertFalse(emptyWallet.addTool(new CreditCard("67891","Luca Cianci","20121231")));
       
       assertTrue(emptyWallet.addTool(new BankAccount("12345","Luca Cianci","bancaXYZ")));
       assertFalse(emptyWallet.addTool(new BankAccount("12345","Luca Cianci","bancaXYZ")));
       
       assertTrue(emptyWallet.addTool(new BankAccount("67891","Luca Cianci","bancaXYZ")));
       assertFalse(emptyWallet.addTool(new BankAccount("67891","Luca Cianci","bancaXYZ")));
   }
}
Affinchè tutto vada liscio occorre ricordarsi dei metodi equals. Come già detto infatti, solo io posso decidere le regole che rendono uguali i miei PaymentTool ragion per cui:


//Classe CreditCard
public boolean equals(Object other) {
       boolean check = false;
       
       if(other instanceof CreditCard && ((CreditCard)other).getNumber() == number) {
           check = true;
       }
       
       return check;
}
// Classe BankAccount
public boolean equals(Object other) {
       boolean check = false;
       
       if(other instanceof BankAccount && ((BankAccount)other).getCode().equals(code) && ((BankAccount)other).getBank().equals(bank)) {
           check = true;
       }
       
       return check;
}

Una volta sicuri che la funzionalità di base per l’inserimento dei tool di pagamento è testata e funzionante, possiamo passare all’integrazione con il database e quindi all’operazione di loading di un wallet pre-esistente. Questo per validare anche i requisiti 6 e 10.

Test per il caricamento dei dati del Wallet

Prima di tutto devo poter caricare il wallet a partire dal suo WalletID e eventualmente visualizzarne il suo contenuto. L’operazione di loading effettua il caricamento dei dati a partire dal mio database.
Predisponiamo quindi la classe MockDAO affinchè sia in grado di caricare un wallet.
>> Refactoring: anche in questo caso, passatemi la semplificazione. Dovremmo implementare classi DAO differenti per differenti tipi di dati. Dovremmo quindi creare una classe MockWalletDAO e eventualmente convertire quella esistente in MockUserDAO, tuttavia ai fini del nostro esempio questo è del tutto irrilevante e lo lascio come spunto per un refactoring interessante del nostro codice.

Con queste modifiche, il MockDAO mette a disposizione due utenti di prova e un wallet con due strumenti di pagamento: una carta di credito e un conto corrente. Ecco in dettaglio il mio costruttore:

public MockDAO() {
       users = new ArrayList();
       users.add(new User("lcianci","p4ssw0rd",RoleAccessManager.ADMIN,new WalletID(1)));
       users.add(new User("luca","p4ssw0rd",RoleAccessManager.USER,new WalletID(2)));
       
       wallets = new ArrayList();
       
       DigitalWallet wallet1 = new DigitalWallet(new WalletID(1));
       wallet1.addTool(new CreditCard("12345","Luca Cianci","20121231"));
       wallet1.addTool(new BankAccount("abcde","Luca Cianci","BancaXYZ"));
       wallets.add(wallet1);
}

Adesso che il nostro DAO è in grado di recuperare i dati del nostro Wallet non resta che la classe di test:

public class DigitalWalletTest extends TestCase {
   public DigitalWallet emptyWallet, wallet1, wallet2;
   
   @Before
   public void setUp() throws Exception {
       emptyWallet = new DigitalWallet(new WalletID(1));
       wallet1 = new DigitalWallet(new WalletID(1));
       wallet2 = new DigitalWallet(new WalletID(2));
   }
   
   @Test
   public void testAddTool() {
       assertFalse(emptyWallet.addTool(null));
       
       assertTrue(emptyWallet.addTool(new CreditCard("12345","Luca Cianci","20121231")));
       assertFalse(emptyWallet.addTool(new CreditCard("12345","Luca Cianci","20121231")));
       
       assertTrue(emptyWallet.addTool(new CreditCard("67891","Luca Cianci","20121231")));
       assertFalse(emptyWallet.addTool(new CreditCard("67891","Luca Cianci","20121231")));
       
       assertTrue(emptyWallet.addTool(new BankAccount("12345","Luca Cianci","bancaXYZ")));
       assertFalse(emptyWallet.addTool(new BankAccount("12345","Luca Cianci","bancaXYZ")));
       
       assertTrue(emptyWallet.addTool(new BankAccount("67891","Luca Cianci","bancaXYZ")));
       assertFalse(emptyWallet.addTool(new BankAccount("67891","Luca Cianci","bancaXYZ")));
   }
   @Test
   public void testLoadData() {
       assertTrue(wallet1.loadData());
       assertFalse(wallet2.loadData());
       
       assertEquals(new CreditCard("12345","Luca Cianci","20121231"), wallet1.findTool(new CreditCard("12345","Luca Cianci","20121231")));
       assertEquals(new BankAccount("abcde","Luca Cianci","BancaXYZ"), wallet1.findTool(new BankAccount("abcde","Luca Cianci","BancaXYZ")));
       assertNull(wallet1.findTool(new BankAccount("111111","Luca Cianci","BancaXYZ")));    
   }
}
>> Refactoring: il metodo loadData mi permette di validare il requisito 6. Potremmo pensare di implementare un pattern VirtualProxy in modo da gestire in maniera ancor più elegante la nostra operazione di caricamento.

Test di pagamento con gli strumenti presenti nel Wallet

A questo punto possiamo passare all’ultimo step che avevamo previsto, ovvero un test per verificare la correttezza delle operazioni di pagamento.
Decido che il mio Wallet avrà uno strumento di pagamento di default che l’utente potrà scegliere tra quelli inseriti. Automaticamente il primo PaymentTool diventa lo strumento di default ed eventualmente mi preoccuperò successivamente di capire come e quando far cambiare questa impostazione all’utente. Il tool di default viene salvato in un’apposita variabile del DigitalWallet in modo da avere un’accesso rapido all’oggetto.
Applico quindi una piccola modifica al metodo addTool del DigitalWallet:

public boolean addTool(PaymentTool tool) {
       boolean check = false;
       
       if(tool != null && canAdd(tool)) {
           tools.add(tool);
           check = true;
       }
       
       if(tools.size() == 1) {
           defaultTool = tool;
       }
       
       return check;
}

Implemento due metodi necessari per chiudere il cerchio:

   public boolean doPayment() {
       boolean check = false;
       
       if(defaultTool != null) {
           defaultTool.useForPayment();
           check = true;
       }
       
       return check;
   }
   
   public boolean setDefaultTool(PaymentTool tool) {
       boolean check = false;
       
       if(tool != null) {
           this.defaultTool = tool;
           check = true;
       }        
       
       return check;        
   }

Con queste modifiche alla mano posso già passare alla mia classe di test alla quale aggiungo:

@Test
public void testDoPayment() {
       assertFalse(emptyWallet.doPayment());
       emptyWallet.addTool(new CreditCard("12345","Luca Cianci","20121231"));
       assertTrue(emptyWallet.doPayment());
}
Questo test presuppone che il metodo useForPayment dei vari PaymentTool funzioni nel modo atteso. Inutile dire che occorre la classe di test per rassicurarci anche su questo:

public class CreditCardTest extends TestCase{
   CreditCard card;
   
   @Before
   public void setUp() throws Exception {
       card = new CreditCard("12345","Luca Cianci","20121231");
   }
   
   @Test
   public void testUseForPayment() {
       assertEquals("12345|Luca Cianci|20121231",card.useForPayment());
   }
}
Infine aggiungo questa classe alla mia TestSuite in modo da poter eseguire tutti i test con un solo click:

public class AllTests {
   public static Test suite() {
       TestSuite suite = new TestSuite("Test for it.lcianci.junit.sample.dwallet.tests");
   
       suite.addTestSuite(LoginTest.class);
       suite.addTestSuite(CreditCardTest.class);
       suite.addTestSuite(DigitalWalletTest.class);
               
       return suite;
   }
}

>> Refactoring e Design: ovviamente non tutto quello che abbiamo visto è elegante, completo o correttamente implementato. Mancano controlli di sicurezza e validazione, mancano la parte sui beneficiari e quella per la visualizzazione e chissà quante altre cose. Un buon esercizio potrebbe essere quello di implementare i pezzi mancanti in modo da colmare questo vuoto e prendere dimestichezza con la metodologia.

Conclusioni

Cosa succede adesso?
Abbiamo implementato alcuni metodi di base per il nostro DigitalWallet e verificato che funzionano. Il passo successivo sarebbe quello di implementare una semplice applicazione Web che permette all’utente di interagire con il suo portafoglio elettronico.
In sintesi:
  • Connessione reale al database.
  • Pagina di login.
  • Pagina di accesso profilato con le funzionalità base di aggiunta strumenti e pagamento usando il DigitalWallet.


Il nostro tutorial termina qui. Spero di aver eliminato un pò del fumo che sta intorno alla metodologia test driven e di aver trasmesso correttamente il tipo di approccio da utilizzare.

Per quelli più interessati è possibile scaricare i sorgenti del progettino di cui abbiamo discusso.

Nessun commento:

Posta un commento