Optimisation des tests d’intégration printanière

Optimiser les tests d'intégration printaniers

1. introduction

Dans cet article, nous allons avoir une discussion holistique sur les tests d'intégration utilisant Spring et comment les optimiser.

Tout d'abord, nous discuterons brièvement de l'importance des tests d'intégration et de leur place dans les logiciels modernes en se concentrant sur l'écosystème Spring.

Plus tard, nous couvrirons plusieurs scénarios, en nous concentrant sur les applications Web.

Next, we’ll discuss some strategies to improve testing speed, en découvrant différentes approches qui pourraient influencer à la fois la façon dont nous façonnons nos tests et la façon dont nous façonnons l'application elle-même.

Avant de commencer, il est important de garder à l'esprit qu'il s'agit d'un article d'opinion basé sur l'expérience. Certaines de ces choses pourraient vous convenir, d'autres non.

Enfin, cet article utilise Kotlin pour les exemples de code afin de les garder aussi concis que possible, mais les concepts ne sont pas spécifiques à ce langage et les extraits de code devraient sembler significatifs pour les développeurs Java et Kotlin.

2. Tests d'intégration

Integration tests are a fundamental part of automated test suites. Bien qu'ils ne devraient pas être aussi nombreux que les tests unitaires si nous suivons unhealthy test pyramid. En s'appuyant sur des frameworks tels que Spring, nous avons besoin de nombreux tests d'intégration pour réduire certains risques de notre système.

The more we simplify our code by using Spring modules (data, security, social…), the bigger a need for integration tests.  Cela devient particulièrement vrai lorsque nous déplaçons des bits et des bobs de notre infrastructure dans les classes@Configuration.

Nous ne devons pas «tester le cadre», mais nous devons certainement vérifier que le cadre est configuré pour répondre à nos besoins.

Les tests d'intégration nous aident à renforcer la confiance, mais ils ont un prix:

  • C'est une vitesse d'exécution plus lente, ce qui signifie des constructions plus lentes

  • De plus, les tests d'intégration impliquent un champ d'application plus large, ce qui n'est pas idéal dans la plupart des cas.

Dans cet esprit, nous essaierons de trouver des solutions pour atténuer les problèmes mentionnés ci-dessus.

3. Test des applications Web

Spring propose quelques options pour tester les applications Web, et la plupart des développeurs Spring les connaissent bien:

  • MockMvc: se moque de l'API de servlet, utile pour les applications Web non réactives

  • TestRestTemplate: peut être utilisé en pointant vers notre application, utile pour les applications Web non réactives où les servlets simulés ne sont pas souhaitables

  • WebTestClient: est un outil de test pour les applications Web réactives, à la fois avec des demandes / réponses simulées ou sur un serveur réel

Comme nous avons déjà des articles sur ces sujets, nous ne passerons pas de temps à en parler.

N'hésitez pas à jeter un coup d'œil si vous souhaitez creuser plus profondément.

4. Optimiser le temps d'exécution

Les tests d'intégration sont excellents. Ils nous donnent un bon degré de confiance. En outre, s’ils sont mis en œuvre de manière appropriée, ils peuvent décrire l’intention de notre application de manière très claire, avec moins de bruit moqueur et de configuration.

Cependant, à mesure que notre application mûrit et que le développement s'accumule, le temps de création augmente inévitablement. Au fur et à mesure que le temps de construction augmente, il peut s'avérer impossible de continuer à exécuter tous les tests à chaque fois.

Ensuite, impact sur notre boucle de rétroaction et mise en place des meilleures pratiques de développement.

De plus, les tests d'intégration sont intrinsèquement coûteux. Démarrer une certaine persistance, envoyer des requêtes via (même si elles ne quittent jamaislocalhost) ou faire des E / S prend simplement du temps.

Il est primordial de garder un œil sur notre temps de construction, y compris l'exécution des tests. Et il y a quelques astuces que nous pouvons appliquer au printemps pour le garder bas.

Dans les sections suivantes, nous aborderons quelques points pour nous aider à optimiser notre temps de construction ainsi que certains pièges qui pourraient avoir un impact sur sa vitesse:

  • Utilisation judicieuse des profils - Impact des profils sur les performances

  • Reconsidérer les performances des spectacles moqueurs@MockBean – 

  • Refactoring@MockBean  - alternatives pour améliorer les performances

  • Réfléchir soigneusement à @DirtiesContext – est une annotation utile mais dangereuse et comment ne pas l'utiliser

  • Utilisation de tranches de test - un outil génial qui peut vous aider ou vous mettre en route

  • Utilisation de l'héritage de classe - un moyen d'organiser les tests de manière sécurisée

  • Gestion des états - Bonnes pratiques pour éviter les tests de flocons

  • Refactoring en tests unitaires - le meilleur moyen d'obtenir une construction solide et vivante

