Optimierung der Federintegrationstests

1. Einleitung

In diesem Artikel werden wir eine ganzheitliche Diskussion über Integrationstests mit Spring und deren Optimierung führen.

Zunächst werden wir kurz die Bedeutung von Integrationstests und ihren Platz in moderner Software diskutieren, wobei der Schwerpunkt auf dem Spring-Ökosystem liegt.

Später werden wir mehrere Szenarien behandeln und uns auf Web-Apps konzentrieren.

Als nächstes werden wir einige Strategien zur Verbesserung der Testgeschwindigkeit diskutieren , indem wir verschiedene Ansätze kennenlernen, die sowohl die Art und Weise, wie wir unsere Tests gestalten, als auch die Art und Weise, wie wir die App selbst gestalten, beeinflussen können.

Bevor Sie beginnen, ist es wichtig zu beachten, dass dies ein Erfahrungsartikel ist, der auf Erfahrung basiert. Einige dieser Dinge könnten zu Ihnen passen, andere nicht.

Schließlich wird in diesem Artikel Kotlin für die Codebeispiele verwendet, um sie so präzise wie möglich zu halten. Die Konzepte sind jedoch nicht spezifisch für diese Sprache, und Codefragmente sollten für Java- und Kotlin-Entwickler gleichermaßen von Bedeutung sein.

2. Integrationstests

Integrationstests sind ein wesentlicher Bestandteil automatisierter Testsuiten. Obwohl sie nicht so zahlreich sein sollten wie Unit-Tests, wenn wir einer gesunden Testpyramide folgen. Wenn wir uns auf Frameworks wie Spring verlassen, müssen wir eine ganze Reihe von Integrationstests durchführen, um bestimmte Verhaltensweisen unseres Systems zu gefährden.

Je mehr wir unseren Code durch die Verwendung von Spring-Modulen (Daten, Sicherheit, Soziales…) vereinfachen, desto größer ist der Bedarf an Integrationstests. Dies gilt insbesondere dann, wenn wir Teile unserer Infrastruktur in @ Configuration- Klassen verschieben.

Wir sollten das Framework nicht „testen“, aber wir sollten auf jeden Fall überprüfen, ob das Framework für unsere Anforderungen konfiguriert ist.

Integrationstests helfen uns, Vertrauen aufzubauen, aber sie haben ihren Preis:

  • Das ist eine langsamere Ausführungsgeschwindigkeit, was langsamere Builds bedeutet
  • Integrationstests implizieren auch einen breiteren Testumfang, der in den meisten Fällen nicht ideal ist

In diesem Sinne werden wir versuchen, einige Lösungen zu finden, um die oben genannten Probleme zu mindern.

3. Testen von Web Apps

Spring bietet einige Optionen zum Testen von Webanwendungen, mit denen die meisten Spring-Entwickler vertraut sind:

  • MockMvc : Verspottet die Servlet-API, die für nicht reaktive Webanwendungen nützlich ist
  • TestRestTemplate : Kann verwendet werden, um auf unsere App zu verweisen. Dies ist nützlich für nicht reaktive Web-Apps, bei denen verspottete Servlets nicht erwünscht sind
  • WebTestClient: Ist ein Testtool für reaktive Webanwendungen, sowohl mit verspotteten Anforderungen / Antworten als auch mit einem echten Server

Da wir bereits Artikel zu diesen Themen haben, werden wir keine Zeit damit verbringen, darüber zu sprechen.

Schauen Sie doch einfach mal rein, wenn Sie tiefer graben möchten.

4. Ausführungszeit optimieren

Integrationstests sind großartig. Sie geben uns ein gutes Maß an Vertrauen. Auch wenn sie angemessen implementiert sind, können sie die Absicht unserer App auf sehr klare Weise beschreiben, mit weniger Verspottungs- und Einrichtungsgeräuschen.

Wenn unsere App jedoch ausgereift ist und sich die Entwicklung häuft, steigt zwangsläufig die Bauzeit. Mit zunehmender Erstellungszeit kann es unpraktisch werden, jedes Mal alle Tests auszuführen.

Danach wirkt sich dies auf unsere Rückkopplungsschleife aus und macht sich auf den Weg zu den besten Entwicklungspraktiken.

Darüber hinaus sind Integrationstests von Natur aus teuer. Das Starten einer Persistenz, das Senden von Anforderungen (auch wenn sie localhost nie verlassen ) oder das Ausführen von E / A-Vorgängen dauert einfach einige Zeit.

Es ist von größter Bedeutung, unsere Erstellungszeit im Auge zu behalten, einschließlich der Testausführung. Und es gibt einige Tricks, die wir im Frühjahr anwenden können, um sie niedrig zu halten.

In den nächsten Abschnitten werden wir einige Punkte behandeln, um unsere Erstellungszeit zu optimieren, sowie einige Fallstricke, die sich auf die Geschwindigkeit auswirken können:

  • Mit Bedacht Profile verwenden - wie sich Profile auf die Leistung auswirken
  • Überdenken von @MockBean - wie Spott die Leistung beeinträchtigt
  • Refactoring @MockBean - Alternativen zur Leistungsverbesserung
  • Denken Sie sorgfältig über @ DirtiesContext nach - eine nützliche, aber gefährliche Anmerkung und wie man sie nicht verwendet
  • Verwenden von Testscheiben - ein cooles Tool, das Ihnen helfen oder uns auf den Weg machen kann
  • Verwenden der Klassenvererbung - eine Möglichkeit, Tests auf sichere Weise zu organisieren
  • Staatsmanagement - bewährte Verfahren zur Vermeidung von Flockentests
  • Refactoring in Unit-Tests - der beste Weg, um einen soliden und bissigen Build zu erhalten

