Tests unitaires, examen détaillé des tests paramétrés. Partie I

Bonne journée, chers collègues.



J'ai décidé de partager ma vision des tests unitaires paramétrés, comment nous le faisons et comment vous ne le faites probablement pas (mais que vous voulez faire).



Je voudrais écrire une belle phrase sur ce qui doit être testé correctement, et les tests sont importants, mais beaucoup de matériel a déjà été dit et écrit avant moi, je vais simplement essayer de résumer et de mettre en évidence ce que, à mon avis, les gens utilisent rarement (comprennent), pour lequel fondamentalement emménage.



L'objectif principal de l'article est de montrer comment vous pouvez (et devriez) arrêter d'encombrer votre test unitaire avec du code pour créer des objets, et comment créer de manière déclarative des données de test si mock (any ()) ne suffit pas, et il existe de nombreuses situations de ce type.



Créons un projet maven, ajoutons junit5, junit-jupiter-params et mokito



Pour que ce ne soit pas complètement ennuyeux, nous commencerons à écrire tout de suite après le test, comme les apologistes de TDD l'aiment, nous avons besoin d'un service que nous testerons de manière déclarative, tout le fera, que ce soit HabrService.



Créons un test HabrServiceTest. Ajoutez un lien vers le HabrService dans le champ de classe de test:



public class HabrServiceTest {

    private HabrService habrService;

    @Test
    void handleTest(){

    }
}


créez un service via ide (en appuyant légèrement sur le raccourci), ajoutez l'annotation @InjectMocks dans le champ.



Commençons directement par le test: le HabrService dans notre petite application aura une seule méthode handle () qui prendra un seul argument HabrItem, et maintenant notre test ressemble à ceci:



public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @Test
    void handleTest(){
        HabrItem item = new HabrItem();
        habrService.handle(item);
    }
}


Ajoutons une méthode handle () à HabrService, qui retournera l'id d'un nouveau message sur Habré après qu'il est modéré et enregistré dans la base de données, et prend le type HabrItem, nous allons également créer notre HabrItem, et maintenant le test se compile, mais plante.



Le fait est que nous avons ajouté une vérification de la valeur de retour attendue.



public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp(){
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem item = new HabrItem();
        Long actual = habrService.handle(item);

        assertEquals(1L, actual);
    }
}


Aussi, je veux m'assurer que lors de l'appel à la méthode handle (), ReviewService et PersistanceService ont été appelés, ils ont été appelés strictement l'un après l'autre, ils ont fonctionné exactement 1 fois, et aucune autre méthode n'a été appelée plus. En d'autres termes, comme ceci:



public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp(){
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem item = new HabrItem();
        
        Long actual = habrService.handle(item);
        
        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(item);
        inOrder.verify(persistenceService).makePersist(item);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }
}


Ajoutez reviewService et persistenceService aux champs de la classe de classe, créez-les, ajoutez-leur respectivement les méthodes makeRewiew () et makePersist (). Maintenant, tout se compile, mais bien sûr le test est rouge.



Dans le contexte de cet article, les implémentations ReviewService et PersistanceService ne sont pas si importantes, l'implémentation HabrService est importante, rendons-la un peu plus intéressante qu'elle ne l'est maintenant:



public class HabrService {

    private final ReviewService reviewService;

    private final PersistenceService persistenceService;

    public HabrService(final ReviewService reviewService, final PersistenceService persistenceService) {
        this.reviewService = reviewService;
        this.persistenceService = persistenceService;
    }

    public Long handle(final HabrItem item) {
        HabrItem reviewedItem = reviewService.makeRewiew(item);
        Long persistedItemId = persistenceService.makePersist(reviewedItem);

        return persistedItemId;
    }
}


et en utilisant les constructions when (). then (), nous verrouillons le comportement des composants auxiliaires, en conséquence, notre test est devenu comme ceci et maintenant il est vert:



public class HabrServiceTest {

    @Mock
    private ReviewService reviewService;

    @Mock
    private PersistenceService persistenceService;

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp() {
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem source = new HabrItem();
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }
}


Une maquette pour démontrer la puissance des tests paramétrés est prête.