Commençons!

4.1. Utilisation judicieuse des profils

Profiles est un outil très intéressant. À savoir, des balises simples qui peuvent activer ou désactiver certaines zones de notre application. On pourrait mêmeimplement feature flags avec eux!

À mesure que nos profils s’enrichissent, il est tentant d’échanger de temps en temps nos tests d’intégration. Il existe des outils pratiques pour le faire, comme@ActiveProfiles. Cependant,every time we pull a test with a new profile, a new ApplicationContext gets created.

La création de contextes d’application peut être rapide avec une application de démarrage printanière vanille ne contenant rien. Ajoutez un ORM et quelques modules et le temps passera rapidement à plus de 7 secondes.

Ajoutez un tas de profils et dispersez-les à travers quelques tests et nous obtiendrons rapidement une compilation de plus de 60 secondes (en supposant que nous exécutons des tests dans le cadre de notre construction - et nous devrions le faire).

Une fois que nous sommes confrontés à une application assez complexe, résoudre ce problème est décourageant. Cependant, si nous planifions soigneusement à l'avance, il devient trivial de garder un temps de construction raisonnable.

Il y a quelques astuces que nous pourrions garder à l'esprit quand il s'agit de profils dans les tests d'intégration:

  • Créez un profil global, c.-à-d. test, incluez tous les profils nécessaires à l'intérieur - respectez notre profil de test partout

  • Concevez nos profils avec la possibilité de test. Si nous devons changer de profil, il existe peut-être un meilleur moyen

  • Indiquez notre profil de test dans un endroit centralisé - nous en reparlerons plus tard

  • Évitez de tester toutes les combinaisons de profils. Alternativement, nous pourrions avoir une suite de tests e2e par environnement testant l'application avec cet ensemble de profils spécifique.

4.2. Les problèmes avec@MockBean

@MockBean est un outil assez puissant.

Lorsque nous avons besoin de la magie du printemps mais que nous voulons nous moquer d'un composant particulier,@MockBean est vraiment pratique. Mais cela a un prix.

Every time @MockBean appears in a class, the ApplicationContext cache gets marked as dirty, hence the runner will clean the cache after the test-class is done. Ce qui ajoute à nouveau quelques secondes supplémentaires à notre build.

C’est un sujet controversé, mais essayer d’appliquer l’application réelle au lieu de se moquer de ce scénario particulier pourrait aider. Bien sûr, il n’ya pas de solution miracle ici. Les frontières deviennent floues lorsque nous ne nous permettons pas de nous moquer des dépendances.

Nous pourrions penser: Pourquoi persisterions-nous alors que tout ce que nous voulons tester est notre couche REST? C’est un bon point, et il y a toujours un compromis.

Cependant, avec quelques principes à l'esprit, cela peut en fait devenir un avantage qui conduit à une meilleure conception des tests et de notre application et réduit le temps de test.

4.3. Refactoring@MockBean

Dans cette section, nous allons essayer de refactoriser un test «lent» en utilisant@MockBean pour le faire réutiliser lesApplicationContext mis en cache.

Supposons que nous souhaitons tester un POST qui crée un utilisateur. Si nous nous moquions - en utilisant@MockBean, nous pourrions simplement vérifier que notre service a été appelé avec un utilisateur joliment sérialisé.

Si nous avons testé notre service correctement, cette approche devrait suffire:

class UsersControllerIntegrationTest : AbstractSpringIntegrationTest() {

    @Autowired
    lateinit var mvc: MockMvc

    @MockBean
    lateinit var userService: UserService

    @Test
    fun links() {
        mvc.perform(post("/users")
          .contentType(MediaType.APPLICATION_JSON)
          .content("""{ "name":"jose" }"""))
          .andExpect(status().isCreated)

        verify(userService).save("jose")
    }
}

interface UserService {
    fun save(name: String)
}

Nous voulons cependant éviter les@MockBean. Nous finirons donc par persister l'entité (en supposant que c'est ce que fait le service).

L’approche la plus naïve ici serait de tester l’effet secondaire: après le postage, mon utilisateur se trouve dans ma base de données. Dans notre exemple, il utilisait JDBC.

