Am cunoscut oameni care nu stăpâneau uneltele cu care lucrau, dar erau programatori buni, dar nu am cunoscut programatori care să stăpanească uneltele cu care lucrau şi să rămână mediocri. - Kent Beck
Acest articol este o continuare a articolului Unit tests with Spock scris de Bianca Leuca. Conține un set de probleme reale legate de unit testing şi soluțiile lor posibile.
Setupul poate fi făcut după cum a fost descris în articolul precendent; pentru utilizatorii de maven ar arata conform setărilor de mai jos (pom.xml):
<dependencies>
<!-- Groovy: A powerful, dynamic language for the
JVM -->
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>2.4.4</version>
</dependency>
<!-- Spock is a testing and specification framework
for Java and Groovy applications -->
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.0-groovy-2.4</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Byte Code Generation Library is high level API to generate and transform Java byte code. It is used by AOP, testing, data access frameworks to generate dynamic proxy objects and intercept field access. -->
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib-nodep</artifactId>
<version>3.1</version>
<scope>test</scope>
</dependency>
<!-- Objenesis is a small Java library that serves one purpose: To instantiate a new object of a particular class -->
<dependency>
<groupId>org.objenesis</groupId>
<artifactId>objenesis</artifactId>
<version>2.1</version>
<scope>test</scope>
</dependency>
<!-- Creates test (or, in Spock terms, Specifications) reports -->
<dependency>
<groupId>com.athaydes</groupId>
<artifactId>spock-reports</artifactId>
<version>1.2.12</version>
<scope>test</scope>
<!-- this avoids affecting your version of Groovy/Spock -->
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
Se poate observa utilizarea dependențelor: * Groovy-All care oferă suportul lingvistic Groovy pentru JVM. * Spock Core- implementarea Spock. * CGLib folosit pentru generarea de bytecode (Spock il folosește pentru a genera clase mock, interfețele sunt mockuite folosind JDK dynamic proxies). * Objenesis care ajută la crearea de obiecte (folosit de Spock pentru a rescrie constructorii).
Test automation este un proces automat de a rula şi compara rezultatul cu un rezultat așteptat, folosind software special conceput în acest sens. xUnit este folosit pentru unit testing, în special în extreme programming şi agile software development. Pentru mai multe detalii se poate citi despre Test Driven Development şi Behavior Driven Development.
Rolul unit testelor este de a testa, după cum sugerează şi numele, bucăți mici de cod. Unit testele la fel ca şi bazele de date, trebuie să respecte principiile ACID:
Atomic - nu pot fi sparte în mai multe bucăți.
Consistența - executate de mai multe ori returnează acelaşi rezultat.
Izolare - ordinea în care rulează, nu contează, nu se vor înfluența reciproc (există cazuri în care ordinea contează şi este definită în cod).
De asemenea, unit testele trebuie să fie rapide şi scrise la un moment anume în timpul procesului de development (TDD - înaintea codului, DDT - în timpul scrierii, POUT/TAD - după cod).
Testele funcționale sunt folosite pentru a testa funcționalitatea unui cod - de exemplu, un algoritm sau un flow într-o aplicație. Ele sunt un tip de black-box testing, ceea ce înseamnă că programatorul/testerul nu trebuie să ştie cum funcționează intern, doar ce ar trebui să facă. Toate componentele de care depind, va trebui să fie mockuite încât testele să ruleze în izolare, iar rezultatul lor să fie durabil şi consistent.
Testele de caracterizare sunt un alt tip de teste, iar rolul lor este de a păstra comportamentul curent al codului testat (considerat ca fiind bun), încât să poată fi modificat într-un mod controlat. Acest termen a fost inventat de Michael Feathers şi prezentat în cartea sa "Working Effectively with Legacy Code".
Testele de integrare au rolul de a verifica modul în care funcționează mai multe componente împreună, de aceea nu vor folosi mocking/stubbing pentru componentele integrate (de exemplu, baza de date). Deoarece se folosesc mai multe componente, rezultatul este imprevizibil, testele pot eşua, dacă baza de date şi/sau nişte servicii web de care depinde, nu sunt accesibile. Asemenea scenarii sunt de aşteptat, până la urmă dorim să aflăm dacă mediul în care facem deployment este sau nu într-o stare bună.
Spock foloseşte GWT (dat fiind, când facem şi atunci se întamplă), pe când folosind JUnit se prefera AAA pregăteşte, acționează şi verifică. Indiferent de modul de aranjare al codului de testare, trebuie ținut cont de faptul că testele trebuie să poată fi citite ușor, atât de oameni tehnici cât şi de oameni de business.
Dacă un test conține prea multă logică, va fi greu de citit. În acest caz este de preferat să se împartă în teste mai mici şi implicit mai ușor de citit/înţeles.
Testele care acum rulează cu succes şi peste câteva momente eșuează fără o recompilare de cod, fac mai mult rău decât bine, deoarece programatorul nu va ști dacă într-adevăr este o problemă sau nu. În acest caz, va trebui analizată cauza problemei şi fixat testul.
Codul folosit la testare trebuie tratat la fel ca şi codul pentru producție. Ar trebui să încapă pe ecranul monitorului. În cazul în care există cod de inițializare sau care este folosit la asserturi, e de preferat să fie mutat într-o metodă separată.
Verificarea metodelor interne (protected/private/package private) va cauza teste să eșueze, o dată cu refactorizarea lor. Costul de mentenanță va crește fără nici un beneficiu vizibil; până la urmă, rolul unit testelor este validarea a ceea ce face o bucată de cod, nu neapărat cum face acest lucru.
Testele trebuie să ruleze pe orice calculator, nu doar pe unul singur, de obicei al persoanei care a creat testul.
Este de preferat verificarea rezultatului şi nu a pașilor, deoarece odată cu schimbarea pașilor pentru a obține rezultatul dorit (de exemplu, prin îmbunătățirea algoritmului), va trebui ca testele să fie modificate (costuri suplimentare).
Scrierea de teste bune ajută la diminuarea costurile, deoarece defectele sunt găsite mai repede, când costul de reparare este mai mic. Studiul condus de dl. Nagappan şi colegii săi la Microsoft a arătat că echipele care folosesc TDD produc cod cu 60% până la 90% mai bun ca densitate a bugurilor decât echipele non-TDD. De asemenea, au descoperit că echipele care folosesc TDD au nevoie de 15% până la 35% mai mult timp pentru a termina aceleași taskuri. De asemenea, au descoperit că un code coverage mai mare nu înseamnă implicit mai puține defecte; contează mai mult să fie testate zonele mai complexe din aplicație decât să se obtină un code coverage mare. Code coverage-ul ar trebui să fie un efect secundar al scrierii de teste şi nu un țel în sine. Un code coverage mare şi teste prost scrise nu vor ajuta la găsirea de defecte şi ar putea fi dăunatoare şi o risipă de resurse.
Diferite procese de development, incluzând TDD, pot să ducă la rezultate diferite în funcție de organizația, echipa sau proiectele la care sunt folosite.
Acestea sunt trei din cele mai importante tehnici folosite la unit testing. Le vom explica în detaliu în rândurile ce urmează.
Stubbing se referă la crearea de obiecte care respectă o interfață a unui obiect real; în cazul stubbingului , programatorul nu este interesat de interacțiunea cu obiectul, ci doar de datele pe care le furnizează şi care trebuie să fie aceleași şi în aceeași ordine furnizate. Cel mai bun exemplu este un obiect al unei clase cu o singura metodă, care returnează totdeauna null
.
Pentru a genera un obiect stub pentru interfața de mai jos:
interface Calculator {
BigDecimal calculate()
}
in Spock, se poate scrie următorul cod:
def calculator = Stub(Calculator)
calculator.calculate() >> null
Un obiect mocked este o replică a unui obiect real/implementare a unei interfețe şi este folosit nu numai pentru a obține valori (stub), dar şi pentru a verifica interacțiunea cu acest obiect. Se verifică dacă unele metode ale acestui obiect sunt apelate de un număr fix de ori, şi de asemenea, este configurat să returneze anumite valori, în funcție de context (de exemplu, input sau alte variabile). Mocking este acțiunea de a crea obiecte mocked, al cărui rol este de a injecta comportament/date cunoscute în codul testat. Mocking poate fi văzut ca următorul nivel al stubbingului.
În exemplul de mai jos se poate observa că metoda calculateVAT este chemată de trei ori, returnează valoarea 100_000 prima dată, 200_000 la al doilea apel şi excepție la al treilea.
given: "A calculator"
def calculator = Mock(Calculator)
when: "calculate taxes"
def actual = calculateTaxes(calculator)
then: "equals expected"
actual == 778_000
and: "VAT calculator is called once and returns what is expected"
3 * calculator.calculateVAT(_) >>
new BigDecimal(100_000) >> new BigDecimal(200_000)
>> { throw new Exception("no more values") }
De retinut faptul că împărțirea în două a verificării şi a setarii valorii care se returnează, nu va funcționa ca şi în cazul Mockito (when, verify).
Folosirea spy-urilor ascunde de obicei probleme de design; se dovedesc a fi folositori în cazul legacy code (teste de caracterizare) unde problemele de design sunt de cele mai multe ori o realitate. Spock spies funcționează prin rescrierea metodelor obiectelor reale.
given: "A real currency calculator with construtor
arguments"
def exchangeProvider =
Mock(CurrencyExchangeProvider)
def item = Mock(Item)
...
def calculator = Spy(RealPriceCalculator,
constructorArgs: [ item, exchangeProvider ])
when: "calculator is used"
def actual = calculatePrice(Currency.EUR)
then: "equals expected"
actual == expected
and: "calculator is called once and returns what is
expected"
1 * calculator.calculateItemPrice() >> new
BigDecimal(myValue)
where: "test matrix"
currency | expected
Currency.EUR | 12.8
Currency.USD | 102.2
Prin folosirea Objenesis este posibilă rescrierea constructorilor, ca în exemplul de mai jos:
def calculator = Spy(RealPriceCalculator,
useObjenesis: true) {
it.exchangeProvider =
Mock(CurrencyExchangeProvider)
it.item = Mock(Item)
}
Codul de mai sus exemplifică rescrierea unui constructor şi setarea atributelor exchangeProvider şi item la valorile dorite (it este echivalentul pentru this). Acest lucru nu se poate realiza dacă atributele nu sunt accesibile pentru codul de test (package private sau protected şi nu final). O altă tehnică folosește rescrierea getterelor acestor atribute pe care le rescrie în codul de testare; aceste gettere trebuie să fie folosite peste tot în codul de producție în locul accesării directe a valorii atributelor. Aceste metode sunt alternative pentru crearea unui constructor, care este folosit doar pentru testare şi care setează acele atribute din afară (de obicei, mockuri).
Câteodată este nevoie să validăm faptul că o secvență de cod este chemată într-o anumită ordine; unul dintre cele mai bune exemple este executarea unui cod ca parte dintr-o transacție. În codul de mai jos vom testa execute around pattern pentru transacții:
def "Test runWithinTransaction - happy flow"() {
given: "A JDBC Connection"
def dbConnection = Mock(Connection)
def logger = Mock(Logger)
def runnable = Mock(Runnable)
when: "call runWithinTransaction"
DBUtil.runWithinTransaction(dbConnection,
logger, runnable)
then: "auto commit set to false"
1 * dbConnection.setAutoCommit(false)
then: "auto commit set to false"
1 * runnable.run()
then: "make db changes"
1 * dbConnection.commit()
}
Cele trei statementuri din blocurile then
ne asigură că instrucțiunile rulează una după alta, orice modificare de tip schimbarea codulul de producție încât commitul să fie executat înainte de run va eșua testul.
Folosind exemplul de mai sus, vom verifica dacă instrucțiunea rollback este chemată în cazul unei excepții:
def "Test runWithinTransaction - exception thrown"() {
given: "A JDBC Connection"
def dbConnection = Mock(Connection)
def logger = Mock(Logger)
def exch = Mock(Exception)
def runnable = Mock(Runnable)
when: "call runWithinTransaction"
DBUtil.runWithinTransaction(dbConnection, logger, runnable)
then: "auto commit set to false"
1 * dbConnection.setAutoCommit(false)
then: "auto commit set to false"
1 * runnable.run() >> { throw exch }
then: "make db changes"
1 * dbConnection.rollback()
and: "log exception"
1 * logger.error("Failed to execute", exch)
}
Folosirea de and
după then
înseamnă că nu contează ordinea în care sunt executate instrucțiunile.
În limbajul Groovy se folosește []
pentru liste şi [:]
pentru dicționare. Testele pot fi citite mai ușor prin folosirea lor, după cum se poate observa mai jos:
def "Test collections"() {
...
when: "get a list"
def actual = underTest.getCurrencies()
then: "equals EUR, USD and RON"
actual == [ Currency.EUR, Currency.USD,
Currency.RON ]
when: "get of country default currency"
def actualCountries =
underTest.getCurrenciesForCountries()
then: "equals EUR --> Germany,
USD --> USA and RON --> Romania"
actualCountries == [ Germany:Currency.EUR,
USA:Currency.USD, Romania:Currency.RON ]
}
Dacă ordinea nu contează, se poate face o verificare a conținutului ca în exemplul următor:
def "Test collections"() {
...
then: "equals expected - EUR, USD and RON"
def expected = [ Currency.EUR,
Currency.USD, Currency.RON ]
def actualSet = actual.toSet()
actualSet.size() == expected.size() &&
expected.each { item -> assert actualSet
.contains(item) }
Codul cu efecte secundare este mai greu de înţeles, de depanat, de întreținut şi testat; de aceea, efectele secundare trebuie evitate pe cât posibil. Testarea lor poate fi realizată prin câteva moduri. Testând dacă metodele corecte sunt chemate, nu este un mod bun de a testa un astfel de cod, deoarece poate deveni complex şi nu ne protejează împotriva apelurilor către alte metode cu efecte secundare care ar putea reseta ceea ce a fost făcut înainte.
O altă metodă este de a verifica rezultatul final, ca şi în codul de mai jos unde se verifică faptul ca John Snow are cu 5kg de aur mai mult şi banca 5kg în minus:
def person = new Person("John Snow")
def underTest = new IronBankOfBraavos(
new Vault.VaultBuilder()
withGold(new GoldValuable(100_000_000, Unit
.Kilogram)).build())
...
when: "John Snow's withdraws 5 kg of gold"
underTest.withdraw(person, new GoldValuable(5,
Unit.Kilogram))
then: "John Snow has 5 kg of gold with him"
person.getCurrentInventary().hasValuable(
new GoldValuable(5, Unit.Kilogram))
and: "The Iron bank of Braavos has 5 kg of gold less"
underTest.getValuablesInventory()
.hasValuable(new GoldValuable(99_999_995,
Unit.Kilogram))
Captura argumentelor pasate se poate realiza în Spock, ca în exemplul de mai jos:
def tran = Mock(DBTransaction)
underTest.runWithinTransaction(
_ as TransactionRunnable) >> {
arguments ->
assert arguments[0] instanceof
TransactionRunnable
((TransactionRunnable) arguments[0]).execute(tran)
}
when: "Save the data to the db"
underTest.persist()
Codul de mai sus este o variantă a execute around pattern deja menționat; cheamă direct metoda de executat în interiorul metodei runWithinTransaction cu o tranzacție mock.
Testele de caracterizare pot fi generate rulând metodele care urmează a fi testate cu anumite valori şi verificând starea obiectelor. Această verificare poate fi generată folosind codul de mai jos pentru atribute (cu mici modificari şi pentru gettere); codul de verificare este printat la consolă:
def void printFields(T result, Class clazz) {
clazz.getDeclaredFields().each {
if (it.getModifiers() & Modifier.PUBLIC &&
!(it.getModifiers() & Modifier.STATIC)) {
if (it.getType().isPrimitive()) {
if (it.getType().getName().equals("double")) {
println "result." + it.getName() + " == "
+ it.get(result) + "d"
} else {
println "result." + it.getName() + " == "
+ it.get(result)
}
} else {
if (it.get(result) == null) {
println "result." + it.getName() + " == null"
} else {
println "result." + it.getName().toString()
+ ".toString() == \""
+ it.get(result).toString() + "\""
}
}
}
}
}
Codul de mai jos exemplifica în verificare că nu există interacțiune cu obiectul shouldNotInteractWith prin folosirea placeholderului _ (matching cu orice metodă):
0 * shouldNotInteractWith._
Generarea spy/mockurilor în mod recursiv a fost preluat din articolul DZone "Spock Return Nested Spies. Aceasta este o alternativă pentru refolosirea mockurilor cu valori predefinite (de exemplu, o clasă care extinde clasa de bază Spock Specification folosită pentru teste şi care conține metode gen createXXX()
). Sunt folositoare în cazul în care există multe atribute care trebuie setate la o valoare diferită de null
.
class ExtendedSpockSpecification extends Specification {
def addNestedMocksToPropertiesPaths(object
, String... propertiesPath) {
propertiesPath.each {
property ->
def lastObject = object
property.tokenize('.').inject object,
{ obj, prop ->
def foundProp = obj.metaClass
.properties.find { it.name == prop }
def mockedProp = Mock(foundProp.type)
lastObject."${prop}" >> mockedProp
lastObject = mockedProp
}
}
object
}
}
Metoda de mai sus poate fi folosită pentru a crea un mock utilizat la testarea distanței între birou şi acasă pentru o persoană:
def person = (Person)
addNestedMocksToPropertiesPaths(Mock(Person)
, "address.street", "job.company.address.street")
Exprimă clar ceea ce se testează, ce trebuie să se întample, în ce condiții şi care sunt rezultatele aşteptate
Este de preferat ca textul să exprime cât mai clar posibil ce se testează, în ce condiții şi care sunt rezultatele aşteptate deoarece, până la urmă, acest text va face parte din documentația testului iar clientul va trebui să fie capabil să-l citească şi să-l înțeleagă. De exemplu, în loc de ceva abstract ca :
def "Test getOfficeDistance"() {
given: ""
when: "call getOfficeDistance"
then: "expect to equal"
se poate scrie:
def "#1234 AC1: Test how far is the person from the office"() {
given: "A person with home address Hansastraße 10, 80686 München"
and: "Working in the office with address Werner-von-Siemens-Straße 1, 80333 München"
and: "A map"
when: "Check the distance between home address and office address"
then: "the person lives 5.7 km away from the
office"
Se poate observa folosirea notației #1234 AC1 unde 1234 este numărul user story-ului şi numărul acceptance criteria.
Un exemplu foarte bun în care se aplică este pentru verificarea rezultatelor true/false - separându-le este mult mai ușor de înțeles când o metodă trebuie să returneze true şi când fals.
where: "true only if condition is '1' and is not something but is something else and does not matter if it has something else"
condition| isSomething| isSomethingElse| hasSomethingElse| Expected
...
"0" | false | false | false | false
"0" | false | false | true | false
"0" | true | false | false | false
"1" | false | true | true | true
"0" | false | true | false | true
...
Folosind stringuri "Yes" pentru true şi "" pentru false se pot observa inclusiv valorile de input mai ușor:
where: "true only if condition is '1' and is not something but is something else and does not matter if it has something else"
condition| isSomething| isSomethingElse| hasSomethingElse| Expected
...
"0" | "" | "" | "" | ""
"0" | "" | "" | "YES"| ""
"0" | "YES" | "" | "" | ""
"1" | "" | "YES"| "YES"| "YES"
"0" | "" | "YES"| "" | "YES"...
Pentru testarea comparatorelor este de preferat folosirea textului în loc de numere positive/negative:
def static SECOND_BIGGER = 1
def static FIRST_BIGGER = -1
def static EQUAL = 0
@Unroll
def "Test compare"() {
when: "compare"
def actual = underTest.compare(first, first)
then: "equals expected"
actual == expected
where: "test matrix"
first | first | expected
null | null | EQUAL
"" | null | EQUAL
null | "" | EQUAL
"" | "" | EQUAL
"ABC" | null | FIRST_BIGGER
null | "ABC" | SECOND_BIGGER
"" | "ABC" | SECOND_BIGGER
"ABC" | "" | FIRST_BIGGER
Această regulă este preluată din Code Complete: Dacă valoarea complexității este (complexitatea cyclomatică este ușor de observat în blocul when): * > 0-5 - codul este ok * > 6-10 - gândește-te cum să simplifici * > 10+ - creează o nouă metodă şi apelează-o din prima.
Multe dintre conceptele şi caracteristicile Spock sunt similare cu cele din JUnit (folosind Mockito şi Hamcrest), după cum se poate observa în tabelul de mai jos:
Comparația între Spock şi doar JUnit poate fi citită pe site-ul documenties pentru Spock.
Până la urmă uneltele nu sunt răspunsul după cum spunea Uncle Bob, disciplina şi profesionalismul sunt. Verificarea sistematică a corectitudinii codului prin scrierea şi rularea de teste sunt dovadă de disciplină şi professionalism, iar Spock ajută la a obține acest lucru.
Mai multe informații vă stau la dispoziție pe site-ul Spock.