Când am început primul nostru proiect, am căutat un tool ușor de folosit care ne-ar fi putut ajuta să scriem teste lizibile și concise. Ne-am hotărât să folosim Spock, pentru că în combinație cu Groovy ne dă exact ce avem nevoie: teste lizibile de tip BDD pe care le putem scrie și schimba rapid. Spock poate fi folosit și în combinație cu Java, dar are sinergie foarte bună cu Groovy.
Toate exemplele din articol sunt scrise în Groovy, dar e suficientă puțină experiență cu Java pentru a le înțelege. Deoarece exemplele de teste din proiectul nostru sunt prea complicate pentru o introducere în Spock, am ales exemple de cod dintr-un kata pe care l-am făcut pentru a exersa TDD.
E ușor să adaugi Spock într-un proiect Gradle. Acesta este fișierul meu build.gradle:
apply plugin: 'groovy'
repositories {
mavenCentral()
}
dependencies {
testCompile(
'junit:junit:4.12',
'org.codehaus.groovy:'+
'groovy-all:2.4.4',
'org.spockframework:
'spock-core:1.0-groovy-2.4',
)
testRuntime(
// for spock reports
'com.athaydes:'
+'spock-reports:1.2.7'
)
}
Se scrie primul test care pică:
import spock.lang.Specification
class SeeIfItWorksSpec extends
Specification {
def "it should fail"() {
expect:
false
}
}
Outputul este:
Condition not satisfied:
false
Apoi, se înlocuiește false cu true. Dacă outputul este Tests PASSED
, setupul este complet.
Numele testelor în Spock sunt sub formă de Stringuri. Acest lucru permite scrierea de fraze întregi în nume care ajută la lizibilitatea testelor și permite înțelegerea lor doar din citirea numelui.
def "transforms 1 into roman
numeral I and writes the result
into a file"() {
}
Prin extinderea clasei de bază, Specification, se pot accesa în teste șase tipuri de blocuri
(given, when, then, expect, where, cleanup
). Cu ajutorul lor, se face o separare clară între precondiții, acțiunea care se testează și răspuns.
Exemplu:
Am ales ca exemplu transformarea unui număr arab într-un număr roman.
class NumeralTransformerSpec extends Specification {
def numeralTransformer
def setup() {
numeralTransformer =
new NumeralTransformer()
}
def "transforms arabic numeral 1 into roman
numeral I"() {
given:
def arabicNumeral = 1
when:
def result = numeralTransformer
.transform(arabicNumeral)
then:
result == "I"
}
}
Exemplul de mai sus se citește după cum urmează: dat fiind un număr arab, când este apelată metoda "transform" cu numărul respectiv ca parametru, atunci rezultatul așteptat este "I".
În metoda de setup se pune codul care rulează înainte de fiecare metodă de test. În exemplul de mai sus, obiectul testat este inițializat acolo pentru a evita duplicarea în alte metode de test.
Keywordul def din Groovy folosește la declararea unei variabile sau metode fără a-i specifica tipul. Este similar cu a o declara ca Object.
În blockul given se scrie setupul ințial pentru feature-ul testat. Codul poate conține inițializarea obiectului testat, mockuri și stuburi, declarări de variabile, etc. .
Aceste blocuri sunt mereu împreună. În ele se specifică ce anume se testează și se verifică răspunsul. În timp ce blocul given este opțional, aceste două blocuri sunt prezente tot timpul, în afară de cazul în care este folosit blocul expect.
Codul din blocul then poate verifica trei lucruri: starea rezultatului, dacă a fost aruncată vreo excepție sau dacă au fost interacțiuni cu alte clase sau metode.
Codul care verifică starea unui obiect în Spock este similar cu asserturile din Junit, doar că aici se pot folosi expresii simple de tip boolean.
Să presupunem că NumeralTransformer din exemplul anterior are o metodă care mapează numărul arab cu reprezentarea lui romană și returnează mapul respectiv.
def "builds the numeral mapping for 1"() {
when:
def result = numeralTransformer.buildNumeralMap(1)
then:
result == [1: "I"]
}
Ce se întâmplă dacă schimbăm expresia verificată?
def "builds the numeral mapping for 1""() {
when:
def result = numeralTransformer.buildNumeralMap(1)
then:
result == [1: "II"]
}
Outputul devine:
Failure: builds the numeral mapping(com.coolconf.unitTestsWithSpock.RomanTransformerSpec)
Condition not satisfied:
result == [1: "II"]
| |
[1:I] false
Se poate verifica dacă a fost aruncată o excepție în blocul then prin apelarea metodei thrown cu parametrul tipul presupusei excepții.
În exemplul următor, se verifică aruncarea unei excepții de tip IllegalArgumentException
când numărul dat ca parametru este -1.
def "throws IllegalArgumentException if
number is -1"() {
when:
numeralTransformer.transform(-1)
then:
thrown(IllegalArgumentException)
}
Verificarea interacțiunilor înseamnă verificarea felului în care obiectul testat interacționează cu alte obiecte. Acest lucru este realizat folosind obiecte de tip Mock, asigurate de clasa MockingApi din Spock.
În această secțiune voi da doar un exemplu, voi detalia comportamentul obiectelor de tip Mock în secțiunea Mock-uri și Stub-uri.
class NumeralTransformerSpec extends Specification {
def numeralTransformer
def setup() {
numeralTransformer = new NumeralTransformer()
numeralTransformer.fileWriterService =
Mock(FileWriterService)
}
def "transforms 1 into roman numeral I and writes
the result into a file"() {
given:
def arabicNumeral = 1
def file = new File("/somepath")
when:
numeralTransformer
.writeTheRomanTransformationToFile(file,
arabicNumeral)
then:
1 * numeralTransformer
.fileWriterService.writesNumberToFile(file,
"I")
}
}
În exemplul de mai sus, obiectul FileWriterService
de tip Mock este creat în secțiunea de setup și interacțiunile sunt verificate în blocul then.
Exemplul se citește în felul următor: dat fiind un număr arab cu valoarea "1" și un fișier, când metoda
writeTheRomanTransformationToFile
este apelată cu fișierul si numărul ca parametri, atunci metoda
writesNumberToFile()
este apelată o singură dată cu fișierul și Stringul "I" ca parametri.
Blocul expect se poate folosi când are mai mult sens plasarea în aceeași expresie a acțiunii și a rezultatului ei. Acest bloc folosește doar la verificarea unei stări, și mai poate conține doar declarări de variabile.
Unul din exemplele de mai sus se poate rescrie:
def "transforms 1 into roman value I"() {
expect:
numeralTransformer.transform(1) == "I"
}
Acest bloc este util când testul este data driven, adică se dorește testarea aceleiași metode cu inputuri diferite. Cu ajutorul blocului where, se evită duplicarea.
def "transforms arabic numeral into roman numeral"() {
when:
def result = numeralTransformer.
transform(arabicNumeral)
then:
result == expectedResult
where:
arabicNumeral || expectedResult
1 || "I"
2 || "II"
3 || "III"
}
În acest exemplu, blocul where conține un tabel de date. Antetul tabelului reprezintă variabilele și restul rândurilor sunt valorile lor. Fiecare rând reprezintă un caz de test și pentru fiecare din rânduri testul se va executa o dată, cu metoda de setup la începutul fiecărei interații și metoda de cleanup la sfârșit ( dacă metodele sunt prezente). Outputul unui test poate fi separat prin două pipeline-uri.
@Unroll
def "transform arabic numeral #arabicNumeral into
roman numeral #expectedResult"() {
when:
def result = numeralTransformer
.transform(arabicNumeral)
then:
result == expectedResult
where:
arabicNumeral || expectedResult
1 || "I"
2 || "II"
3 || "III"
}
Scopul adnotării Unroll este de a face ca iterațiile să fie raportate independent. Acest lucru este util în special când unele din ele pică și ai nevoie să știi care din ele. În combinație cu placeholderele (variabilele din numele testului care încep cu "#"), se generează un raport lizibil:
--Output from transform arabic numeral 1 into roman value I--| Running 1 unit test... 2 of 2
--Output from transform arabic numeral 2 into roman value II--| Running 1 unit test... 3 of 3
--Output from transform arabic numeral 3 into roman value III--| Completed 3 unit tests, 0 failed in 0m 0s
Tabelele de date sunt doar syntactic sugar pentru pipe-urile de date. Exemplul de mai sus poate fi rescris în felul următor:
@Unroll
def "transform arabic numeral #arabicNumeral into
roman numeral #expectedResult"() {
when:
def result = numeralTransformer
.transform(arabicNumeral)
then:
result == expectedResult
where:
arabicNumeral << [1, 2, 3]
expectedResult << ["I", "II", "III"]
}
Mockingul în Spock este permisiv, adică orice apel de metodă al obiectului mockuit care nu este relevant testului și nu este verificată interacțiunea cu el este permis și este dat un răspuns default pentru el (null, false sau zero).
Obiectele de tip Mock nu au comportament și nu trebuie confundate cu stuburile.
Interacțiuni
Să revedem examplul cu fileWriterService
:
then:
1 * numeralTransformer.fileWriterService
.writesNumberToFile(file, "I")
În verificarea acțiunii sunt specificate următoarele lucruri:
cardinalitatea ( 1 *) : de câte ori ne așteptăm ca obiectul testat să interacționeze cu metodele colaboratorilor săi;
fileWriterService
): obiectul mockului;writesNumberToFile
) : metoda al cărei apel este așteptat;Cel mai des declar interacțiunile în blocul then, dar nu este obligatoriu. Interacțiunile pot fi declarate înainte de blocul when, adică pot fi plasate în metoda de setup sau blocul given. De asemenea, pot fi declarate în momentul în care se creează mockurile sau după.
Ce se întâmplă când o interacțiune nu este respectată? Se poate întâmpla din două motive principale: fie numărul interacțiunilor este specificat greșit, sau parametrii metodei sunt greșiți. Voi refolosi exemplul de mai sus dar voi schimba cardinalitatea interacțiunii.
def "transforms 1 into roman numeral I and writes the result into a file"() {
given:
def arabicNumeral = 1
def file = new File("/somepath")
when:
numeralTransformer
.writeTheRomanTransformationToFile(file,
arabicNumeral)
then:
0 * numeralTransformer
.fileWriterService.writesNumberToFile(file,
"I")
}
Eroarea afișată este:
Too many invocations for:
0 * numeralTransformer.fileWriterService.writesNumberToFile(file, "I") (1 invocation)
Matching invocations (ordered by last occurrence):
1 * <FileWriterService>.writesNumberToFile(/somepath, 'I') <-- this triggered the error
Eroarea este destul de intuitivă: există o invocare a metodei writeTheRomanTransformationToFile în codul de producție și testul așteaptă zero invocări.
La polul opus, dacă se verifcă:
2 * numeralTransformer.fileWriterService
.writesNumberToFile(file, "I")
se va afișa următoarea eroare:
Too few invocations for:
2 * numeralTransformer.fileWriterService
.writesNumberToFile(file, "I") (1 invocation)
Aceasta înseamnă că testul aștepta două invocări ale service callului, dar în codul de producție există doar una.
Stubbingul definește comportamentul unui colaborator (returnarea unei valori sau a unei secvențe de valori, aruncarea unei excepții).
Exemplu:
class RomanNumeralsArithmeticsSpec extends
Specification {
def romanNumeralsArithmeticsService
def setup() {
romanNumeralsArithmeticsService =
new RomanNumeralsArithmeticsService()
romanNumeralsArithmeticsService
.numeralTransformerService = Mock(NumeralTransformerService)
}
def "check I + II = III"() {
given:
def first = "I"
def second = "II"
and:
romanNumeralsArithmeticsService
.numeralTransformerService.fromRoman(first) >> 1
romanNumeralsArithmeticsService
.numeralTransformerService.fromRoman(second) >> 2
romanNumeralsArithmeticsService
.numeralTransformerService.transform(3) >> "III"
when:
def sum = romanNumeralsArithmeticsService
.sum(first, second)
then:
sum == "III"
}
}
În acest exemplu este verificată suma a două numere romane. Numeral Tranformer Service este stubbed și metodele lui sunt "forțate" să întoarcă anumite valori care vor fi folosite în codul de producție.
Exemplul se citește în felul următor: fiind date două numere romane, și metodele colaboratorului RomanNumeralsArithmeticsService întorc valorile 1, 2 și "III", atunci suma lor este "III".
Stubbingul și verificarea interacțiunilor se pot face în același timp. De asemenea, metodele aceluiași obiect de tip Mock se pot grupa:
def "check I + II = III"() {
given:
def first = "I"
def second = "II"
romanNumeralsArithmeticsService
.numeralTransformerService = Mock(NumeralTransformerService) {
1 * fromRoman(first) >> 1
1 * fromRoman(second) >> 2
1 * transform(3) >> "III"
}
when:
def sum = romanNumeralsArithmeticsService
.sum(first, second)
then:
sum == "III"
}
}
Spock permite descrieri de tip text în blocuri:
@Unroll
def "transforms arabic numeral #arabicNumeral
into roman numeral #expectedResult"() {
when: "Calls transform method on arabic numeral"
def result = numeralTransformer
.transform(arabicNumeral)
then: "Expected result is '#expectedResult'"
result == expectedResult
where: "Arabic numeral is #arabicNumeral"
arabicNumeral || expectedResult
1 || "I"
2 || "II"
3 || "III"
}
Datorită pluginului Spock-Reports-Plugin, pot fi generate rapoarte lizibile care permit utilizarea testelor ca documentație:
Report for NumeralTransformerSpec
Summary:
Created on Fri May 05 22:10:34 EEST 2017 by biancal
+-------------------+----------+--------+--------------+-----------+
| Executed features | Failures | Errors | Success rate | Time |
+-------------------+----------+--------+--------------+-----------+
| 3 | 0 | 0 | 100.0% | 0.074 sec |
+-------------------+----------+--------+--------------+-----------+
Features:
transforms arabic numeral 1 into roman numeral I
transforms arabic numeral 2 into roman numeral II
transforms arabic numeral 3 into roman numeral III
+------------------------------------------+------------------------------------------+
| transforms arabic numeral 1 into roman | |
| numeral I | |
+------------------------------------------+------------------------------------------+
| When: | Calls transform method on arabic numeral |
+------------------------------------------+------------------------------------------+
| Then: | Expected result is 'I' |
+------------------------------------------+------------------------------------------+
| Where: | Arabic numeral is 1 |
+------------------------------------------+------------------------------------------+
| transforms arabic numeral 2 into roman | |
| numeral II | |
+------------------------------------------+------------------------------------------+
| When: | Calls transform method on arabic numeral |
+------------------------------------------+------------------------------------------+
| Then: | Expected result is 'II' |
+------------------------------------------+------------------------------------------+
| Where: | Arabic numeral is 2 |
+------------------------------------------+------------------------------------------+
| transforms arabic numeral 3 into roman | |
| numeral III | |
+------------------------------------------+------------------------------------------+
| When: | Calls transform method on arabic numeral |
+------------------------------------------+------------------------------------------+
| Then: | Expected result is 'III' |
+------------------------------------------+------------------------------------------+
| Where: | Arabic numeral is 3 |
+------------------------------------------+------------------------------------------+
Rapoartele generate sunt în special utile pentru feature-uri higher-level, care pot fi citite și de către alți membri ai echipei care nu sunt programatori (de exemplu, de către product owneri)
Spock este recomandat programatorilor care lucrează cu Groovy sau Java și care au nevoie de un framework de testare care are o curbă de învățare rapidă, generează rapoarte lizibile de tip BDD și are o librărie de mocking permisivă care nu aglomerează codul cu verificări de interacțiuni irelevante.