Ottimizzazione dei test di integrazione primaverile

1. Introduzione

In questo articolo, avremo una discussione olistica sui test di integrazione che utilizzano Spring e su come ottimizzarli.

Innanzitutto, discuteremo brevemente l'importanza dei test di integrazione e il loro posto nel software moderno concentrandoci sull'ecosistema Spring.

Successivamente, tratteremo più scenari, concentrandoci sulle app web.

Successivamente, discuteremo alcune strategie per migliorare la velocità dei test , apprendendo diversi approcci che potrebbero influenzare sia il modo in cui modelliamo i nostri test sia il modo in cui modelliamo l'app stessa.

Prima di iniziare, è importante tenere presente che questo è un articolo di opinione basato sull'esperienza. Alcune di queste cose potrebbero essere adatte a te, altre no.

Infine, questo articolo utilizza Kotlin per gli esempi di codice per mantenerli il più concisi possibile, ma i concetti non sono specifici per questo linguaggio e gli snippet di codice dovrebbero essere significativi allo stesso modo per gli sviluppatori Java e Kotlin.

2. Test di integrazione

I test di integrazione sono una parte fondamentale delle suite di test automatizzate. Anche se non dovrebbero essere numerosi come i test unitari se seguiamo una piramide di test sana. Affidarsi a framework come Spring ci lascia la necessità di una discreta quantità di test di integrazione per ridurre il rischio di determinati comportamenti del nostro sistema.

Più semplifichiamo il nostro codice utilizzando i moduli Spring (dati, sicurezza, social ...), maggiore sarà la necessità di test di integrazione. Ciò diventa particolarmente vero quando spostiamo bit e bob della nostra infrastruttura nelle classi @Configuration .

Non dovremmo "testare il framework", ma dovremmo certamente verificare che il framework sia configurato per soddisfare le nostre esigenze.

I test di integrazione ci aiutano a creare fiducia, ma hanno un prezzo:

  • Questa è una velocità di esecuzione più lenta, il che significa build più lente
  • Inoltre, i test di integrazione implicano un ambito di test più ampio che non è l'ideale nella maggior parte dei casi

Con questo in mente, proveremo a trovare alcune soluzioni per mitigare i problemi sopra menzionati.

3. Test delle app Web

Spring offre alcune opzioni per testare le applicazioni web e la maggior parte degli sviluppatori Spring le conosce, queste sono:

  • MockMvc : prende in giro l'API servlet, utile per le app web non reattive
  • TestRestTemplate : può essere utilizzato puntando alla nostra app, utile per app Web non reattive in cui i servlet simulati non sono desiderabili
  • WebTestClient: è uno strumento di test per app Web reattive, sia con richieste / risposte simulate o che colpiscono un server reale

Poiché abbiamo già articoli che trattano questi argomenti, non passeremo il tempo a parlarne.

Sentiti libero di dare un'occhiata se desideri scavare più a fondo.

4. Ottimizzazione del tempo di esecuzione

I test di integrazione sono fantastici. Ci danno un buon grado di fiducia. Inoltre, se implementati in modo appropriato, possono descrivere l'intento della nostra app in modo molto chiaro, con meno beffe e rumore di installazione.

Tuttavia, man mano che la nostra app matura e lo sviluppo si accumula, il tempo di creazione aumenta inevitabilmente. Con l'aumentare del tempo di compilazione, potrebbe diventare poco pratico continuare a eseguire tutti i test ogni volta.

Da allora in poi, influenzando il nostro ciclo di feedback e ottenendo le migliori pratiche di sviluppo.

Inoltre, i test di integrazione sono intrinsecamente costosi. Avviare una persistenza di qualche tipo, inviare richieste tramite (anche se non lasciano mai localhost ) o eseguire operazioni di I / O richiede semplicemente tempo.

È fondamentale tenere d'occhio il nostro tempo di compilazione, inclusa l'esecuzione dei test. E ci sono alcuni trucchi che possiamo applicare in primavera per mantenerlo basso.

