Tester son code Java avec Groovy et Spock


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 :

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.

test  groovy  spock 

Voir également

comments powered by Disqus