|
Home | Blog | Articles | Books | About Me | Contact Me | ThoughtWorks |
Ma molto spesso trovo i mock object descritti malamente. In particolare li trovo sempre confusi con gli stub - un sistema diffuso per gli ambienti di test. Capisco questa confusione - io stesso li ho ritenuti simili per un po', ma varie conversazioni con gli sviluppatori di mock hanno fatto sì che una minima comprensione dei mock entrasse in quel guscio di tartaruga che ho per cranio.
Le differenze sono in realtà due separate. Da un lato c'è la differenza su come i risultati dei test vengono verificati: la distinzione tra verifica sullo stato e verifica sul comportamento. Dall'altra parte c'è la differenza sull'intera filosofia di come il test e il design si combinano tra loro, a cui mi riferisco qua come allo stile classico e mockico del Test Driven Development (TDD).
(Nella prima versione di questo saggio avevo realizzato che ci fosse una differenza, ma avevo considerato le due differenze come una sola. Da allora la mia conoscenza è migliorata e come risultato è giunta l'ora di aggiornare questo saggio. Se non avete letto il saggio precedente potete ignorare le miei dubbi, ho scritto questo saggio come se il precedente non fosse esistito. Ma se avete presente la versione vecchia potrebbe aiutarvi tenere conto che ho spezzato la vecchia dicotomia di test di stato e test di interazione nella dicotomia verifica di stato/comportamento e nella dicotomia TDD classico/mockico. Ho anche modificato il mio vocabolario per allinearlo a quello dell'imminente libro sui pattern xUnit di Gerard Meszaros.)
Inizierò ad illustrare i due stili con un semplice esempio. (L'esempio è in Java, ma i principi hanno senso con qualsiasi altro linguaggio object-oriented). Si vuole prendere un oggetto Order e riempirlo da un oggetto Warehouse. Order è molto semplice, con solo un prodotto ed una quantità. Warehouse tiene l'inventario dei diversi prodotti. Quando si chiede ad un ordine di evadersi da un magazzino, ci dono due possibili risposte. Se c'è abbastanza prodotto nel magazzino per evadere l'ordine, l'ordine diventa evaso e l'ammontare a magazzino del prodotto viene diminuito dell'ammontare opportuno. Se non c'è abbastanza prodotto in magazzino allora l'ordine resta non evaso e non accade nulla nel magazzino.
Da questi due comportamenti si derivano un paio di test, i quali paiono test JUnit abbastanza convenzionali.public class OrderStateTester extends TestCase {
private static String TALISKER = "talisker";
private static String HIGHLAND_PARK = "Highland Park";
private Warehouse warehouse = new WarehouseImpl();
protected void setUp() throws Exception {
warehouse.add(TALISKER, 50);
warehouse.add(HIGHLAND_PARK, 25);
}
public void testOrderIsFilledIfEnoughInWarehouse() {
Order order = new Order(TALISKER, 50);
order.fill(warehouse);
assertTrue(order.isFilled());
assertEquals(0, warehouse.getInventory(TALISKER));
}
public void testOrderDoesNotRemoveIfNotEnough() {
Order order = new Order(TALISKER, 51);
order.fill(warehouse);
assertFalse(order.isFilled());
assertEquals(50, warehouse.getInventory(TALISKER));
}
I test xUnit seguono una sequenza tipica di quattro fasi: preparazione, esecuzione, verifica, pulizia. In questo caso la fase di preparazione viene svolta in parte nel metodo setUp (preparazione del magazzino) ed in parte nel metodo di test (preparazione dell'ordine). La chiamata a order.fill è la fase di esecuzione. Qua è dove l'oggetto va a fare ciò che vogliamo testare. Le istruzione di assert sono la fase di verifica, per verificare su il metodo dell'esecuzione ha portato a termine correttamente il proprio compito. In questo caso non c'è alcuna fase esplicita di pulizia, la fa per noi implicitamente il garbage collector.
Durante la preparazione ci sono due tipologie di oggetto che stiamo mettendo assieme. Order è la classe che stima testando, ma affinché Order.fill funzioni abbiamo anche bisogno di un'istanza di Warehouse. In questa situazione è Order l'oggetto su cui ci stiamo svilppando per il test. Alla gente "orientata ai test" piace usare termini come oggetto-sotto-test o sistema-sotto-test per tali oggetti. Entrambi i termini sono angoscianti da dire, ma sono termini largamente accettati quindi mi tapperò il naso e li userò. Seguendo Meszaros userò Sistema Sotto Test, o o meglio l'abbreviazione SUT (System Under Test, NdT).
Così, per questo test ho bisogno del SUT (Order) e di un collaboratore (Warehouse). Ho bisogno del magazzino per due ragioni: la prima è perché il test possa funzionare veramente (in quanto Order.fill chiama i metodo di Warehouse) e poi ne ho bisogno per la verifica (in quanto uno degli effetti di Order.fill è un potenziale cambiamento allo stato del magazzino). Man mano che ci si addentra nell'argomento vedremo che si farà una grande distinzione fra SUT e i collaboratori. (Nella versione precedente di questo articolo mi riferivo al SUT come all'oggetto primario e ai collaboratori come agli oggetti secondari).
Questo stile di test usa la verifica di stato: significa che si determina se il metodo eseguito ha lavorato correttamente esaminando lo stato del SUT e dei suoi collaboratori dopo che il metodo è stato invocato. Come vedremo i mock object consentono un approccio diverso alla verifica.
public class OrderInteractionTester extends MockObjectTestCase {
private static String TALISKER = "Talisker";
public void testFillingRemovesInventoryIfInStock() {
//setup - data
Order order = new Order(TALISKER, 50);
Mock warehouseMock = new Mock(Warehouse.class);
//setup - expectations
warehouseMock.expects(once()).method("hasInventory")
.with(eq(TALISKER),eq(50))
.will(returnValue(true));
warehouseMock.expects(once()).method("remove")
.with(eq(TALISKER), eq(50))
.after("hasInventory");
//exercise
order.fill((Warehouse) warehouseMock.proxy());
//verify
warehouseMock.verify();
assertTrue(order.isFilled());
}
public void testFillingDoesNotRemoveIfNotEnoughInStock() {
Order order = new Order(TALISKER, 51);
Mock warehouse = mock(Warehouse.class);
warehouse.expects(once()).method("hasInventory")
.withAnyArguments()
.will(returnValue(false));
order.fill((Warehouse) warehouse.proxy());
assertFalse(order.isFilled());
}
Concentratevi prima su testFillingRemovesInventoryIfInStock, in quanto ho adottato un paio di scorciatoie nell'ultimo test.
Tanto per iniziare, la fase di preparazione è molto diversa. All'inizio è divisa in due parti: dati ed attese. La parte dati prepara gli oggetti con cui vogliamo lavorare, in questo senso è simile alla preparazione tradizionale. La differenza sta nella creazione degli oggetti. Il SUT è lo stesso - un Order. Ma il collaboratore non è un oggetto Warehouse, ma invece è un Warehouse mock - tecnicamente è un'istanza della classe Mock.
La seconda parte della preparazione crea le attese sul mock object. Le attese indicano quali metodi dovrebbero essere chiamati sui mock quando il SUT viene invocato.
Una volta che tutte le attese sono state sistemate si può invocare il SUT. Dopo l'invocazione si fa quindi la verifica, che ha due momenti. Si eseguono le assert sul SUT - proprio come prima. Però si verificano anche i mock - per verificare che siano stati invocati rispettando le attese.
Il punto fondamentale è il come si verifica che Order abbia fatto la cosa giusta interagendo con il Warehouse. Con la verifica di stato lo si fa verificando lo stato del Warehouse. I mock usano la verifica del comportamento, in cui si verifica se Order ha fatto le giuste invocazioni sul Warehouse. Lo si fa insegnando durante la fase di preparazione al mock cosa aspettarsi e chiedendo al mock di verificare se stesso durante la fase di verifica. Solo l'ordine viene controllato usando le assert, e se il metodo non dovesse modificare lo stato dell'oggetto non ci sarebbe alcuna assert.
Nel secondo test faccio un paio di cose diverse. Prima creo il mock diversamente, usando il metodo mock nel MockObjectTestCase anziché che nel costruttore. Questo è un metodo di supporto della libreria jMock, il che significa che non bisogna chiamare esplicitamente la verifica successivamente, ogni mock creato col metodo di supporto verrà automaticamente verificato alla fine del test. Avrei potuto farlo anche nel primo test, ma volevo far vedere la verifica in modo più esplicito per mostrare come funziona il test con i mock.
La seconda cosa diversa nel secondo caso di test è che ho rilassato i vincoli sulle attese usando withAnyArguments. La ragione di ciò è che il primo test controlla che il numero venga passato al Warehouse, quindi il secondo test non ha bisogno di ripetere quella verifica. Se si dovrà modificare successivamente la logica dell'ordine, allora solo un test fallirà, facilitando il lavoro per la modifica dei test. Come si può vedere, avrei potuto evitare withAnyArguments in quanto è il default.
Ci sono parecchie librerie di mock object in giro. Una con cui ho lavorato un po', sia nella sua versione Java che .NET, è EasyMock. Anche EasyMock consente la verifica del comportamento, ma ha un paio di differenze di stile rispetto a jMock che val la pena discutere. Ecco di nuovo il solito test:
public class OrderEasyTester extends TestCase {
private static String TALISKER = "Talisker";
private MockControl warehouseControl;
private Warehouse warehouseMock;
public void setUp() {
warehouseControl = MockControl.createControl(Warehouse.class);
warehouseMock = (Warehouse) warehouseControl.getMock();
}
public void testFillingRemovesInventoryIfInStock() {
//setup - data
Order order = new Order(TALISKER, 50);
//setup - expectations
warehouseMock.hasInventory(TALISKER, 50);
warehouseControl.setReturnValue(true);
warehouseMock.remove(TALISKER, 50);
warehouseControl.replay();
//exercise
order.fill(warehouseMock);
//verify
warehouseControl.verify();
assertTrue(order.isFilled());
}
public void testFillingDoesNotRemoveIfNotEnoughInStock() {
Order order = new Order(TALISKER, 51);
warehouseMock.hasInventory(TALISKER, 51);
warehouseControl.setReturnValue(false);
warehouseControl.replay();
order.fill((Warehouse) warehouseMock);
assertFalse(order.isFilled());
warehouseControl.verify();
}
}
EsayMock utilizza la metafora registra/rispondi per impostare le attese. Per ogni oggetto di cui si vuole un mock bisogna creare un controllo e un mock object. Il mock soddisfa l'interfaccia dell'oggetto secondario, il controllo fornisce funzionalità aggiuntive. Per indicare un'attesa si invoca il metodo sul mock con gli argomenti che ci si aspetta. Lo si fa sul controllo se si vuole un valore di ritorno. Un volta finito di impostare le attese si chiama replay sul controllo - a questo punto il mock finisce la fase di registrazione ed è pronto a rispondere all'oggetto principale (SUT, ndT). Una volta finito si invoca la verify sul controllo.
Sembra che nonostante la gente rimanga inizialmente confusa dalla metafora registra/rispondi, ci si abitua poi velocemente. Si ha un vantaggio rispetto ai limiti di jMock nel senso che si fanno le vere chiamate ai metodi del mock piuttosto che specificando i nomi nelle stringhe. Ciò significa che si può usare il completamento del codice del proprio IDE e che ogni refactor sui nomi del metodo aggiorna automaticamente i test. La controparte è che non si possono avere vincoli più deboli.
Gli sviluppatori di jMock stanno lavorando su una nuova versione che permetterà di usare altre tecniche per consentire l'uso delle chiamate dei metodi.
Quando si fanno test di questo tipo ci si concentra su un elemento del software alla volta - da cui il termine comune unit test. Il problema è che per far funzionare una singola unit, si ha spesso bisogno di altre unit - vedi il bisogno di avere un magazzino nel nostro esempio.
public interface MailService {
public void send (Message msg);
}
public class MailServiceStub implements MailService {
private List<Message> messages = new ArrayList<Message>();
public void send (Message msg) {
messages.add(msg);
}
public int numberSent() {
return messages.size();
}
}
Possiamo poi usare la verifica di stato sullo stub così.
class OrderStateTester...
public void testOrderSendsMailIfUnfilled() {
Order order = new Order(TALISKER, 51);
MailServiceStub mailer = new MailServiceStub();
order.setMailer(mailer);
order.fill(warehouse);
assertEquals(1, mailer.numberSent());
}
Naturalmente questo test è molto semplice - so solo che è stato inviato un messaggio. Non abbiamo testato se sia stato inviato alla persona corretta o con il giusto contenuto, ma spiega bene il punto.
Usando i mock questo test sarebbe molto diverso.
class OrderInteractionTester...
public void testOrderSendsMailIfUnfilled() {
Order order = new Order(TALISKER, 51);
Mock warehouse = mock(Warehouse.class);
Mock mailer = mock(MailService.class);
order.setMailer((MailService) mailer.proxy());
mailer.expects(once()).method("send");
warehouse.expects(once()).method("hasInventory")
.withAnyArguments()
.will(returnValue(false));
order.fill((Warehouse) warehouse.proxy());
}
}
In entrambi i casi sto usando un doppione di test del servizio di posta reale. C'è anche una differenza in quanto lo stub usa la verifica di stato mentre il mock usa la verifica di comportamento.
Al fine di usare la verifica di stato sullo stub c'è bisogno di qualche metodo aggiuntivo sullo stub per permettere la verifica. Come risultato lo stub implementa MailService ma aggiunge metodi extra.
I mock object escono dalla comunità XP, e una delle caratteristiche principali di XP è l'enfasi sul Test Driven Development - in cui il design del sistema evolve attraverso iterazioni guidate dalla stesura di test.
Nel TDD classico c'è bisogno di creare non soltanto il SUT ma anche tutti i collaboratori che il SUT richiede in risposta ai test. Mentre nell'esempio ci sono solo un paio di oggetti, i test reali coinvolgono spesso un grande numero di oggetti secondari. Di solito questi oggetti vengono creati e distrutti ad ogni esecuzione dei test.
I test mockici, comunque, hanno bisogno solo di creare il SUT e i mock per i vicini più prossimi. Ciò può evitare del lavoro richiesto per costruire funzionalità complesse. (Almeno in teoria. Ho sentito racconti di preparazioni di mock piuttosto complesse, ma ciò potrebbe essere dovuto all'utilizzo non corretto dello strumento.)
Se si infila un bug in un sistema col test mockico, in genere accadrà che falliscono solo i test del SUT che contiene il baco. Nell'approccio classico invece tutti i test degli oggetti client posso altresì fallire, il che porta a fallimenti dove l'oggetto bacato viene utilizzato come collaboratore nel test di altri oggetti. Come effetto una falla in un oggetto largamente usato provoca un'onda di test falliti in tutto il sistema.
I mockist lo considerano un argomento rilevante; si finisce a fare un sacco di debug al fine di trovare l'errore vero e correggerlo. Comunque i classicisti non lo considerano una fonte di problemi. Di solito il colpevole è facile da individuare guardando quali test falliscono e gli sviluppatori possono distinguere i fallimenti derivati dalla falla originale. Inoltre, se si sta testando regolarmente (some si dovrebbe), allora si sa che il fallimento è stata provocato dalle ultime cose che si sono modificate, così non è difficile trovare la falla.
Un fattore che può essere importante qua è la granularità dei test. Siccome i test classici mettono in opera gli oggetti reali, si individua spesso un test singolo come test principale di un assemblato di oggetti, piuttosto che di uno solo. Se tale assemblato riguarda molti oggetti , allora può essere molto più difficile trovare la vera fonte del bug. Quello che succede qua è che i test hanno granularità troppo grossa.
Accede abbastanza spesso che i test mockici siano meno esposti a questo problema, perché la convenzione dice di fare mock di tutti gli oggetti tranne del principale, il che rende chiaro che servono test più fini per i collaboratori. Detto ciò, è anche vero che l'uso di test con granularità eccessivamente grossa non è necessariamente un fallimento del test classico come metodologia, ma piuttosto è un fallimento nell'uso corretto della tecnica. Una buona regola è di cautelarsi di separare test a grana fine per ogni classe. Mentre gli assemblaggi sono talvolta appropriati, dovrebbero essere limitati a solo pochi oggetti - non più di mezza dozzina. In più, se ci si trova con un problema di debug dovuto a test a grana eccessivamente grossa, si dovrebbe debuggare in modo guidato da test, creando via via test a grana più fine.
In essenza i test xunit classici non sono solo unit test, ma anche mini test di integrazione. Come risultato, a molta gente piace il fatto che i test lato client possano scovare errori che i test di un oggetto potrebbero aver mancato, in particolare il test di quelle aree in cui le classi interagiscono. I testi mockici perdono tale qualità. Inoltre si corre il rischio di sbagliare le attese nei test mockici, ottenendo una barra verde che però nasconde errori intrinsechi.
Ed è qui che sottolineo il fatto che qualsiasi stile si scelga di usare, bisogna combinarlo con con dei test di accettazione a grana più grossa che operino attraverso il sistema come un tuttuno. Mi sono spesso visto in progetti che sono arrivati tardi ad usare test di accettazione e se ne sono pentiti.
Quando si scrive un test mockico, si stanno testando le chiamate al confine del SUT per assicurarsi che parli correttamente ai suoi fornitori. Un test classico si preoccupa solo dello stato finale - non di come ci si giunga. I test mockici sono dunque più legati all'implementazione di un metodo. Cambiare la natura delle chiamate ai collaboratori di solito fa fallire un test mockico.
Questo accoppiamento porta ad un paio di problemi. Quello più importante è l'effetto del Test Driven Development. Con il test mockico, scrivere il test fa pensare all'implementazione del comportamento - infatti i mockisti lo vedono come un vantaggio. I classicisti, comunque, credono che sia importante pensare solo a cosa succede dall'interfaccia esterna e lasciare tutte le considerazioni dell'implementazione a dopo aver scritto il test.
L'accoppiamento all'implementazione influisce anche sul refactoring in quanto è più probabile che i cambiamenti nell'implementazione facciano fallire i test.
Secondo me uno degli aspetti più affascinanti di questi stili di test è il come influenzano le scelte di design. Avendo parlato con entrambi i tipi di utenti mi sono fatto l'idea di qualche differenza tra il design che gli stili comportano, ma sono sicuro che sto solo graffiando la superficie.
Ho già menzionato la differenza sull'affrontare gli strati. Il test mockico favoreggia l'approccio fuori-dentro mentre gli sviluppatori che preferiscono lo stile di modellare il dominio esternamente preferiscono il test classico.
Ad un livello più basso ho notato che i mockisti tendono a rifuggire da metodi che ritornano valori, a favore di metodi che agiscono sopra ad oggetti contenitore. Si consideri il caso in cui bisogna raccogliere informazioni da un gruppo di oggetti per creare una stringa riassuntiva. Un modo diffuso di farlo è di fare che il metodo chiami sui vari oggetti i metodi che restituiscono una stringa e poi assembli le stringhe risultanti in una variabile temporanea. Un mockista preferirebbe passare un buffer di stringa attraverso i diversi oggetti ed far sì che siano loro ad aggiungere le diverse stringhe al buffer - trattando il buffer di stringa come un parametro di raccolta.
La trovo una domanda difficile a cui rispondere con sicurezza. Personalmente sono sempre stato un vecchio classicista del TDD e finora non vedo alcuna ragione per cambiare. Non vedo nessun beneficio irresistibile per nel TDD mockico, e sono spaventato delle conseguenze di accoppiare il test all'implementazione.
Ciò mi ha particolarmente colpito mentre osservavo un programmatore mockico. Mi piace molto il fatto che mentre si scrivono i test ci si concentra sui risultati del comportamento, non su come lo si fa. Un mockista pensa continuamente a come il SUT dovrà essere implementato mentre scrive le attese. Ciò mi sembra innaturale.
Nell'ambito dello unit test sono cresciuti i framework xunit e il Test Driven Develpment; sempre più gente sta scoprendo i mock object. Un sacco di volte la gente impara qualche cosa sul framework mock senza capire completamente la divisione classico/mockico che c'è sotto. Qualsiasi sia lo schieramento che si sceglie, credo sia utile capire la differenza di visione. Mentre non bisogna essere un mockico per trovare il framework mock utile, è utile capire il pensiero che guida molte decisioni di design del software.
Il fine di questo articolo era ed è di estrarre le differenze e di spiegare i vantaggi svantaggi di entrambi. C'è molto di più sul pensiero mockico di quanto io abbia avuto tempo di esporre, in particolare le sue conseguenze sullo stile di design. Spero che nei prossimi anni vedremo più scritti sull'argomento e che ciò approfondirà la nostra conoscenza sulle affascinanti conseguenze di scrivere i test prima del codice.
Per una panoramica sul pensiero della pratica del test xunit, tenete d'occhio l'imminente libro di Gerard Meszaros (rinuncia: è nelle mie serie).
Egli tiene anche un sito con i pattern estratti dal libro.
Per trovare di più sul TDD il primo posto da vedere è il libro di Kent.
Per trovare di più sollo stile mockico di test, il primo posto da guardare è mockobjects.com dove Steve Freeman e Nat Price difendono il punto di vista mockico con articoli ed un blog proficuo. In particolare si legga l'eccellente articolo dell'OOPSLA. Per saperne di più sul Behaviour Driven Development, una diversa derivazione del TDD che è in stile molto mockico, iniziate dall'introduzione di Dan North.
02 gennaio 07: Suddivisa la distinzione originale tra test basato sullo stato e test basto sul comportamento in due: verifica di stato e di comportamento e TDD classico e mockico. Ho fatto anche diverse modifiche di vocabolario per allinearlo a quello del libro di Gerard Meszaros sui pattern xunit.
08 luglio 04: Prima pubblicazione
Tradotto da Marco Perrando.
Nota sul Traduttore: Marco Perrando lavora nel campo dello sviluppo software come sviluppatore e progettista da oltre 10 anni. Attualmente lavora per la SIR - Soluzioni in Rete di Genova.
|
|
© Copyright Martin Fowler, tutti i diritti riservati