Nelle prossime sezioni, tratteremo alcuni punti per aiutarci a ottimizzare il nostro tempo di costruzione e alcune insidie ​​che potrebbero influire sulla sua velocità:

  • Usare i profili con saggezza: come i profili influiscono sulle prestazioni
  • Riconsiderare @MockBean: come la beffa colpisce le prestazioni
  • Refactoring @MockBean : alternative per migliorare le prestazioni
  • Pensare attentamente a @ DirtiesContext: un'annotazione utile ma pericolosa e a come non usarla
  • Utilizzo delle sezioni di prova: uno strumento interessante che può aiutare o farci strada
  • Utilizzo dell'ereditarietà delle classi: un modo per organizzare i test in modo sicuro
  • Gestione dello stato: buone pratiche per evitare i test flakey
  • Refactoring in unit test: il modo migliore per ottenere una build solida e scattante

Iniziamo!

4.1. Usare i profili con saggezza

I profili sono uno strumento piuttosto accurato. Vale a dire, semplici tag che possono abilitare o disabilitare alcune aree della nostra App. Potremmo persino implementare flag di funzionalità con loro!

Man mano che i nostri profili diventano più ricchi, si è tentati di scambiare di tanto in tanto nei nostri test di integrazione. Ci sono strumenti utili per farlo, come @ActiveProfiles . Tuttavia, ogni volta che eseguiamo un test con un nuovo profilo, viene creato un nuovo ApplicationContext .

La creazione di contesti dell'applicazione potrebbe essere scattante con un'app di avvio primaverile vaniglia senza nulla al suo interno. Aggiungi un ORM e alcuni moduli e salirà rapidamente a 7+ secondi.

Aggiungi un gruppo di profili e distribuiscili attraverso alcuni test e otterremo rapidamente una build di oltre 60 secondi (supponendo che eseguiamo test come parte della nostra build - e dovremmo).

Una volta che dobbiamo affrontare un'applicazione abbastanza complessa, risolverlo è scoraggiante. Tuttavia, se pianifichiamo attentamente in anticipo, diventa banale mantenere un tempo di costruzione ragionevole.

Ci sono alcuni trucchi che potremmo tenere a mente quando si tratta di profili nei test di integrazione:

  • Creare un profilo aggregato, ovvero test , includere tutti i profili necessari all'interno - attenersi al nostro profilo di test ovunque
  • Progetta i nostri profili pensando alla testabilità. Se dovessimo cambiare profilo forse c'è un modo migliore
  • Indica il nostro profilo di prova in un luogo centralizzato: ne parleremo più tardi
  • Evita di testare tutte le combinazioni di profili. In alternativa, potremmo avere una suite di test e2e per ambiente che testa l'app con quello specifico set di profili

4.2. I problemi con @MockBean

@MockBean è uno strumento piuttosto potente.

Quando abbiamo bisogno di un po 'di magia primaverile ma vogliamo deridere un particolare componente, @MockBean è davvero utile. Ma lo fa a un prezzo.

Ogni volta che @MockBean appare in una classe, la cache di ApplicationContext viene contrassegnata come sporca, quindi il runner pulirà la cache al termine della classe di test. Il che aggiunge ancora un po 'di secondi in più alla nostra build.

Questo è un argomento controverso, ma provare a esercitare l'app vera e propria invece di deridere questo particolare scenario potrebbe aiutare. Naturalmente, non c'è nessun proiettile d'argento qui. I confini diventano sfocati quando non ci permettiamo di simulare le dipendenze.

Potremmo pensare: perché dovremmo persistere quando tutto ciò che vogliamo testare è il nostro livello REST? Questo è un punto giusto e c'è sempre un compromesso.

Tuttavia, con alcuni principi in mente, questo potrebbe effettivamente essere trasformato in un vantaggio che porta a una migliore progettazione di entrambi i test e della nostra app e riduce i tempi di test.

4.3. Refactoring @MockBean

In questa sezione, proveremo a effettuare il refactoring di un test "lento" utilizzando @MockBean per riutilizzare l' ApplicationContext memorizzato nella cache .