Ceci, cependant, viole les limites du test:

@Test
fun links() {
    mvc.perform(post("/users")
      .contentType(MediaType.APPLICATION_JSON)
      .content("""{ "name":"jose" }"""))
      .andExpect(status().isCreated)

    assertThat(
      JdbcTestUtils.countRowsInTable(jdbcTemplate, "users"))
      .isOne()
}

Dans cet exemple particulier, nous violons les limites de test car nous traitons notre application comme une boîte noire HTTP à envoyer à l'utilisateur, mais nous affirmons plus tard à l'aide des détails d'implémentation, c'est-à-dire que notre utilisateur a été conservé dans une base de données.

Si nous exerçons notre application via HTTP, pouvons-nous également affirmer le résultat via HTTP?

@Test
fun links() {
    mvc.perform(post("/users")
      .contentType(MediaType.APPLICATION_JSON)
      .content("""{ "name":"jose" }"""))
      .andExpect(status().isCreated)

    mvc.perform(get("/users/jose"))
      .andExpect(status().isOk)
}

Il y a quelques avantages si nous suivons la dernière approche:

  • Notre test commencera plus rapidement (bien que cela puisse prendre un peu plus de temps, mais cela devrait rapporter)

  • De plus, notre test ne prend pas en compte les effets secondaires non liés aux limites HTTP, c'est-à-dire DBs

  • Enfin, notre test exprime avec clarté l'intention du système: si vous POSTEZ, vous pourrez OBTENIR des utilisateurs

Bien sûr, cela pourrait ne pas toujours être possible pour diverses raisons:

  • Nous n’avons peut-être pas le point de terminaison «effets secondaires»: une option consiste à envisager de créer des «points de terminaison de test»

  • La complexité est trop élevée pour toucher l'ensemble de l'application: une option ici consiste à prendre en compte les tranches (nous en parlerons plus tard)

4.4. Réfléchir attentivement à@DirtiesContext

Parfois, nous pouvons avoir besoin de modifier lesApplicationContext dans nos tests. Pour ce scénario,@DirtiesContext offre exactement cette fonctionnalité.

Pour les mêmes raisons exposées ci-dessus,@DirtiesContext  est une ressource extrêmement coûteuse en termes de temps d'exécution, et en tant que telle, nous devons être prudents.

Some misuses of @DirtiesContext include application cache reset or in memory DB resets. Il existe de meilleures façons de gérer ces scénarios dans les tests d'intégration, et nous en aborderons certains dans d'autres sections.

4.5. Utilisation de tranches de test

Les tranches de test sont une fonctionnalité Spring Boot introduite dans la version 1.4. L'idée est assez simple, Spring créera un contexte d'application réduit pour une tranche spécifique de votre application.

En outre, le framework se chargera de configurer le minimum.

Spring Boot propose un nombre raisonnable de tranches prêtes à l'emploi et nous pouvons également créer la nôtre:

  • @JsonTest: Enregistre les composants pertinents JSON

  • @DataJpaTest: enregistre les beans JPA, y compris l'ORM disponible

  • @JdbcTest: Utile pour les tests JDBC bruts, prend en charge la source de données et les DB en mémoire sans fioritures ORM

  • @DataMongoTest: tente de fournir une configuration de test mongo en mémoire

  • @WebMvcTest: une tranche de test MVC simulée sans le reste de l'application

  • … (Nous pouvons vérifierthe source pour les trouver tous)

Si elle est utilisée à bon escient, cette fonctionnalité particulière peut nous aider à construire des tests étroits sans une telle pénalité en termes de performances, en particulier pour les applications de petite / moyenne taille.

Cependant, si notre application continue de croître, elle s'accumule également car elle crée un (petit) contexte d'application par tranche.

4.6. Utilisation de l'héritage de classe

Utiliser une seule classeAbstractSpringIntegrationTest comme parent de tous nos tests d'intégration est un moyen simple, puissant et pragmatique de maintenir la construction rapide.

If we provide a solid setup, our team will simply extend it, knowing that everything ‘just works'. De cette façon, nous pouvons moins nous soucier de la gestion de l'état ou de la configuration du cadre et nous concentrer sur le problème à résoudre.

Nous pourrions y définir toutes les exigences de test:

  • Le coureur de printemps - ou de préférence, au cas où nous aurions besoin d’autres coureurs plus tard

  • profils - idéalement notre sprofiletest agrégé

  • configuration initiale - réglage de l'état de notre application

