Optimisation des tests d'intégration Spring

1. Introduction

Dans cet article, nous aurons une discussion globale 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.

Ensuite, nous discuterons de certaines stratégies pour améliorer la vitesse des tests , 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

Les tests d'intégration sont un élément fondamental des suites de tests automatisés. Bien qu'ils ne devraient pas être aussi nombreux que les tests unitaires si nous suivons une pyramide de tests saine. En nous appuyant sur des frameworks tels que Spring, nous avons besoin d'une bonne quantité de tests d'intégration afin de réduire le risque de certains comportements de notre système.

Plus nous simplifions notre code en utilisant des modules Spring (données, sécurité, social…), plus le besoin de tests d'intégration est grand. Cela devient particulièrement vrai lorsque nous déplaçons des bits et des bobs de notre infrastructure dans des classes @Configuration .

Nous ne devrions 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
  • En outre, les tests d'intégration impliquent une portée de test plus large qui n'est pas idéale 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 apporte quelques options pour tester les applications Web, et la plupart des développeurs Spring les connaissent, ce sont:

  • 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 requêtes / 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 approfondir.

4. Optimisation du temps d'exécution

Les tests d'intégration sont excellents. Ils nous donnent une bonne confiance. De plus, s'ils sont mis en œuvre correctement, ils peuvent décrire l'intention de notre application de manière très claire, avec moins de bruit de moquerie et de configuration.

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

Par la suite, impactant notre boucle de rétroaction et mise en place des meilleures pratiques de développement.

De plus, les tests d'intégration sont par nature coûteux. Démarrer une certaine persistance, envoyer des requêtes via (même si elles ne quittent jamais localhost ) 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 quelques pièges qui pourraient avoir un impact sur sa vitesse:

  • Utiliser judicieusement les profils - comment les profils impactent les performances
  • Reconsidérer @MockBean - comment la moquerie frappe les performances
  • Refactoring @MockBean - alternatives pour améliorer les performances
  • Réfléchir attentivement à @ DirtiesContext - une annotation utile mais dangereuse et comment ne pas l'utiliser
  • Utilisation de tranches de test - un outil sympa qui peut vous aider ou nous mettre en route
  • Utilisation de l'héritage de classe - un moyen d'organiser les tests de manière sûre
  • Gestion de l'état - bonnes pratiques pour éviter les tests floconneux
  • Refactoring en tests unitaires - le meilleur moyen d'obtenir une construction solide et dynamique

Commençons!

4.1. Utiliser judicieusement les profils

Les profils sont un outil assez soigné. À savoir, des balises simples qui peuvent activer ou désactiver certaines zones de notre application. Nous pourrions même implémenter des indicateurs de fonctionnalités avec eux!

Au fur et à mesure que nos profils s'enrichissent, il est tentant de les échanger de temps en temps dans nos tests d'intégration. Il existe des outils pratiques pour le faire, comme @ActiveProfiles . Cependant, chaque fois que nous effectuons un test avec un nouveau profil, un nouveau ApplicationContext est créé.

La création de contextes d'application peut être rapide avec une application de démarrage à ressort vanille sans rien dedans. Ajoutez un ORM et quelques modules et il montera rapidement en flèche à 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écutions des tests dans le cadre de notre construction - et nous devrions le faire).

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

Il y a quelques astuces que nous pourrions garder à l'esprit en ce qui concerne les profils dans les tests d'intégration:

  • Créez un profil agrégé, c'est-à-dire testez , incluez tous les profils nécessaires - respectez notre profil de test partout
  • Concevez nos profils en gardant à l'esprit la testabilité. Si nous finissons par devoir changer de profil, il y a peut-être un meilleur moyen
  • Énoncez 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.

Chaque fois que @MockBean apparaît dans une classe, le cache ApplicationContext est marqué comme sale, par conséquent le runner nettoiera le cache une fois la classe de test terminée. Ce qui ajoute encore quelques secondes supplémentaires à notre build.

Ceci est controversé, mais essayer d'exercer l'application réelle au lieu de se moquer de ce scénario particulier pourrait aider. Bien sûr, il n'y a 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 être transformé en un avantage qui conduit à une meilleure conception des tests et de notre application et réduit le temps de test.

4.3. Refactoring @MockBean

In this section, we'll try to refactor a ‘slow' test using @MockBean to make it reuse the cached ApplicationContext.

Let's assume we want to test a POST that creates a user. If we were mocking – using @MockBean, we could simply verify that our service has been called with a nicely serialized user.

If we tested our service properly this approach should suffice:

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) }

We want to avoid @MockBean though. So we'll end up persisting the entity (assuming that's what the service does).

The most naive approach here would be to test the side effect: After POSTing, my user is in my DB, in our example, this would use JDBC.

This, however, violates testing boundaries:

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