Ajoutez un champ avec le type de hub, hubType, à notre modèle de requête pour le service HabrItem, créez une énumération HubType et incluez-y plusieurs types:



public enum HubType {
    JAVA, C, PYTHON
}


et pour le modèle HabrItem, ajoutez un getter et un setter au champ HubType créé.



Supposons qu'un commutateur soit caché dans les profondeurs de notre HabrService, qui, selon le type de hub, fait quelque chose d'inconnu avec la requête, et dans le test nous voulons tester chacun des cas d'inconnu, l'implémentation naïve de la méthode ressemblerait à ceci:



        
    @Test
    void handleTest() {
        HabrItem reviewedItem = mock(HabrItem.class);
        HabrItem source = new HabrItem();
        source.setHubType(HubType.JAVA);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }


Vous pouvez le rendre un peu plus joli et plus pratique en paramétrant le test et en ajoutant une valeur aléatoire de notre enum en tant que paramètre, en conséquence, la déclaration de test ressemblera à ceci:



@ParameterizedTest
    @EnumSource(HubType.class)
    void handleTest(final HubType type) 


magnifiquement, déclarativement, et toutes les valeurs de notre enum seront certainement utilisées lors de la prochaine série de tests, l'annotation a des paramètres, nous pouvons ajouter des stratégies pour inclure, exclure.



Mais peut-être que je ne vous ai pas convaincu que les tests paramétrés sont bons. Ajouter à

la requête HabrItem originale, un nouveau champ editCount, dans lequel le nombre de milliers de fois que les utilisateurs de Habr éditent leur article sera écrit avant de poster, de sorte que vous l'aimiez au moins un peu, et supposons que quelque part dans les profondeurs de HabrService il y a une sorte de logique qui fait l'inconnu quelque chose, en fonction de combien l'auteur a essayé, que se passe-t-il si je ne veux pas écrire 5 ou 55 tests pour toutes les options editCount possibles, mais que je veux tester de manière déclarative, et quelque part au même endroit, indiquer immédiatement toutes les valeurs que je voudrais vérifier ... Il n'y a rien de plus simple, et en utilisant l'API des tests paramétrés, nous obtenons quelque chose comme ceci dans la déclaration de méthode:



    @ParameterizedTest
    @ValueSource(ints = {0, 5, 14, 23})
    void handleTest(final int type) 


Il y a un problème, nous voulons collecter deux valeurs dans les paramètres de la méthode de test à la fois de manière déclarative, vous pouvez utiliser une autre excellente méthode de tests paramétrés @CsvSource, parfaite pour tester des paramètres simples, avec une valeur de sortie simple (extrêmement pratique pour tester des classes utilitaires), mais quoi si l'objet devient beaucoup plus compliqué? Disons qu'il aura environ 10 champs, et pas seulement des primitives et des types java.



L'annotation @MethodSource vient à la rescousse, notre méthode de test est devenue sensiblement plus courte et il n'y a plus de setters dedans, et la source de la requête entrante est transmise à la méthode de test en tant que paramètre:



    
    @ParameterizedTest
    @MethodSource("generateSource")
    void handleTest(final HabrItem source) {
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }


l'annotation @MethodSource a la chaîne generateSource, qu'est-ce que c'est? c'est le nom de la méthode qui va collecter le modèle requis pour nous, sa déclaration ressemblera à ceci:



   private static Stream<Arguments> generateSource() {
        HabrItem habrItem = new HabrItem();
        habrItem.setHubType(HubType.JAVA);
        habrItem.setEditCount(999L);
        
        return nextStream(() -> habrItem);
    }


Pour plus de commodité, j'ai déplacé la formation du flux d'arguments nextStream dans une classe de test d'utilitaire distincte:



public class CommonTestUtil {
    private static final Random RANDOM = new Random();

    public static <T> Stream<Arguments> nextStream(final Supplier<T> supplier) {
        return Stream.generate(() -> Arguments.of(supplier.get())).limit(nextIntBetween(1, 10));
    }

    public static int nextIntBetween(final int min, final int max) {
        return RANDOM.nextInt(max - min + 1) + min;
    }
}


