Spock est un framework de tests écrit sous la forme de spécifications pour des applications Java ou Groovy. Il offre une alternative tout en un au trio JUnit (TestNG), Mockito et AssertJ.
Mise en place
Maven
Il s’agit de dépendances à ajouter et d’un peu de configuration supplémentaire :
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy</artifactId>
<version>2.4.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.0-groovy-2.4</version>
<scope>test</scope>
</dependency>
<!-- (Optionnel) Nécessaire pour mocker des classes concrètes -->
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib-nodep</artifactId>
<version>3.1</version>
<scope>test</scope>
</dependency>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>1.5</version>
<executions>
<execution>
<goals>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<includes>
<include>**/*Spec.java</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
Et voilà on peut écrire des spécifications de type *Spec.groovy
dans src/test/groovy
.
Gradle
apply plugin: 'java'
apply plugin: 'groovy'
repositories {
mavenCentral()
}
dependencies {
testCompile group: 'org.codehaus.groovy', name: 'groovy', version: '2.4.3'
testCompile group: 'org.spockframework', name:'spock-core', version: '1.0-groovy-2.4'
testRuntime group: 'cglib', name: 'cglib-nodep', version: '3.1'
}
Et voilà on peut écrire des spécifications de type *Spec.groovy
dans src/test/groovy
.
Spécification
Squelette
Une spécification est une classe qui étends spock.lang.Specification
. Elle est composée de champs, de méthodes de configuration (fixture
) et enfin de méthodes pour décrire des fonctionnalités (feature
).
class MyFirstSpec extends Specification {
ClassUnderSpecification underTest = new ClassUnderSpecification()
@Shared
VeryExpensiveResource resource = new VeryExpensiveResource()
def setup() {}
def cleanup() {}
def setupSpec() {}
def cleanupSpec() {}
def "pushing an element on the stack"() {}
}
Les champs ne sont pas partagés entre les méthodes de feature
.
Pour partager un objet (par exemple un peu long à initialiser), il faut annoter le champ avec @Shared
.
Equivalence fixture
avec les annotations JUnit :
Méthode | JUnit |
---|---|
setup | @Before |
cleanup | @After |
setupSpec | @BeforeClass |
cleanupSpec | @AfterClass |
On peut de toute façon aussi utiliser les annotations JUnit directement.
Une fonctionnalité est équivalente à une méthode annotée avec @Test
dans JUnit.
Blocs
Une fonctionnalité est structurée autour de blocs. Il en existe six : setup
, when
, then
, expect
, cleanup
, et where
. Il fait au minimum un bloc pour qu’une méthode soit considérée comme une fonctionnalité (et donc exécutée).
Chaque bloc joue un rôle dans l’expression d’un test et sa bonne utilisation est validée par la librairie quand c’est possible (ordre de déclaration, autorisation du contenu, etc).
setup, given
Il permet l’initialisation du test, given
est un alias de setup
.
when, then
when
contient le stimulus du test tandis que then
permet d’écrire les assertions.
when
peut contenir n’importe quel type d’instructions contrairement au bloc then
qui lui est limité aux : conditions, vérification des exceptions, interactions (avec les mocks), et définition de variables.
expect
Permet d’exprimer les bloc when
et then
en un seul. Dans la documentation officielle il est conseillé d’utiliser la forme when
/ then
pour tester les méthodes avec des effets de bord et de garder la forme expect
pour les fonctions pures.
cleanup
Le bloc pour nettoyer les ressources utilisées lors d’un test. Son contenu est tout le temps exécuté même si une exception est levée dans une autre partie de la fonctionnalité.
where
Toujours défini en dernier, on peut y écrire la génération des données pour exécuter des tests paramétrés.
Assertions (conditions)
Les assertions avec Spock peuvent être remplacées par de simples expressions booléennes.
given:
def list = []
when:
list.add(1)
then:
!list.isEmpty
list.size() == 10
Si un test échoue, on a un affichage complet de l’état du test.
Condition not satisfied:
list.size() == 10
| | |
[1] 1 false
C’est plutôt pratique et lisible dans la majorité des cas. Quand ce ne l’est pas, on peut très bien continuer d’utiliser sa librairie d’assertions préférées.
Vérification des exceptions
Il existe divers solutions pour tester des exceptions dans les tests unitaires.
Avec Spock on peut utiliser les conditions thrown()
, notThrown
et noExceptionThrown()
.
def "should not throw exception"() {
when:
[1].get(0)
then:
noExceptionThrown()
}
def "should not throw IndexOutOfBoundsException"() {
when:
[1].get(0)
then:
notThrown(IndexOutOfBoundsException)
}
def "should throw exception"() {
when:
[1].get(1)
then:
thrown(IndexOutOfBoundsException)
}
def "should throw exception with message"() {
when:
[1].get(1)
then:
IndexOutOfBoundsException e = thrown()
e.message == 'Index: 1, Size: 1'
}
Mock
Spock utilise sa propre librairie de mock qui se sert des possibilités du langage Groovy pour définir les interactions.
Comme avec Mockito les mocks sont permissifs par défaut (une interaction non définie ne fera pas échouer un test), par contre les interactions non définies ne retournent que les valeurs par défaut (0
, null
ou false
).
Prenons l’exemple d’une classe écoutant l’état d’un build qui chercherait à envoyer des notifications à la fin de celui-ci.
public class BuildListener {
private final Notifier notification;
public BuildListener(Notifier notification) {
if (notification.checkCompatibility(System.getProperty("os.name"))) {
throw new IllegalArgumentException("Notifier not compatible with current os");
}
this.notification = notification;
}
public void onSuccess(BuildResult result) {
if (notification.isReady()) {
notification.send("Build success !");
}
}
}
Création
La définition d’un mock passe par l’utilisation de l’api Mock()
.
def notifier = Mock(Notifier)
ou
Notifier notifier = Mock()
interaction
La spécification du comportement du mock se fait directement dans le bloc then
.
class MockSpec extends Specification {
Notifier notifier = Mock()
BuildListener listener = new BuildListener(notifier)
def "should send notification when build ends and notifier is ready"() {
when:
listener.onSuccess(new BuildResult())
then:
1 * notifier.isReady() >> true
1 * notifier.send("Build success !")
}
}
Cela peut paraitre peu naturel au début de définir les interactions dans le bloc then
. Mais c’est parce que l’api ne se limite pas à la définition du comportement, on a en plus la vérification que le mock a bien été appelé (l’équivalent d’un Mockito.verify()
) !
Si l’interaction avec le mock ne s’est pas passée comme prévu on a un message d’erreur :
Too few invocations for:
1 * notifier.send("Build success !") (0 invocations)
Unmatched invocations (ordered by similarity):
1 * notifier.isReady()
On peut aussi ne pas vérifier les interactions (dans ce cas l’usage d’un Stub()
est préférable). Le test précédent peut s’écrire :
def "should send notification when build ends"() {
given:
notifier.isReady() >> true
when:
listener.onSuccess(new BuildResult())
then:
1 * notifier.send("Build success !")
}
On peut déclarer le nombre d’appel attendu :
1 * notifier.send("Build success !") // un appel
0 * notifier.send("Build success !") // pas d'appel
(2..5) * notifier.send("Build success !") // entre deux et cinq appels
(1.._) * notifier.send("Build success !") // au moins un appel
(_..2) * notifier.send("Build success !") // deux appels au plus
La vérification du/des arguments d’une méthode est lui aussi assez riche :
1 * notifier.send("a") // argument vaut "a"
1 * notifier.send(!"a") // argument différent de "a"
1 * notifier.send(_) // n'importe quel argument (dont null)
1 * notifier.send(!null) // argument non null
1 * notifier.send(_ as String) // argument de type String
1 * notifier.send({ it.startsWith 'a' }) // argument commençant par "a"
L’utilisation d’une closure dans le matching d’argument (comme le dernier exemple ci-dessus) peut permettre dans certains cas limites de reproduire le comportement d’un ArgumentCaptor
de Mockito :
def "should send notification when build ends"() {
def captured
given:
notifier.isReady() >> true
when:
listener.onSuccess(new BuildResult())
then:
1 * notifier.send(_) >> { arguments -> captured = arguments[0] }
captured == "Build success !"
}
Pour retrouver l’équivalent du @InjectMocks
de Mockito
il existe une extension Spock (Spock Subjects-Collaborators Extension) qui permet d’injecter automatiquement des mocks dans l’objet que l’on souhaite tester.
Les mocks doivent être annotés avec @Collaborator
et le sujet du test avec @Subject
.
class MockWithSubjectSpec extends Specification {
@Collaborator
Notifier notifier = Mock()
@Subject
BuildListener listener
def "should send notification when build succeed"() {
when:
listener.onSuccess(new BuildResult())
then:
1 * notifier.isReady() >> true
1 * notifier.send("Build success !")
}
}
Par défaut Spock ne vérifie pas l’ordre dans lequel les interactions sont invoquées. Si vraiment on en a besoin on peut définir les interactions dans des blocs then
différents. Dans ce cas les mocks devront être appelés dans l’ordre dans lequel ils ont été déclarés.
def "should send notification when build ends"() {
when:
listener.onSuccess(new BuildResult())
then:
1 * notifier.isReady() >> true
then:
1 * notifier.send("Build success !")
}
Définition retour des méthodes
Pour retourner une valeur dans la définition d’une interaction on utilise l’opérateur >>
.
notifier.isReady() >> true
L’opérateur >>>
permet de définir des valeurs de retours différentes pour des appels successifs.
notifier.isReady() >>> [true, false, false, true]
On peut également retourner une valeur après un calcul dépendant d’un argument.
notifier.checkCompatibility(_) >> { String os -> !os.startsWith("win") }
Et enfin pour lever une exception :
notifier.isReady() >> { throw new NullPointerException() }
Stub, Spy, Partial mock
Spock gère aussi tous ces autres type de mocks.
Un stub peut-être créé avec l’api Stub()
. La grande différence avec un Mock()
est qu’on ne peut pas vérifié le comportement du stub (d’ailleurs une erreur se produira si l’on essaie). L’initialisation par défaut est aussi plus “intelligente” que celle des Mock()
.
Si une interaction n’est pas définie et qu’un appel a lieu, la valeur retournée tentera de s’adapter au type de retour défini (une collection vide sera retournée par exemple).
Les autres s’utilisent avec l’api Spy()
. Tout est décrit dans la documentation
Tests paramétrés
Parfois on a besoin d’exécuter plusieurs fois le même test avec des données différentes.
Avec JUnit on a le choix entre :
- les
Parameterized tests
, - les
theories
, JUnitParams
,junit-dataprovider
Généralement j’utilise junit-dataprovider
qui mime le comportement des DataProvider
de TestNG mais avec Spock on va avoir accès à des solutions bien plus élégantes.
Data Tables
La déclaration des données se fait dans un bloc expect
.
def "should return maximum of two numbers"() {
expect:
Math.max(a, b) == c
where:
a | b || c
1 | 2 || 2
2 | 1 || 2
1 | 0 || 1
0 | 0 || 0
}
Le test sera exécuté pour chaque ligne du tableau, chaque colonne définissant une variable du test.
Le double séparateur de colonne est optionnel, c’est du sucre syntaxique pour séparer les valeurs d’entrée et le résultat attendu.
Par défaut le test est vu comme un seul, ce qui fait que ce n’est pas toujours facile de retrouver quelle ligne du tableau entraine un échec.
Pour améliorer la lisibilité du rapport de test, on peut utiliser l’annotation @Unroll
. Elle permet de créer un rapport par ligne du tableau et d’injecter les valeurs du test dans le nom de la méthode.
On peut donc remplacer le nom du test précédent en utilisant #variable
comme placeholder :
@Unroll
def "maximum of #a and #b should be #c"()
et le rapport de l’exécution des tests ressemblera à :
maximum of 1 and 2 should be 2
maximum of 2 and 1 should be 2
maximum of 1 and 0 should be 1
maximum of 0 and 0 should be 0
Data Pipes
Les data pipes sont la seconde forme de data provider disponible.
def "even numbers"() {
expect:
a % 2 == 0
where:
a << [0, 2, 4, 6]
}
Le test sera exécuté quatre fois avec les valeurs 0, 2, 4, 6. Tout objet connu comme étant iterable par Groovy peut être utilisé dans un data pipe.
Test génératifs
Spock-Genesis est une collection de générateurs pour écrire des tests génératifs.
@Unroll
def "should reverse #string"() {
when:
String reversed = string.reverse()
then: 'It maintains length'
reversed.size() == string.size()
and: 'It is not destructive'
reversed.reverse() == string
where:
string << Gen.these('', 'aba', 'aa').then(Gen.string).take(10000)
}
Tout se passe à partir de spock.genesis.Gen
, il y a des générateurs pour les String
, int
, long
, double
, Date
, etc.
La documentation est succincte mais il y a des exemples d’utilisation ici.
Pour améliorer l’analyse statique dans l’IDE il est possible d’ajouter en paramètre de la méthode de test les types des variables nécessaires.
Dans l’exemple précédent, IntelliJ Idea se plaint de ne pas trouver les méthodes reverse
et size
pour la variable string
(reconnu comme étant de type Object
). Si on déclare un paramètre pour la méthode, plus de problème.
@Unroll
def 'should reverse #string'(String string)
Extensions
Spock possède un mécanisme permettant d’enrichir son cycle de vie. Les extensions disponibles par défaut sont listées dans la documentation.
Parmi les plus sympa, il y a :
@IgnoreIf
permet d’ignorer un test quand une condition n’est pas respectée,@Timeout
pour faire échouer un test s’il ne s’est pas exécuté en un certain temps,@AutoCleanup
appelle automatiquement une méthode sur un champ annoté pour libérer des resources à la fin de son cycle de vie,@RestoreSystemProperties
rétabli les valeurs des propriétés systèmes quand elles ont été modifiées au cours d’un test.
Et il en existe d’autres développés par la communauté :
- Spock Reports pour générer un rapport HTML des spécifications,
@TempDirectory
crée des dossiers temporaires,- Spock Subjects-Collaborators Extension pour injecter des mocks dans la classe que l’on teste,
- Et bien d’autres…
Et finalement Spock est aussi compatible avec les @Rule
de JUnit !!
Documentation
Spock met en avant le fait que les tests sont des spécifications qui peuvent-être documentées comme telles.
Les blocs sont en fait des labels, on peut donc y associer une description :
when: "a build notifies a listener when ending"
listener.onSuccess(new BuildResult())
then: "the listener asks a notifier if it is ready"
1 * notifier.isReady() >> true
then: "the listener asks the notifier to send a notification"
1 * notifier.send("Build success !")
Un bloc peut être découpé en plusieurs étapes avec le label and:
given: "something"
// code
and: "something else"
// code
when:
then:
Il existe aussi des extensions à fin documentaire :
@Title
,@Narrative
définissent une spécification,@Issue
indique qu’une fonctionnalité ou une spécification sont liées à un ticket,@Subject
pour identifier la classe que l’on spécifie.
Conclusion
Les tests écrits avec Spock sont globalement plus concis et lisibles. Les noms de méthodes sont de vrais phrases, les spécifications sont structurées autour de blocs bien définis (given, when, then) et le langage Groovy permet d’écrire moins de code.
Spock vient avec son propre framework de mocks et ses assertions pensés pour l’outil. Ça évitera de se retrouver avec des tests écrits avec des librairies différentes et de devoir retenir plusieurs API. Rien n’empêche d’ailleurs d’utiliser Mockito (ou un autre) par exemple à la place des mocks livrés avec Spock.
Spock est compatible avec de nombreux outils puisqu’il est accompagné d’un runner JUnit. Pas de soucis pour exécuter les spécifications depuis son IDE ou son outil de build préféré. Cela le rend aussi compatible avec les Rule
JUnit !
Groovy a une courbe d’apprentissage rapide pour un développeur Java. Le langage en est très proche et on peut se permettre d’en apprendre les spécificités et les concepts au fur et à mesure. Utiliser Groovy pour ses tests a encore plus de sens quand on maintient du code Java dans des versions un peu ancienne.
Même si les exemples se sont concentrés sur du code Java, Spock fonctionne aussi très bien pour tester du Groovy 😇
Encore ?
Le guide d’utilisation de Spock mérite une lecture attentive, ses mécanismes y sont très bien expliqués.
La présentation Smarter testing Java code with Spock Framework propose tout une série d’astuces pour encore améliorer son utilisation de Spock !
Et pour finir les auteurs de l’outil maintiennent un projet d’exemple dans lequel on peut aller piocher de bonnes idées.