17 maart 2023
Als programmeur wil je mooie, leesbare en onderhoudbare code schrijven waar je trots op bent. Maar hoe zit dat eigenlijk met onze test code? Geldt daar niet hetzelfde voor? Dit artikel laat zien waarom Groovy en het Spock framework uitermate geschikt zijn om mooie en leesbare unit tests te schrijven. Dan kun je daar ook nog eens trots op zijn.
Er zijn talloze frameworks, patterns en blogs die je proberen te helpen bij het leesbaar en onderhoudbaar maken van je gewone code. Je test code is echter totaal anders van structuur en heeft daarom zijn eigen frameworks en patterns. Maar is Java wel de meest geschikte taal voor unit tests?
Neem bijvoorbeeld de volgende unit test in Java:
private final List<DayOfWeek> weekDays = List.of(
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY
);
private final Map<List<DayOfWeek>, OpenTimes> regularHours = Map.of(
weekDays, OpenTimes.parse("08:00-17:00"),
List.of(SATURDAY), OpenTimes.parse("08:00-14:00")
);
private final Map<LocalDate, OpenTimes> holidayHours = Map.of(
LocalDate.parse("2021-12-24"), OpenTimes.parse("08:00-16:00"),
LocalDate.parse("2021-12-25"), OpenTimes.CLOSED_ALL_DAY
);
private final OpeningHoursProvider underTest =
new OpeningHoursProvider(regularHours, holidayHours);
@Test
void shouldProvideCorrectOpeningHoursOn24DecemberAt10AM() {
// given
var dec24 = LocalDate.of(2021, 12, 24);
var dec24At10AM = dec24.atTime(10, 0);
// when
var openingHours = underTest.forDateTime(dec24At10AM);
// then
var expectedTimes = OpenTimes.parse("08:00-16:00");
assertEquals(new OpeningHours(dec24, expectedTimes, Status.OPEN),
openingHours);
}
Dit is een test van een class die openingstijden geeft, uitgaande van de reguliere openingstijden en afwijkende openingstijden tijdens de feestdagen. De test controleert of op 24 december de winkel inderdaad een uur eerder gesloten is.
Groovy en het Spock framework kunnen deze unit test op een aantal punten leesbaarder en beter te onderhouden maken.
Methode naam van de test
Een unit test begint met een naam. Het is belangrijk dat de naam duidelijk aangeeft waar de unit test voor dient. In het voorbeeld wordt de ‘should’ naamgeving gebruikt: de naam geeft dan aan wat het gewenste gedrag is dat getest wordt, dus wat de code ‘zou’ moeten doen.
Met de ‘should’ naamgeving gebruik je vaak een volledige zin om aan te geven wat de test doet. Voorbeelden zijn ‘shouldSaveToDatabase’
of ‘shouldThrowExceptionIfDatabaseIsDown’
.
Een voordeel van Groovy is dat je echte strings kunt gebruiken in de naamgeving van je methodes. Je kunt dus ook spaties gebruiken, wat de leesbaarheid ten goede komt. Zo kun je de testmethode de volgende, beter leesbare, naam geven:
-
“should provide correct opening hours on 24 December at 10AM”
Specificaties
In het Spock framework wordt een unit test een specificatie genoemd. De reden hiervoor is dat een test eigenlijk veel meer kan zijn dan alleen een controle of je code goed werkt. Een goede test kan ook een beschrijving zijn van de features van je code. Een specificatie dus.
Behavior-driven
Een unit test bestaat vaak uit de volgende stappen:
1. Creëer een initiële situatie [given]
2. Roep de code aan die getest wordt [when]
3. Controleer of de ontstane situatie als verwacht is [then]
Het komt de leesbaarheid van een test ten goed als deze stappen ook zichtbaar zijn. Je kunt dit zelf aangeven met behulp van single-line comments tussen je code, zoals in het Java voorbeeld is gedaan. Het Spock framework bevat echter standaard labels die je kunt gebruiken in je test. Bij elk van de standaard label kun je ook een beschrijving geven van de stap. Hiermee wordt het nog duidelijker wat de stappen zijn die je neemt en wat de specificatie is van je feature.
def "should provide correct opening hours on 24 December at 10AM"() {
given: “december 24 2021 at 10:00”
def dec24 = LocalDate.of(2021, 12, 24)
def dec24At10AM = dec24.atTime(LocalTime.parse("10:00"))
when: “the opening times are requested”
def openingHours = underTest.forDateTime(dec24At10AM)
then: “the opening times are 08:00 until 16:00”
def expectedTimes = OpenTimes.parse("08:00-16:00")
and: “the shop should be open”
openingHours == new OpeningHours(dec24, expectedTimes, OPEN)
}
Een bijkomende feature is dat je in het ‘then:’ label condities kunt gebruiken, die worden gebruikt als asserties voor je test. Je kunt bijvoorbeeld de volgende conditie gebruiken:
openingHours == new OpeningHours(dec24, expectedTimes, OPEN)
Er wordt dan geverifieerd of de geretourneerde openingstijden (variable openingHours) gelijk is aan wat je verwacht (het gecreëerde OpeningHours object). Het is dus equivalent aan de assertEquals uit de Java unit test:
assertEquals(new OpeningHours(dec24, expectedTimes, Status.OPEN),
openingHours);
Data-driven testing met data tables
In het gegeven voorbeeld hebben we 1 test gedaan voor de openingstijden van 24 december, maar er zijn waarschijnlijk veel meer edge cases waar je een test voor wilt schrijven, zoals andere feestdagen, weekdagen en allerlei edge cases rond de opening- en sluitingstijden. Je wilt wel dat je test code leesbaar blijft en dat je geen code hoeft te dupliceren voor deze nieuwe tests.
Het Spock framework heeft hiervoor data tables tot zijn beschikking. Hiermee kun je dezelfde test code gebruiken voor verschillende input en output. Deze definieer je onder een where: label. Dit zou er als volgt uit kunnen zien:
def "should provide correct opening hours on #dateStr at #timeStr"() {
given: “a moment in time”
def date = LocalDate.parse(dateStr)
def dateTime = date.atTime(LocalTime.parse(timeStr))
when: “the opening times are requested”
def openingHours = underTest.forDateTime(dateTime)
then: “the opening times should be correct”
def expectedTimes = OpenTimes.parse(expectedTimesStr)
and: “the expected status should be correct”
openingHours == new OpeningHours(date, expectedTimes, expectedStatus)
where: “we use the following test data”
dateStr | timeStr || expectedStatus | expectedTimesStr
"2021-12-07" | "16:59" || OPEN | "08:00-17:00"
"2021-12-07" | "17:00" || CLOSED | "08:00-17:00"
"2021-12-11" | "19:15" || CLOSED | "08:00-14:00"
"2021-12-24" | "14:00" || OPEN | "08:00-16:00"
"2021-12-25" | "14:00" || CLOSED | null
}
Dezelfde test wordt met deze code 5x gedraaid. Iedere keer met een andere datum en tijd (kolommen dateStr en timeStr) en iedere keer met andere verwachte uitkomsten (expectedStatus en expectedTimesStr). De kolomnamen kun je in je test gebruiken als variabelen. Kolommen met input kun je van de kolommen met verwachte uitkomsten scheiden door middel van een dubbel rechtopstaand streepje.
Ook kun je de kolomnamen gebruiken in de naam van je test. Dit is in het voorbeeld gedaan met #dateStr en #timeStr. Bij het draaien van de test worden de gebruikte waarden gesubstitueerd in de testnaam. Bij een falende test kun je dan precies zien welke variabelen er voor de test gebruikt zijn:
[ERROR] should provide correct opening hours on 2021-12-25 at 14:00 Time elapsed: 0.318 s <<< FAILURE!
In een IDE zoals IntelliJ kun je ook goed zien welke variabelen er worden gebruikt voor de verschillende tests, zodat je snel kunt zien welke test er faalt:
Syntax voor collections in Groovy
In een unit test creëer je vaak veel collections. Bijvoorbeeld bij het opzetten van je initiële situatie of het creëren van de expected output. Java kan nog steeds erg omslachtig zijn als het om het creëren van collecties gaat, hoewel er gelukkig al veel verbeterd is.
Groovy heeft veel handige syntax verbeteringen voor collecties, die je unit test een stuk minder verbose en dus leesbaarder kunnen maken. Zo kun je met rechte haken Lists en Maps initialiseren en kun je ook simpel ranges definiëren, die ook als Lists te gebruiken zijn.
Het gedeelte van de Java unit test waarin de test instantie (met de naam underTest) wordt geïnitialiseerd zou er in Groovy als volgt uit kunnen zien:
def regularHours = [
(MONDAY..FRIDAY): OpenTimes.parse("08:00-17:00"),aven
[SATURDAY]: OpenTimes.parse("08:00-14:00"),
]
def specialHours = [
(LocalDate.parse("2021-12-24")): OpenTimes.parse("08:00-16:00"),
(LocalDate.parse("2021-12-25")): OpenTimes.CLOSED_ALL_DAY,
]
def underTest = new OpeningHoursProvider(regularHours, specialHours)
Het Spock framework gebruiken in je bestaande project
Het Spock framework is een uitgebreid test framework waarin je alles kunt vinden wat je nu ook al in Java frameworks als JUnit kunt vinden. Daarnaast is ook mocking onderdeel van het framework. Je hoeft daar dus niet een additionele library voor te gebruiken.
Het Spock framework is makkelijk in te zetten in je bestaande Java of Kotlin project. Je hebt twee additionele dependencies nodig: org.codehaus.groovy:groovy-all en org.spockframework:spock-core. Gebruik je maven, dan is de GMavenPlus plugin ook erg handig.
Mocht je geïnteresseerd zijn in alle features van het Spock framework of van Groovy, kijk dan op deze websites.
Conclusie
Het is begrijpelijk dat je niet te veel verschillende programmeertalen wilt gebruiken in je organisatie. Er zijn genoeg programmeurs die Java kennen, maar zijn er ook genoeg Groovy programmeurs te vinden?
Toch zou het misschien de moeite waard zijn om eens een blik te werpen op het Spock framework. Groovy is een taal die heel erg op Java lijkt en iedere Java programmeur zou met weinig moeite unit tests in Groovy moeten kunnen schrijven. Groovy draait op het Java platform, dus alle bekende Java libraries zijn te gebruiken en je kunt het zonder moeite gebruiken in je bestaande ontwikkelomgeving.
Voor het schrijven van leesbare en goed te onderhouden unit tests lijkt Groovy diverse voordelen te hebben ten opzichte van Java. Zo kun je eindelijk mooie unit tests schrijven!
Dit artikel is ook verschenen in Java Magazine editie 2 #2022. Wil je Java Magazine ontvangen? Word dan NLJUG lid.