Désormais, lors du démarrage du test, le modèle de requête HabrItem sera ajouté de manière déclarative au paramètre de la méthode de test, et le test sera exécuté autant de fois que le nombre d'arguments générés par notre utilitaire de test, dans notre cas de 1 à 10.



Cela peut être particulièrement pratique si le modèle est dans le flux d'arguments n'est pas collecté par hardcode, comme dans notre exemple, mais à l'aide de randomiseurs (vive les tests flottants, mais s'ils le sont, il y a un problème).



À mon avis, tout est déjà super, le test ne décrit désormais que le comportement de nos talons, et les résultats attendus.



Mais voici la malchance, un nouveau champ, du texte, un tableau de chaînes est ajouté au modèle HabrItem, qui peut ou peut ne pas être très grand, peu importe, l'essentiel est que nous ne voulons pas encombrer nos tests, nous n'avons pas besoin de données aléatoires, nous voulons un modèle strictement défini, avec des données spécifiques, en les collectant dans un test ou ailleurs - nous ne voulons pas. Ce serait cool si vous pouviez prendre le corps d'une requête json de n'importe où, par exemple d'un facteur, créer un fichier fictif basé sur celui-ci et former un modèle de manière déclarative dans le test, en spécifiant uniquement le chemin du fichier json avec les données.



Excellent. Nous utilisons l'annotation @JsonSource, qui prendra un paramètre de chemin, avec un chemin de fichier relatif et une classe cible. Zut! Il n'y a pas une telle annotation dans les tests paramétrés, mais j'aimerais bien.



Écrivons-le nous-mêmes.



ArgumentsProvider est responsable du traitement de toutes les annotations fournies avec @ParametrizedTest dans junit, nous écrirons notre propre JsonArgumentProvider:



public class JsonArgumentProvider implements ArgumentsProvider, AnnotationConsumer<JsonSource> {

    private String path;

    private MockDataProvider dataProvider;

    private Class<?> clazz;

    @Override
    public void accept(final JsonSource jsonSource) {
        this.path = jsonSource.path();
        this.dataProvider = new MockDataProvider(new ObjectMapper());
        this.clazz = jsonSource.clazz();
    }

    @Override
    public Stream<Arguments> provideArguments(final ExtensionContext context) {
        return nextSingleStream(() -> dataProvider.parseDataObject(path, clazz));
    }
}


MockDataProvider est une classe pour analyser les fichiers json simulés, son implémentation est extrêmement simple:




public class MockDataProvider {

    private static final String PATH_PREFIX = "json/";

    private final ObjectMapper objectMapper;

     public <T> T parseDataObject(final String name, final Class<T> clazz) {
        return objectMapper.readValue(new ClassPathResource(PATH_PREFIX + name).getInputStream(), clazz);
    }

}


Le fournisseur fictif est prêt, le fournisseur d'arguments pour notre annotation aussi, il reste à ajouter l'annotation elle-même:




/**
 * Source-   ,
 *     json-
 */
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(JsonArgumentProvider.class)
public @interface JsonSource {

    /**
     *   json-,   classpath:/json/
     *
     * @return     
     */
    String path() default "";

    /**
     *  ,        
     *
     * @return  
     */
    Class<?> clazz();
}


Hourra. Notre annotation est prête à l'emploi, la méthode de test est maintenant:



  
    @ParameterizedTest
    @JsonSource(path = MOCK_FILE_PATH, clazz = HabrItem.class)
    void handleTest(final HabrItem source) {
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }


dans mock json, nous pouvons produire autant et très rapidement un tas d'objets dont nous avons besoin, et nulle part à partir de maintenant il n'y a un code qui détourne de l'essence du test, pour la formation de données de test, bien sûr, vous pouvez souvent faire avec des simulations, mais pas toujours.



En résumé, je voudrais dire ce qui suit: souvent nous travaillons comme nous le faisions, pendant des années, sans penser que certaines choses peuvent être faites magnifiquement et simplement, souvent en utilisant l'API standard des bibliothèques que nous utilisons depuis des années, mais ne connaissons pas toutes leurs capacités.



PS L'article n'est pas une tentative de connaissance des concepts TDD, j'ai voulu ajouter des données de test à la campagne de storytelling pour la rendre un peu plus claire et plus intéressante.



All Articles