Jetons un coup d'œil à une classe de base simple qui prend en charge les points précédents:

@SpringBootTest
@ActiveProfiles("test")
abstract class AbstractSpringIntegrationTest {

    @Rule
    @JvmField
    val springMethodRule = SpringMethodRule()

    companion object {
        @ClassRule
        @JvmField
        val SPRING_CLASS_RULE = SpringClassRule()
    }
}

4.7. Gestion d'état

Il est important de se rappeler où «unité» dans le test unitairecomes from. En termes simples, cela signifie que nous pouvons exécuter un seul test (ou un sous-ensemble) à tout moment pour obtenir des résultats cohérents.

Par conséquent, l'état doit être propre et connu avant le début de chaque test.

En d'autres termes, le résultat d'un test doit être cohérent, qu'il soit exécuté isolément ou conjointement à d'autres tests.

Cette idée s’applique de la même manière aux tests d’intégration. Nous devons nous assurer que notre application a un état connu (et reproductible) avant de commencer un nouveau test. Plus nous réutilisons de composants pour accélérer les choses (contexte d'application, bases de données, files d'attente, fichiers, etc.), plus il y a de chances d'obtenir une pollution d'état.

En supposant que nous ayons tous hérité de l'héritage de classe, nous avons maintenant un endroit central pour gérer l'état.

Améliorons notre classe abstraite pour nous assurer que notre application est dans un état connu avant d'exécuter des tests.

Dans notre exemple, nous supposerons qu'il existe plusieurs référentiels (provenant de différentes sources de données) et un serveurWiremock:

@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureWireMock(port = 8666)
@AutoConfigureMockMvc
abstract class AbstractSpringIntegrationTest {

    //... spring rules are configured here, skipped for clarity

    @Autowired
    protected lateinit var wireMockServer: WireMockServer

    @Autowired
    lateinit var jdbcTemplate: JdbcTemplate

    @Autowired
    lateinit var repos: Set>

    @Autowired
    lateinit var cacheManager: CacheManager

    @Before
    fun resetState() {
        cleanAllDatabases()
        cleanAllCaches()
        resetWiremockStatus()
    }

    fun cleanAllDatabases() {
        JdbcTestUtils.deleteFromTables(jdbcTemplate, "table1", "table2")
        jdbcTemplate.update("ALTER TABLE table1 ALTER COLUMN id RESTART WITH 1")
        repos.forEach { it.deleteAll() }
    }

    fun cleanAllCaches() {
        cacheManager.cacheNames
          .map { cacheManager.getCache(it) }
          .filterNotNull()
          .forEach { it.clear() }
    }

    fun resetWiremockStatus() {
        wireMockServer.resetAll()
        // set default requests if any
    }
}

4.8. Refactoring dans les tests unitaires

C'est probablement l'un des points les plus importants. Nous nous retrouverons à maintes reprises avec des tests d'intégration qui appliquent en fait une politique de haut niveau de notre application.

Chaque fois que nous trouvons des tests d’intégration testant plusieurs cas de logique métier, il est temps de repenser notre approche et de les décomposer en tests unitaires.

Un modèle possible ici pour accomplir ceci avec succès pourrait être:

  • Identifiez les tests d'intégration testant plusieurs scénarios de la logique métier principale

  • Dupliquer la suite et refactoriser la copie en tests unitaires - à ce stade, nous aurons peut-être besoin de décomposer le code de production également pour le rendre testable.

  • Obtenez tous les tests verts

  • Laissez un exemple de chemin joyeux qui soit assez remarquable dans la suite d’intégration - nous pourrions avoir besoin de refactoriser ou de rejoindre et de remodeler quelques

  • Supprimer les tests d'intégration restants

Michael Feathers couvre de nombreuses techniques pour y parvenir et davantage dans Travailler efficacement avec le code hérité.

5. Sommaire

Dans cet article, nous avons eu une introduction aux tests d'intégration en mettant l'accent sur Spring.

Nous avons d’abord parlé de l’importance des tests d’intégration et de la raison pour laquelle ils sont particulièrement pertinents dans les applications Spring.

Après cela, nous avons résumé quelques outils pouvant s'avérer utiles pour certains types de tests d'intégration dans Web Apps.

Enfin, nous avons passé en revue une liste de problèmes potentiels susceptibles de ralentir la durée d'exécution de nos tests, ainsi que des astuces pour l'améliorer.