In this particular example we violate testing boundaries because we treat our app as an HTTP black box to send the user, but later we assert using implementation details, that is, our user has been persisted in some DB.

If we exercise our app through HTTP, can we assert the result through HTTP too?

@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) }

There are a few advantages if we follow the last approach:

  • Our test will start quicker (arguably, it might take a tiny bit longer to execute though, but it should pay back)
  • Also, our test isn't aware of side effects not related to HTTP boundaries i.e. DBs
  • Finally, our test expresses with clarity the intent of the system: If you POST, you'll be able to GET Users

Of course, this might not always be possible for various reasons:

  • We might not have the ‘side-effect' endpoint: An option here is to consider creating ‘testing endpoints'
  • Complexity is too high to hit the entire app: An option here is to consider slices (we'll talk about them later)

4.4. Thinking Carefully About @DirtiesContext

Sometimes, we might need to modify the ApplicationContext in our tests. For this scenario, @DirtiesContext delivers exactly that functionality.

For the same reasons exposed above, @DirtiesContext is an extremely expensive resource when it comes to execution time, and as such, we should be careful.

Some misuses of @DirtiesContext include application cache reset or in memory DB resets. There are better ways to handle these scenarios in integration tests, and we'll cover some in further sections.

4.5. Using Test Slices

Test Slices are a Spring Boot feature introduced in the 1.4. The idea is fairly simple, Spring will create a reduced application context for a specific slice of your app.

Also, the framework will take care of configuring the very minimum.

There are a sensible number of slices available out of the box in Spring Boot and we can create our own too:

  • @JsonTest: Registers JSON relevant components
  • @DataJpaTest: Registers JPA beans, including the ORM available
  • @JdbcTest: Useful for raw JDBC tests, takes care of the data source and in memory DBs without ORM frills
  • @DataMongoTest: Tries to provide an in-memory mongo testing setup
  • @WebMvcTest: A mock MVC testing slice without the rest of the app
  • … (we can check the source to find them all)

This particular feature if used wisely can help us build narrow tests without such a big penalty in terms of performance particularly for small/medium sized apps.

However, if our application keeps growing it also piles up as it creates one (small) application context per slice.

4.6. Using Class Inheritance

Using a single AbstractSpringIntegrationTest class as the parent of all our integration tests is a simple, powerful and pragmatic way of keeping the build fast.

If we provide a solid setup, our team will simply extend it, knowing that everything ‘just works'. This way we can worry less about managing state or configuring the framework and focus on the problem at hand.

We could set all the test requirements there:

  • The Spring runner – or preferably rules, in case we need other runners later
  • profiles – ideally our aggregate test profile
  • initial config – setting the state of our application

Let's have a look at a simple base class that takes care of the previous points:

@SpringBootTest @ActiveProfiles("test") abstract class AbstractSpringIntegrationTest { @Rule @JvmField val springMethodRule = SpringMethodRule() companion object { @ClassRule @JvmField val SPRING_CLASS_RULE = SpringClassRule() } }

4.7. State Management

It's important to remember where ‘unit' in Unit Test comes from. Simply put, it means we can run a single test (or a subset) at any point getting consistent results.

Hence, the state should be clean and known before every test starts.

In other words, the result of a test should be consistent regardless of whether it is executed in isolation or together with other tests.

This idea applies just the same to integration tests. We need to ensure our app has a known (and repeatable) state before starting a new test. The more components we reuse to speed things up (app context, DBs, queues, files…), the more chances to get state pollution.

Assuming we went all in with class inheritance, now, we have a central place to manage state.

Let's enhance our abstract class to make sure our app is in a known state before running tests.

In our example, we'll assume there are several repositories (from various data sources), and a Wiremock server:

@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 into Unit Tests

This is probably one of the most important points. We'll find ourselves over and over with some integration tests that are actually exercising some high-level policy of our app.

Whenever we find some integration tests testing a bunch of cases of core business logic, it's time to rethink our approach and break them down into unit tests.

A possible pattern here to accomplish this successfully could be:

  • Identify integration tests that are testing multiple scenarios of core business logic
  • Duplicate the suite, and refactor the copy into unit Tests – at this stage, we might need to break down the production code too to make it testable
  • Get all tests green
  • Leave a happy path sample that is remarkable enough in the integration suite – we might need to refactor or join and reshape a few
  • Remove the remaining integration Tests

Michael Feathers covers many techniques to achieve this and more in Working Effectively with Legacy Code.

5. Summary

In this article, we had an introduction to Integration tests with a focus on Spring.

Tout d'abord, nous avons 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 qui pourraient être utiles pour certains types de tests d'intégration dans les applications Web.

Enfin, nous avons parcouru une liste de problèmes potentiels qui ralentissent le temps d'exécution de nos tests, ainsi que des astuces pour l'améliorer.