Aller au contenu
  1. Articles/

Tester son code Java avec Groovy et Spock

·12 mins·

Article publié il y a 11 ans : les outils et les commandes décrits ont pu changer depuis.

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éthodeJUnit
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 :

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é :

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.

Partager

Articles connexes


Commentaires archivés (5)

Discussion d'origine, conservée telle quelle. Elle est close.

Guillaume Renaudin

Hey !

Je suis fan de Spock depuis que j'ai commencé à développer en Grails. Tout l'avantage de l'approche BDD sans rentrer dans le mythe du test fonctionnel écrit par une MOA ! Pas bête de se libérer pour l'utiliser sur du Java (surtout pour les tests paramétrés qui sont une vrai calamités en JUnit...). J'ai pu mettre un mix Groovy / Java sur un projet, next step, introduire Spock pour la partie testing :)

Merci pour l'article très complet (j'ai souvent du mal à retrouver mes sources Web pour la partie syntaxe des expects), hop en favori !

A +

Jean-Christophe GAY

Oui j'aime beaucoup aussi !

J'essaie d'en mettre un peu partout aussi :) Le seul truc qui me chagrine un peu quand je l'utilise dans un mix Java/Groovy c'est que certains refactoring automatiques ne sont pas propagés correctement (en tout cas avec IntelliJ Idea). De mémoire les transformations du type Constructeur -> Factory Method ne se passent pas très bien. Ça fait un peu plus de code à retoucher à la main :(
Tu n'as pas trop de problèmes de ce genre quand tu utilises les deux langages en même temps ?

Sinon avec JUnit j'utilise toujours https://github.com/TNG/juni... pour écrire des tests paramétrés, c'est bien mieux que ce que propose JUnit.

Guillaume Renaudin

Pour le refactoring, je suis sur Eclipse donc avec le plugin Groovy, il n'y a presqu'aucun refacto qui passe correctement, c'est effectivement ce qui peut-être le plus gros frein...

Merci pour le lien pour JUnit, j'étais justement en train de me dire que si je devais continuer à écrire du test en éco-système Java, j'allais soit passer à TNG ou devoir trouver quelque chose de plus élégant !

J'ai pas regardé dans le détail mais son runner ne pose pas de problème avec d'autres (genre le runner Spring pour les TI ?)

Hassania

ageelink.com
Oui j'aime beaucoup !

J'apprécie l'approche BDD du MOA , car je crois cela intéressant d'utiliser du JAVA et un mix Groovy sur un même projet.

Je vous remercie pour votre explication dans cet article.

Avez-vous eu des difficultés à utiliser deux langages ? Qu'en pensez-vous ?