Lass uns anfangen!

4.1. Mit Bedacht Profile verwenden

Profile sind ein hübsches Werkzeug. Einfache Tags, die bestimmte Bereiche unserer App aktivieren oder deaktivieren können. Wir könnten sogar Feature-Flags mit ihnen implementieren!

Wenn unsere Profile umfangreicher werden, ist es verlockend, ab und zu in unseren Integrationstests zu tauschen. Dazu gibt es praktische Tools wie @ActiveProfiles . Jedes Mal , wenn wir einen Test mit einem neuen Profil durchführen, wird ein neuer ApplicationContext erstellt.

Das Erstellen von Anwendungskontexten kann mit einer Vanilla Spring Boot-App, in der sich nichts befindet, schnell funktionieren. Wenn Sie ein ORM und einige Module hinzufügen, steigt die Geschwindigkeit schnell auf über 7 Sekunden.

Fügen Sie eine Reihe von Profilen hinzu und verteilen Sie sie auf einige Tests. Wir erhalten schnell einen Build von mehr als 60 Sekunden (vorausgesetzt, wir führen Tests als Teil unseres Builds aus - und das sollten wir auch).

Sobald wir mit einer ausreichend komplexen Anwendung konfrontiert sind, ist es entmutigend, diese zu beheben. Wenn wir jedoch sorgfältig im Voraus planen, wird es trivial, eine vernünftige Bauzeit einzuhalten.

Es gibt einige Tricks, die wir bei Profilen in Integrationstests beachten sollten:

  • Erstellen Sie ein aggregiertes Profil, dh testen Sie , und schließen Sie alle erforderlichen Profile ein. Halten Sie sich überall an unser Testprofil
  • Gestalten Sie unsere Profile unter Berücksichtigung der Testbarkeit. Wenn wir am Ende das Profil wechseln müssen, gibt es vielleicht einen besseren Weg
  • Geben Sie unser Testprofil an einem zentralen Ort an - wir werden später darüber sprechen
  • Vermeiden Sie es, alle Profilkombinationen zu testen. Alternativ könnten wir eine e2e-Testsuite pro Umgebung haben, die die App mit diesem spezifischen Profilsatz testet

4.2. Die Probleme mit @MockBean

@MockBean ist ein ziemlich mächtiges Werkzeug.

Wenn wir etwas Frühlingszauber brauchen, aber eine bestimmte Komponente verspotten möchten, ist @MockBean sehr praktisch. Aber das zu einem Preis.

Jedes Mal, wenn @MockBean in einer Klasse angezeigt wird , wird der ApplicationContext- Cache als fehlerhaft markiert, sodass der Runner den Cache nach Abschluss der Testklasse bereinigt. Das fügt unserem Build wieder ein paar Sekunden hinzu.

Dies ist umstritten, aber der Versuch, die eigentliche App zu üben, anstatt sich über dieses spezielle Szenario lustig zu machen, könnte helfen. Natürlich gibt es hier keine Silberkugel. Grenzen verschwimmen, wenn wir uns nicht erlauben, Abhängigkeiten zu verspotten.

Wir könnten denken: Warum sollten wir bestehen bleiben, wenn wir nur unsere REST-Ebene testen möchten? Dies ist ein fairer Punkt, und es gibt immer einen Kompromiss.

Mit ein paar Prinzipien kann dies jedoch tatsächlich zu einem Vorteil werden, der zu einem besseren Design sowohl der Tests als auch unserer App führt und die Testzeit verkürzt.

4.3. Refactoring @MockBean

In diesem Abschnitt werden wir versuchen, einen 'langsamen' Test mit @MockBean umzugestalten , damit der zwischengespeicherte ApplicationContext wiederverwendet wird .

Nehmen wir an, wir möchten einen POST testen, der einen Benutzer erstellt. Wenn wir uns über @MockBean lustig machten , konnten wir einfach überprüfen, ob unser Dienst mit einem gut serialisierten Benutzer aufgerufen wurde.

Wenn wir unseren Service ordnungsgemäß getestet haben, sollte dieser Ansatz ausreichen:

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

Wir möchten jedoch @MockBean vermeiden . Am Ende bleiben wir also bei der Entität (vorausgesetzt, der Dienst leistet dies).

Der naivste Ansatz wäre hier, den Nebeneffekt zu testen: Nach dem POSTing befindet sich mein Benutzer in meiner Datenbank. In unserem Beispiel würde dies JDBC verwenden.

Dies verstößt jedoch gegen Testgrenzen:

@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.

Zunächst sprachen wir über die Bedeutung von Integrationstests und warum sie in Spring-Anwendungen besonders relevant sind.

Danach haben wir einige Tools zusammengefasst, die für bestimmte Arten von Integrationstests in Web Apps nützlich sein können.

Schließlich gingen wir eine Liste potenzieller Probleme durch, die unsere Testausführungszeit verlangsamen, sowie Tricks, um sie zu verbessern.