Supponiamo di voler testare un POST che crea un utente. Se stessimo prendendo in giro, usando @MockBean , potremmo semplicemente verificare che il nostro servizio è stato chiamato con un utente ben serializzato.

Se testassimo adeguatamente il nostro servizio, questo approccio dovrebbe essere sufficiente:

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

Tuttavia, vogliamo evitare @MockBean . Quindi finiremo per rendere persistente l'entità (supponendo che sia ciò che fa il servizio).

L'approccio più ingenuo qui sarebbe testare l'effetto collaterale: dopo il POST, il mio utente è nel mio DB, nel nostro esempio, questo userebbe JDBC.

Questo, tuttavia, viola i limiti del test:

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

In questo particolare esempio violiamo i limiti del test perché trattiamo la nostra app come una scatola nera HTTP da inviare all'utente, ma in seguito affermiamo di utilizzare i dettagli di implementazione, ovvero il nostro utente è stato mantenuto in alcuni DB.

Se esercitiamo la nostra app tramite HTTP, possiamo affermare il risultato anche tramite 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) }

Ci sono alcuni vantaggi se seguiamo l'ultimo approccio:

  • Il nostro test inizierà più rapidamente (probabilmente, potrebbe essere necessario un po 'più di tempo per l'esecuzione, ma dovrebbe ripagare)
  • Inoltre, il nostro test non è a conoscenza di effetti collaterali non correlati ai limiti HTTP, ad esempio i DB
  • Infine, il nostro test esprime con chiarezza l'intento del sistema: se effettui il POST, sarai in grado di GET Users

Naturalmente, questo potrebbe non essere sempre possibile per vari motivi:

  • Potremmo non avere l'endpoint "effetto collaterale": un'opzione qui è considerare la creazione di "endpoint di test"
  • La complessità è troppo alta per colpire l'intera app: un'opzione qui è prendere in considerazione le sezioni (ne parleremo più avanti)

4.4. Pensando attentamente a @DirtiesContext

A volte, potrebbe essere necessario modificare ApplicationContext nei nostri test. Per questo scenario, @DirtiesContext offre esattamente quella funzionalità.

Per gli stessi motivi esposti sopra, @DirtiesContext è una risorsa estremamente costosa quando si tratta di tempo di esecuzione e, come tale, dovremmo stare attenti.

Alcuni usi impropri di @DirtiesContext includono il ripristino della cache dell'applicazione o il ripristino del database in memoria. Esistono modi migliori per gestire questi scenari nei test di integrazione e ne tratteremo alcuni nelle sezioni successive.

4.5. Utilizzo di sezioni di prova

Le sezioni di prova sono una funzionalità di Spring Boot introdotta nella versione 1.4. L'idea è abbastanza semplice, Spring creerà un contesto applicativo ridotto per una fetta specifica della tua app.

Inoltre, il framework si occuperà di configurare il minimo.

Ci sono un numero ragionevole di sezioni disponibili fuori dagli schemi in Spring Boot e possiamo crearne anche nostre:

  • @JsonTest: registra i componenti rilevanti JSON
  • @DataJpaTest : registra i bean JPA, incluso l'ORM disponibile
  • @JdbcTest : utile per i test JDBC non elaborati , si prende cura dell'origine dati e nei DB di memoria senza fronzoli ORM
  • @DataMongoTest : cerca di fornire una configurazione di test mongo in memoria
  • @WebMvcTest : una sezione di test MVC fittizia senza il resto dell'app
  • ... (possiamo controllare la fonte per trovarli tutti)

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.

Innanzitutto, abbiamo parlato dell'importanza dei test di integrazione e del motivo per cui sono particolarmente rilevanti nelle applicazioni Spring.

Successivamente, abbiamo riepilogato alcuni strumenti che potrebbero tornare utili per alcuni tipi di test di integrazione nelle app Web.

Infine, abbiamo esaminato un elenco di potenziali problemi che rallentano il nostro tempo di esecuzione del test, oltre a trucchi per migliorarlo.