how to survive your unit tests
TRANSCRIPT
How to survive your unit tests@racingDeveloper - Gabriele Tondi
Gabriele Tondi @racingDeveloper
Agile Software Developer
Coordinatore: XPUG-MI
Appassionato di:
sviluppo software, metodologie agili (eXtreme Programming), OOD, TDD e motori
Story time
Greenfield Project
Test Driven Development
First Iteration
Product Owner Status
Developers Status
Nuova Funzionalità
Embrace Change
Cambiamento nel Design
Un piccolo cambiamento, tanti test rotti
Come sopravvivere?
Perché facciamo TDD
• Il codice che abbiamo scritto fa quello che ci aspettiamo?
• feedback immediato sul design
• una suite di test per poter fare refactoring spesso senza paura
Perché i nostri test si oppongono al refactoring?
Spesso:
• Non si riesce a capire perché il test fallisce
• Non si riesce a capire cosa vuole verificare il test
• Il test è fragile
• Il test è troppo accoppiato al codice di produzione
ATTENZIONE: il più delle volte un test è difficile da scrivere a causa di un problema
nel design del codice di produzione.
!
Focus di oggi è sul design dei test realizzati durante le iterazioni TDD
Semplici linee guida. L’insieme fa la differenza.
Un buon test
• è conciso e semplice
• quando fallisce riusciamo subito a capire dove si trova il problema
• rappresenta al meglio un valido esempio di comportamento
Naming
@Test public void testThatValuesLowerThan3AreInvalid() { assertFalse(validator.validate(2)); }
@Test public void testThatValuesLowerThan3AreInvalid() { assertFalse(validator.validate(2)); }
Evitare rumore nel nome dei test
Evitare duplicazione nel nome dei testLasciamo che sia il test stesso a dirci cosa succede
Suggerimento: identificare scenari con i nomi dei testEsempi: tooLowValue, notANumber
@Test
public void tooLowValue() { assertFalse(validator.validate(2)); }
Assert
@Test public void formattedErrorMessage() { assertTrue(formatter.format(NOT_FOUND_ERROR) .equals(“Book not found")); }
java.lang.AssertionError at org.junit.Assert.fail(Assert.java:86) at org.junit.Assert.assertTrue(Assert.java:41) at org.junit.Assert.assertTrue(Assert.java:52)
WTF ?!?!?
@Test public void formattedErrorMessage() { assertEquals(“Book not found”, formatter.format(NOT_FOUND_ERROR)); }
org.junit.ComparisonFailure: Expected :Book not found Actual :Libro non trovato <Click to see difference>
Guardate il test fallire!… sempre! Anche se per qualche motivo già passa.
Suggerimenti
• Usate asserzioni specifiche
• Se non fosse possibile, includete un messaggio
• assertFalse(“value is invalid”, validator.validate(…))
• Fate particolare attenzione ai custom matcher! (Hamcrest)
Act
@Test public void filledCart() { ShoppingCart cart = new ShoppingCart();
cart.add(new Item(“item 1”, 2)); assertEquals(2, cart.total());
cart.remove(“item 1”); assertEquals(0, cart.total());
}
Rischi
• Il test può fallire per due o più comportamenti diversi
• Cosa vogliamo veramente testare?
Suggerimenti• Una sola azione per test
• Evitare act -> assert, act -> assert …
• Sfruttare questo smell per spingere il design
• Nel caso specifico: perché non permettere la creazione di un carrello in uno stato preciso?
Arrange
ATTENZIONE: se il test richiede un setup complesso c’è quasi certamente un problema
con il design del codice di produzione.
!
@Test(expected = MissingTitleException.class) public void missingTitle() { new Book(1, “123485”, null, “an adventure book”); }
@Test(expected = MissingISBNException.class) public void missingISBN() { new Book(1, null, “Survive your unit tests", “an adventure book”); }
@Test(expected = MissingIDException.class) public void missingID() // […]
Rischi
• Quali dati sono importanti per ogni test?
• Se domani il codice ISBN deve essere di almeno 14 caratteri, quanti test devo cambiare?
Suggerimento
• Evitate dettagli inutili per il test
• Rendono più complicato capire cosa è importante per il test
• Rischiano di creare accoppiamento
• Potete evidenziare meglio problemi con il design
Object Mother• Classe con delle fixture pre-impostate
• Esempi:
• BookFixture.newBookWithMissingTitle ( )
• BookFixture.newBookWithMissingISBN ( )
• BookFixture.newBookWithMissingID ( )
Rischio
• Le fixture possono aumentare in maniera poco controllabile
• Questa soluzione potrebbe non scalare abbastanza
Builder
• Una classe che permette di generare un oggetto con uno stato preciso, partendo da valori di default sensibili
public class BookBuilder { private long id = 1; private String title = “A BOOK TITLE”; private String isbn = “123466579490”; private String abstract = “a test book”;
private BookBuilder() {}
public static BookBuilder aBook() { return new BookBuilder();
}
public BookBuilder withTitle(String title) { this.title = title; return this;
}
public Book build() { return new Book(id, title, isbn, abstract);
} }
@Test(expected = MissingTitleException.class) public void missingTitle() { aBook().withTitle(null).build(); }
@Test(expected = MissingISBNException.class) public void missingISBN() { aBook().withISBN(null).build(); }
Vantaggi• Ridotta duplicazione
• i valori di default sono impostati in un unico punto
• Aumentato l’espressività del test
• è molto chiara la correlazione tra input ed output, non dobbiamo specificare valori inutili per il test
Metodi privati nella classe di test
@Test public void obsoleteFlight() { givenAnObsoleteFlight(); whenITryToBookIt(); thenIGetAnError()
}
Vantaggi• l’espressività del comportamento è massima
Rischi• fatico ad ottenere feedback dal design
• posso nascondere di tutto nei metodi privati
• i nomi dei metodi potrebbero mentire!
Suggerimento• usate i metodi privati nei test con attenzione
• ogni metodo privato deve essere di 1 o 2 righe
• lo scopo è quello di generare un Domain Specific Language per il test
• un buon test dovrebbe essere leggibile anche senza metodi privati
Stato globale
@Test public void creationDate() { Date currentDate = new Date(); Book book = new Book();
assertThat(book.creationDate(), greaterThan(currentDate)); }
Suggerimenti
• Evitare il più possibile gli stati globali (ad esempio il tempo)
• È possibile iniettare un collaboratore con ruolo Clock (che può avere una implementazione programmabile)
• Oppure… possiamo passare direttamente il dato che ci interessa!
E quando abbiamo diversi messaggi tra diversi oggetti?
Incoming | Outgoing
Object Under TestIncoming
Outgoing
Command Query Separation• Query:
• un messaggio che torna qualcosa
• non ha side-effect
• Comando
• non torna nulla (void)
• ha dei side-effect
Test Object Under Test
Incoming Query Message
query message
Inviamo un messaggio dal test al oggetto sotto test
response
Oggetto sotto test elabora ed invia una risposta
Verifichiamo la risposta
Test Object Under Test
Incoming Command Message
command message
Inviamo un messaggio dal test al oggetto sotto test
Oggetto sotto test elabora e cambia lo stato
Verifichiamo gli effetti pubblici diretti
simple query on state
@Test public void soldBook() { Book book = new Book(); book.sell();
assertFalse(“book cannot be sold again”, book.canBeSold()); }
Esempio
Test Object Under Testresponse
Outgoing Query Message
query message
Inviamo un messaggio dal test al oggetto sotto test
Verifichiamo la risposta
Collaborator
Oggetto sotto test chiede qualcosa al collaboratore
Integration test?
Quando usare un test double?
• Valore: MAI !
• Entità: A volte
• Servizio: Sempre
Test Object Under Test
query messageresponse
Outgoing Query Message
Non facciamo asserzioni sul messaggio di query tra Object Under Test e collaboratore
Programmiamo il test double con una risposta fissa (stub)
Collaborator test double
@Test public void listManyBooks() { BookRepository bookRepository = context.mock(BookRepository.class); ListBooksUseCase = new ListBooksUseCase(bookRepository);
context.checking(new Expectations() {{ allowing(bookRepository).findAll(); will(returnValue(asList(new Book(), new Book(), new Book()))) }});
List<Book> books = useCase.listBooks();
assertThat(books.count(), is(3)); }
Esempio
Test Object Under Test
Outgoing Command Message
Inviamo un messaggio dal test al oggetto sotto test
Dobbiamo verificare che il comando in uscita sia inviato
Collaboratorcommand message
Oggetto sotto test invia un comando al collaboratore
Test Object Under Test
command message
Outgoing Command MessageCollaborator
MOCK
Impostiamo una expectation sul fatto che il messaggio sia inviato
@Test public void productFound() { ProductRepository productRepository = context.mock(ProductRepository.class); Display display = context.mock(Display.class); PointOfSale pos = new PointOfSale(productRepository, display);
context.checking(new Expectations() {{ allowing(productRepository).find(“__A_BARCODE__”); will(returnValue(new Product(“__PRODUCT_PRICE__”)));
oneOf(display).show(“__PRODUCT_PRICE__”); }});
pos.onBarcode(“__A_BARCODE__”); }
Esempio
Test Object Under Test
Message sent to self (private)
Non facciamo nessuna verifica sul messaggio privato
Perché usare i mock (test double)?
• Se ben usati generano enorme pressione sul design
• Vanno usati come strumento di design
• NON vanno usati al solo scopo di isolare i test
In brevissimo
• Curate i vostri test come curate il codice di produzione
• Fate code-review anche dei test!
• Un buon test oggi può salvarvi da un sacco di lavoro domani
Thank you!
Domande?