
Wenn ich zu einem neuen Projekt komme, stoße ich regelmäßig auf eine der folgenden Situationen:
- Es gibt überhaupt keine Tests.
- Es gibt nur wenige Tests, sie werden selten geschrieben und nicht fortlaufend ausgeführt.
- Tests sind vorhanden und in CI (Continuous Integration) enthalten, schaden aber mehr als sie nützen.
Leider ist es das letztere Szenario, das häufig zu ernsthaften Versuchen führt, Tests durchzuführen, wenn keine geeigneten Fähigkeiten vorhanden sind.
Was kann getan werden, um die aktuelle Situation zu ändern? Die Idee, Tests zu verwenden, ist nicht neu. Gleichzeitig ähneln die meisten Tutorials dem berühmten Bild, wie man eine Eule zeichnet: Verbinden Sie JUnit, schreiben Sie den ersten Test, verwenden Sie den ersten Mock - und los geht's! Solche Artikel beantworten keine Fragen darüber, welche Tests geschrieben werden müssen, worauf es sich zu achten lohnt und wie man damit umgeht. Von hier aus wurde die Idee dieses Artikels geboren. Ich habe versucht, meine Erfahrungen bei der Implementierung von Tests in verschiedenen Projekten kurz zusammenzufassen, um diesen Weg für alle zu erleichtern.

Es gibt mehr als genug einführende Artikel zu diesem Thema, daher werden wir uns nicht wiederholen und versuchen, von der anderen Seite zu gehen. Im ersten Teil werden wir den Mythos entlarven, dass das Testen ausschließlich zusätzliche Kosten verursacht. Es wird gezeigt, wie die Erstellung von Qualitätstests wiederum den Entwicklungsprozess beschleunigen kann. Am Beispiel eines kleinen Projekts werden dann die Grundprinzipien und Regeln berücksichtigt, die befolgt werden sollten, um diesen Vorteil zu realisieren. Schließlich werden im letzten Abschnitt spezifische Implementierungsempfehlungen gegeben: Wie können typische Probleme vermieden werden, wenn Tests beginnen, im Gegenteil, die Entwicklung wird erheblich verlangsamt.
Da meine Hauptspezialisierung das Java-Backend ist, wird in den Beispielen der folgende Technologie-Stack verwendet: Java, JUnit, H2, Mockito, Spring, Hibernate. Gleichzeitig widmet sich ein wesentlicher Teil des Artikels allgemeinen Testproblemen, und die darin enthaltenen Tipps gelten für ein viel breiteres Aufgabenspektrum.
Seien Sie jedoch vorsichtig! Tests machen süchtig: Wenn Sie erst einmal gelernt haben, wie man sie benutzt, können Sie nicht mehr ohne sie leben.
Tests gegen Entwicklungsgeschwindigkeit
Die wichtigsten Fragen, die sich bei der Erörterung der Implementierung von Tests stellen: Wie lange dauert das Schreiben von Tests und welche Vorteile ergeben sich daraus? Das Testen erfordert wie jede andere Technologie ernsthafte Anstrengungen für die Entwicklung und Implementierung, sodass zunächst keine signifikanten Vorteile zu erwarten sind. Die Zeitkosten hängen stark vom jeweiligen Team ab. Weniger als 20-30% der zusätzlichen Kosten für die Codierung sollten jedoch nicht genau berechnet werden. Weniger ist einfach nicht genug, um zumindest ein Ergebnis zu erzielen. Die Erwartung sofortiger Renditen ist häufig der Hauptgrund für die Einschränkung dieser Aktivität, noch bevor die Tests nützlich werden.
Aber über welche Art von Effizienz sprechen wir? Lassen Sie uns die Texte über die Schwierigkeiten bei der Implementierung fallen und sehen, welche spezifischen Möglichkeiten zum Sparen von Zeit beim Testen eröffnet werden.
Code an jedem Ort ausführen
Wenn das Projekt keine Tests enthält, können Sie nur die gesamte Anwendung anheben. Es ist gut, wenn es ungefähr 15 bis 20 Sekunden dauert, aber Fälle von großen Projekten, bei denen ein vollständiger Start mehrere Minuten dauern kann, sind alles andere als selten. Was bedeutet das für Entwickler? Ein wesentlicher Teil ihrer Arbeitszeit sind diese kurzen Wartesitzungen, in denen Sie nicht weiter an der aktuellen Aufgabe arbeiten können, aber gleichzeitig zu wenig Zeit bleibt, um zu etwas anderem zu wechseln. Viele sind mindestens einmal auf solche Projekte gestoßen, bei denen der in einer Stunde geschriebene Code aufgrund langer Neustarts zwischen den Korrekturen viele Stunden Debugging erfordert. In Tests können Sie sich darauf beschränken, kleine Teile der Anwendung auszuführen, was die Wartezeit erheblich verkürzt und die Produktivität bei der Arbeit an Code erhöht.
Darüber hinaus führt die Möglichkeit, Code an jedem Ort auszuführen, zu einem gründlicheren Debugging. Oft erfordert das Überprüfen selbst der wichtigsten positiven Anwendungsfälle über die Anwendungsoberfläche einen erheblichen Aufwand und Zeitaufwand. Das Vorhandensein von Tests ermöglicht es, eine detaillierte Überprüfung einer bestimmten Funktion viel einfacher und schneller durchzuführen.
Ein weiteres Plus ist die Möglichkeit, die Größe der getesteten Einheit zu regulieren. Abhängig von der Komplexität der zu testenden Logik können Sie sich auf eine Methode, eine Klasse, eine Gruppe von Klassen, die bestimmte Funktionen implementieren, einen Dienst usw. bis hin zur Automatisierung des Testens der gesamten Anwendung beschränken. Diese Flexibilität ermöglicht es Ihnen, Tests auf hoher Ebene von vielen Teilen zu entfernen, da diese auf niedrigeren Ebenen getestet werden.
Tests neu starten
Dieses Plus wird oft als die Essenz der Testautomatisierung bezeichnet, aber lassen Sie uns es aus einem weniger vertrauten Blickwinkel betrachten. Welche neuen Möglichkeiten eröffnen sich Entwicklern?
Erstens kann jeder neue Entwickler, der zum Projekt gekommen ist, problemlos vorhandene Tests ausführen, um die Anwendungslogik anhand von Beispielen zu verstehen. Leider wird die Bedeutung davon stark unterschätzt. Unter modernen Bedingungen arbeiten dieselben Leute selten länger als 1-2 Jahre an einem Projekt. Und da die Teams aus mehreren Personen bestehen, ist das Erscheinen eines neuen Teilnehmers alle 2-3 Monate eine typische Situation für relativ große Projekte. Besonders schwierige Projekte durchlaufen Schichten ganzer Entwicklergenerationen! Die Möglichkeit, einen beliebigen Teil der Anwendung einfach zu starten und das Verhalten des Systems zu überprüfen, vereinfacht das Eintauchen neuer Programmierer in das Projekt. Darüber hinaus reduziert eine detailliertere Untersuchung der Codelogik die Anzahl der am Ausgang gemachten Fehler und die Zeit, um sie in Zukunft zu debuggen.
Zweitens eröffnet die Möglichkeit, auf einfache Weise zu überprüfen, ob die Anwendung ordnungsgemäß funktioniert, den Weg für ein kontinuierliches Refactoring. Dieser Begriff ist leider viel weniger beliebt als CI. Dies bedeutet, dass das Refactoring jedes Mal durchgeführt werden kann und sollte, wenn der Code verfeinert wird. Es ist die regelmäßige Einhaltung der berüchtigten Pfadfinder-Regel „Verlassen Sie den Parkplatz sauberer als vor Ihrer Ankunft“, die eine Verschlechterung der Codebasis vermeidet und dem Projekt ein langes und glückliches Leben garantiert.
Debuggen
Das Debuggen wurde bereits in den vorhergehenden Absätzen erwähnt, aber dieser Punkt ist so wichtig, dass er näher betrachtet werden muss. Leider gibt es keine zuverlässige Möglichkeit, die Beziehung zwischen der Zeit, die für das Schreiben und Debuggen von Code aufgewendet wurde, zu messen, da diese Prozesse praktisch untrennbar miteinander verbunden sind. Trotzdem reduziert das Vorhandensein von Qualitätstests im Projekt die Debugging-Zeit erheblich, bis fast kein Debugger mehr ausgeführt werden muss.
Wirksamkeit
All dies kann beim ersten Debuggen des Codes erheblich Zeit sparen. Nur mit dem richtigen Ansatz werden alle zusätzlichen Entwicklungskosten bezahlt. Die verbleibenden Testboni - Verbesserung der Qualität der Codebasis (schlecht gestalteter Code ist schwer zu testen), Reduzierung der Anzahl von Fehlern, Möglichkeit, die Richtigkeit des Codes jederzeit zu überprüfen usw. - werden fast kostenlos.
Von der Theorie zur Praxis
In Worten, alles sieht gut aus, aber kommen wir zur Sache. Wie bereits erwähnt, gibt es mehr als genug Materialien für die anfängliche Einrichtung der Testumgebung. Deshalb fahren wir sofort mit dem fertigen Projekt fort.
Quellen hier.Herausforderung
Betrachten Sie als Vorlagenaufgabe ein kleines Fragment des Backends eines Online-Shops. Wir werden eine typische API für die Arbeit mit Produkten schreiben: Erstellen, Empfangen, Bearbeiten. Sowie einige Methoden für die Arbeit mit Kunden: Ändern eines „Lieblingsprodukts“ und Berechnen von Bonuspunkten für eine Bestellung.
Domänenmodell
Um das Beispiel nicht zu überladen, beschränken wir uns auf eine minimale Anzahl von Feldern und Klassen.
Der Kunde hat einen Benutzernamen, einen Link zu einem Lieblingsprodukt und eine Flagge, die angibt, ob er ein Premium-Kunde ist.
Produkt (Produkt) - Name, Preis, Rabatt und Flagge, die angeben, ob es derzeit beworben wird.
Projektstruktur
Die Struktur des Hauptprojektcodes ist wie folgt.
Klassen sind geschichtet:
- Modell - Domänenmodell des Projekts;
- Jpa - Repositories für die Arbeit mit Datenbanken basierend auf Spring Data;
- Service - Geschäftslogik der Anwendung;
- Controller - Controller, die die API implementieren.
Unit Test Struktur.
Testklassen befinden sich in denselben Paketen wie der ursprüngliche Code. Zusätzlich wurde ein Paket mit Buildern zur Aufbereitung von Testdaten erstellt, aber mehr dazu weiter unten.
Es ist bequem, Unit-Tests und Integrationstests zu trennen. Sie haben oft unterschiedliche Abhängigkeiten, und für eine komfortable Entwicklung sollte es die Möglichkeit geben, entweder die eine oder die andere auszuführen. Dies kann auf verschiedene Arten erreicht werden: Namenskonventionen, Module, Pakete, sourceSets. Die Wahl einer bestimmten Methode ist ausschließlich Geschmackssache. In diesem Projekt liegen Integrationstests in einem separaten sourceSet - IntegrationTest.
Klassen mit Integrationstests befinden sich wie Komponententests in denselben Paketen wie der ursprüngliche Code. Darüber hinaus gibt es Basisklassen, mit denen Konfigurationsduplikationen beseitigt werden können und die bei Bedarf nützliche universelle Methoden enthalten.
Integrationstests
Es gibt verschiedene Ansätze, mit welchen Tests es sich zu beginnen lohnt. Wenn die getestete Logik nicht sehr kompliziert ist, können Sie sofort mit den Integrationslogiken fortfahren (sie werden manchmal auch als Akzeptanzlogiken bezeichnet). Im Gegensatz zu Unit-Tests stellen sie sicher, dass die Anwendung insgesamt ordnungsgemäß funktioniert.
ArchitekturZunächst müssen Sie entscheiden, auf welcher Ebene Integrationsprüfungen durchgeführt werden sollen. Spring Boot bietet vollständige Wahlfreiheit: Sie können einen Teil des Kontexts, den gesamten Kontext und sogar einen vollwertigen Server, auf den über die Tests zugegriffen werden kann, aufrufen. Mit zunehmender Größe der Anwendung wird dieses Problem immer komplexer. Oft muss man verschiedene Tests auf verschiedenen Ebenen schreiben.
Ein guter Ausgangspunkt wären Controller-Tests ohne Start des Servers. In relativ kleinen Anwendungen ist es durchaus akzeptabel, den gesamten Kontext zu erhöhen, da er standardmäßig zwischen den Tests wiederverwendet und nur einmal initialisiert wird. Betrachten Sie die grundlegenden Methoden der
ProductController
Klasse:
@PostMapping("new") public Product createProduct(@RequestBody Product product) { return productService.createProduct(product); } @GetMapping("{productId}") public Product getProduct(@PathVariable("productId") long productId) { return productService.getProduct(productId); } @PostMapping("{productId}/edit") public void updateProduct(@PathVariable("productId") long productId, @RequestBody Product product) { productService.updateProduct(productId, product); }
Das Problem der Fehlerbehandlung bleibt unberücksichtigt. Angenommen, es wird extern implementiert, basierend auf einer Analyse der ausgelösten Ausnahmen. Der Code der Methoden ist sehr einfach, ihre Implementierung im
ProductService
nicht viel komplizierter:
@Transactional(readOnly = true) public Product getProduct(Long productId) { return productRepository.findById(productId) .orElseThrow(() -> new DataNotFoundException("Product", productId)); } @Transactional public Product createProduct(Product product) { return productRepository.save(new Product(product)); } @Transactional public Product updateProduct(Long productId, Product product) { Product dbProduct = productRepository.findById(productId) .orElseThrow(() -> new DataNotFoundException("Product", productId)); dbProduct.setPrice(product.getPrice()); dbProduct.setDiscount(product.getDiscount()); dbProduct.setName(product.getName()); dbProduct.setIsAdvertised(product.isAdvertised()); return productRepository.save(dbProduct); }
Das
ProductRepository
Repository enthält überhaupt keine eigenen Methoden:
public interface ProductRepository extends JpaRepository<Product, Long> { }
Alles deutet darauf hin, dass diese Klassen keine Komponententests benötigen, nur weil die gesamte Kette durch mehrere Integrationstests einfach und effizient überprüft werden kann. Das Duplizieren derselben Tests in verschiedenen Tests erschwert das Debuggen. Im Falle eines Fehlers im Code fällt jetzt nicht ein Test, sondern 10-15 auf einmal. Dies erfordert wiederum eine weitere Analyse. Wenn es keine Duplizierung gibt, zeigt der einzige Test wahrscheinlich sofort einen Fehler an.
KonfigurationDer
BaseControllerIT
markieren wir die Basisklasse
BaseControllerIT
, die die Spring-Konfiguration und einige Felder enthält:
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @Transactional public abstract class BaseControllerIT { @Autowired protected ProductRepository productRepository; @Autowired protected CustomerRepository customerRepository; }
Repositorys werden in die Basisklasse verschoben, um die Testklassen nicht zu überladen. Ihre Rolle ist ausschließlich eine Hilfsfunktion: Daten vorbereiten und den Status der Datenbank überprüfen, nachdem der Controller funktioniert. Wenn Sie die Größe der Anwendung erhöhen, ist dies möglicherweise nicht mehr praktisch, aber für den Anfang ist es durchaus geeignet.
Die Hauptkonfiguration von Spring wird durch die folgenden Zeilen definiert:
@SpringBootTest
- wird verwendet, um den Kontext der Anwendung
@SpringBootTest
.
WebEnvironment.NONE
bedeutet, dass kein
WebEnvironment.NONE
muss.
@Transactional
-
@Transactional
alle Klassentests in einer Transaktion mit automatischem Rollback, um den Status der Datenbank zu speichern.
TeststrukturKommen wir zu einer minimalistischen Reihe von Tests für die
ProductController
Klasse -
ProductControllerIT
.
@Test public void createProduct_productSaved() { Product product = product("productName").price("1.01").discount("0.1").advertised(true).build(); Product createdProduct = productController.createProduct(product); Product dbProduct = productRepository.getOne(createdProduct.getId()); assertEquals("productName", dbProduct.getName()); assertEquals(number("1.01"), dbProduct.getPrice()); assertEquals(number("0.1"), dbProduct.getDiscount()); assertEquals(true, dbProduct.isAdvertised()); }
Der Testcode sollte auf einen Blick äußerst einfach und verständlich sein. Ist dies nicht der Fall, gehen die meisten Vorteile der im ersten Abschnitt des Artikels beschriebenen Tests verloren. Es wird empfohlen, den Testkörper in drei Teile zu unterteilen, die visuell voneinander getrennt werden können: Daten vorbereiten, Testmethode aufrufen, Ergebnisse validieren. Gleichzeitig ist es sehr wünschenswert, dass der Testcode auf den gesamten Bildschirm passt.
Persönlich erscheint es mir offensichtlicher, wenn die Testwerte aus dem Abschnitt Datenaufbereitung später in den Prüfungen verwendet werden. Alternativ können Sie Objekte explizit vergleichen, zum Beispiel wie folgt:
assertEquals(product, dbProduct);
In einem anderen Test zum Aktualisieren von Produktinformationen (
updateProduct
) wird deutlich, dass die Erstellung von Daten etwas komplizierter geworden ist.
updateProduct
die visuelle Integrität der drei Teile des Tests zu
updateProduct
, werden sie durch zwei Zeilenvorschübe hintereinander getrennt:
@Test public void updateProduct_productUpdated() { Product product = product("productName").build(); productRepository.save(product); Product updatedProduct = product("updatedName").price("1.1").discount("0.5").advertised(true).build(); updatedProduct.setId(product.getId()); productController.updateProduct(product.getId(), updatedProduct); Product dbProduct = productRepository.getOne(product.getId()); assertEquals("updatedName", dbProduct.getName()); assertEquals(number("1.1"), dbProduct.getPrice()); assertEquals(number("0.5"), dbProduct.getDiscount()); assertEquals(true, dbProduct.isAdvertised()); }
Jeder der drei Teile des Teigs kann vereinfacht werden. Für die Datenaufbereitung eignen sich Testbuilder hervorragend, die die Logik zum Erstellen von Objekten enthalten, die für die Verwendung aus Tests geeignet ist. Zu komplexe Methodenaufrufe können zu Hilfsmethoden in Testklassen gemacht werden, wodurch einige der Parameter ausgeblendet werden, die für diese Klasse irrelevant sind. Um komplexe Prüfungen zu vereinfachen, können Sie auch Hilfsfunktionen schreiben oder eigene Matcher implementieren. Die Hauptsache bei all diesen Vereinfachungen ist, die Sichtbarkeit des Tests nicht zu verlieren: Alles sollte auf einen Blick auf die Hauptmethode klar sein, ohne tiefer gehen zu müssen.
TestbauerTestbauer verdienen besondere Aufmerksamkeit. Das Einkapseln der Logik zum Erstellen von Objekten vereinfacht die Testwartung. Insbesondere das Ausfüllen von Modellfeldern, die für diesen Test nicht relevant sind, kann im Builder ausgeblendet werden. Dazu müssen Sie es nicht direkt erstellen, sondern verwenden eine statische Methode, die die fehlenden Felder mit Standardwerten ausfüllt. Wenn beispielsweise neue erforderliche Felder im Modell angezeigt werden, können sie dieser Methode problemlos hinzugefügt werden. In
ProductBuilder
sieht es so aus:
public static ProductBuilder product(String name) { return new ProductBuilder() .name(name) .advertised(false) .price("0.00"); }
TestnameEs ist unbedingt zu verstehen, was in diesem Test speziell getestet wird. Aus Gründen der Klarheit ist es am besten, diese Frage im Titel zu beantworten. Berücksichtigen Sie bei Verwendung der Beispieltests für die Methode
getProduct
die verwendete Namenskonvention:
@Test public void getProduct_oneProductInDb_productReturned() { Product product = product("productName").build(); productRepository.save(product); Product result = productController.getProduct(product.getId()); assertEquals("productName", result.getName()); } @Test public void getProduct_twoProductsInDb_correctProductReturned() { Product product1 = product("product1").build(); Product product2 = product("product2").build(); productRepository.save(product1); productRepository.save(product2); Product result = productController.getProduct(product1.getId()); assertEquals("product1", result.getName()); }
Im allgemeinen Fall besteht die Überschrift der Testmethode aus drei Teilen, die durch Unterstreichung getrennt sind: dem Namen der zu testenden Methode, dem Skript und dem erwarteten Ergebnis. Es hat jedoch niemand den gesunden Menschenverstand aufgehoben, und es kann gerechtfertigt sein, einige Teile des Namens wegzulassen, wenn sie in diesem Zusammenhang nicht benötigt werden (z. B. ein Skript in einem einzelnen Test zum Erstellen eines Produkts). Mit dieser Benennung soll sichergestellt werden, dass die Essenz jedes Tests verständlich ist, ohne den Code zu lernen. Dies macht das Fenster der Testergebnisse so klar wie möglich und damit beginnt normalerweise die Arbeit mit Tests.
SchlussfolgerungenDas ist alles. Zum ersten Mal reicht ein minimaler Satz von vier Tests aus, um die Methoden der
ProductController
Klasse zu testen. Im Falle der Erkennung von Fehlern können Sie die fehlenden Tests jederzeit hinzufügen. Gleichzeitig reduziert die Mindestanzahl von Tests den Zeit- und Arbeitsaufwand für deren Unterstützung erheblich. Dies ist wiederum für den Implementierungsprozess des Testens von entscheidender Bedeutung, da die ersten Tests normalerweise nicht von bester Qualität sind und viele unerwartete Probleme verursachen. Gleichzeitig reicht eine solche Testsuite völlig aus, um die im ersten Teil des Artikels beschriebenen Boni zu erhalten.
Es ist erwähnenswert, dass solche Tests die Webebene der Anwendung nicht überprüfen, dies ist jedoch häufig nicht erforderlich. Bei Bedarf können Sie separate Tests für die
@WebMvcTest
mit einem Stub anstelle der Basis (
@WebMvcTest
,
MockMvc
,
@MockBean
)
@MockBean
oder einen vollwertigen Server verwenden. Letzteres kann das Debuggen und die Arbeit mit Transaktionen erschweren, da der Test die Transaktion des Servers nicht steuern kann. Ein Beispiel für einen solchen Integrationstest finden Sie in der
CustomerControllerServerIT
Klasse.
Unit-Tests
Unit-Tests haben gegenüber Integrationstests mehrere Vorteile:
- Der Start dauert Millisekunden.
- Kleine Größe der getesteten Einheit;
- Die Überprüfung einer großen Anzahl von Optionen ist einfach zu implementieren, da beim direkten Aufruf der Methode die Datenaufbereitung erheblich vereinfacht wird.
Trotzdem können Unit-Tests aufgrund ihrer Natur die Funktionsfähigkeit der gesamten Anwendung nicht garantieren und ermöglichen es Ihnen nicht, das Schreiben von Integrations-Tests zu vermeiden. Wenn die Logik der zu testenden Einheit einfach ist, bringt das Duplizieren von Integrationstests mit Komponententests keine Vorteile, sondern fügt nur mehr Code zur Unterstützung hinzu.
Die einzige Klasse in diesem Beispiel, die Unit-Tests verdient, ist der
BonusPointCalculator
. Sein Unterscheidungsmerkmal ist eine große Anzahl von Zweigen der Geschäftslogik. Beispielsweise wird angenommen, dass der Käufer einen Bonus von 10% der Produktkosten erhält, multipliziert mit nicht mehr als 2 Multiplikatoren aus der folgenden Liste:
- Das Produkt kostet mehr als 10.000 (× 4);
- Das Produkt nimmt an einer Werbekampagne teil (× 3);
- Das Produkt ist das „Lieblingsprodukt“ des Kunden (× 5);
- Der Kunde hat einen Premium-Status (× 2);
- Wenn der Kunde einen Premium-Status hat und ein „Lieblingsprodukt“ kauft, wird anstelle der beiden angegebenen Multiplikatoren einer (× 8) verwendet.
Im wirklichen Leben wäre es natürlich sinnvoll, einen flexiblen universellen Mechanismus zur Berechnung dieser Boni zu entwickeln, aber um das Beispiel zu vereinfachen, beschränken wir uns auf eine feste Implementierung. Der Multiplikator-Berechnungscode sieht folgendermaßen aus:
private List<BigDecimal> calculateMultipliers(Customer customer, Product product) { List<BigDecimal> multipliers = new ArrayList<>(); if (customer.getFavProduct() != null && customer.getFavProduct().equals(product)) { if (customer.isPremium()) { multipliers.add(PREMIUM_FAVORITE_MULTIPLIER); } else { multipliers.add(FAVORITE_MULTIPLIER); } } else if (customer.isPremium()) { multipliers.add(PREMIUM_MULTIPLIER); } if (product.isAdvertised()) { multipliers.add(ADVERTISED_MULTIPLIER); } if (product.getPrice().compareTo(EXPENSIVE_THRESHOLD) >= 0) { multipliers.add(EXPENSIVE_MULTIPLIER); } return multipliers; }
Eine Vielzahl von Optionen führt dazu, dass zwei oder drei Integrationstests hier nicht beschränkt sind. Ein minimalistischer Satz von Komponententests ist perfekt zum Debuggen solcher Funktionen.
Die entsprechende Testsuite finden Sie in der Klasse
BonusPointCalculatorTest
. Hier sind einige davon:
@Test public void calculate_oneProduct() { Product product = product("product").price("1.00").build(); Customer customer = customer("customer").build(); Map<Product, Long> quantities = mapOf(product, 1L); BigDecimal bonus = bonusPointCalculator.calculate(customer, list(product), quantities::get); BigDecimal expectedBonus = bonusPoints("0.10").build(); assertEquals(expectedBonus, bonus); } @Test public void calculate_favProduct() { Product product = product("product").price("1.00").build(); Customer customer = customer("customer").favProduct(product).build(); Map<Product, Long> quantities = mapOf(product, 1L); BigDecimal bonus = bonusPointCalculator.calculate(customer, list(product), quantities::get); BigDecimal expectedBonus = bonusPoints("0.10").addMultiplier(FAVORITE_MULTIPLIER).build(); assertEquals(expectedBonus, bonus); }
Es ist erwähnenswert, dass wir uns in den Tests speziell auf die öffentliche API der Klasse beziehen - die
calculate
. Durch das Testen eines Klassenvertrags anstelle seiner Implementierung werden Ausfälle von Tests aufgrund nicht funktionaler Änderungen und Refactoring vermieden.
Wenn wir schließlich die interne Logik mit Komponententests überprüft haben, müssen wir nicht mehr alle diese Details in die Integration einbeziehen. In diesem Fall reicht ein mehr oder weniger repräsentativer Test aus, zum Beispiel:
@Test public void calculateBonusPoints_twoProductTypes_correctValueCalculated() { Product product1 = product("product1").price("1.01").build(); Product product2 = product("product2").price("10.00").build(); productRepository.save(product1); productRepository.save(product2); Customer customer = customer("customer").build(); customerRepository.save(customer); Map<Long, Long> quantities = mapOf(product1.getId(), 1L, product2.getId(), 2L); BigDecimal bonus = customerController.calculateBonusPoints( new CalculateBonusPointsRequest("customer", quantities) ); BigDecimal bonusPointsProduct1 = bonusPoints("0.10").build(); BigDecimal bonusPointsProduct2 = bonusPoints("1.00").quantity(2).build(); BigDecimal expectedBonus = bonusPointsProduct1.add(bonusPointsProduct2); assertEquals(expectedBonus, bonus); }
Wie bei Integrationstests ist der verwendete Satz von Komponententests sehr klein und garantiert nicht die vollständige Richtigkeit der Anwendung. Trotzdem erhöht seine Präsenz das Vertrauen in den Code erheblich, erleichtert das Debuggen und gibt die anderen im ersten Teil des Artikels aufgeführten Boni.
Implementierungsempfehlungen
Ich hoffe, die vorherigen Abschnitte haben ausgereicht, um mindestens einen Entwickler davon zu überzeugen, Tests in seinem Projekt zu verwenden. In diesem Kapitel werden kurz die wichtigsten Empfehlungen aufgeführt, die dazu beitragen, schwerwiegende Probleme zu vermeiden und die anfänglichen Implementierungskosten zu senken.
Versuchen Sie, die Tests für die neue Anwendung zu implementieren. Das Schreiben der ersten Tests in einem großen Legacy-Projekt ist viel schwieriger und erfordert mehr Geschick als in einem frisch erstellten. Daher ist es nach Möglichkeit besser, mit einer kleinen neuen Anwendung zu beginnen. Wenn keine neuen vollwertigen Anwendungen erwartet werden, können Sie versuchen, ein nützliches Dienstprogramm für den internen Gebrauch zu entwickeln. Die Hauptsache ist, dass die Aufgabe mehr oder weniger realistisch sein sollte - erfundene Beispiele geben keine vollständige Erfahrung.
Richten Sie regelmäßige Testläufe ein. Wenn die Tests nicht regelmäßig ausgeführt werden, hören sie nicht nur auf, ihre Hauptfunktion auszuführen - die Richtigkeit des Codes zu überprüfen -, sondern sind auch schnell veraltet. Daher ist es äußerst wichtig, mindestens die minimale CI-Pipeline mit automatischem Teststart jedes Mal zu konfigurieren, wenn der Code im Repository aktualisiert wird.
Jagen Sie nicht die Abdeckung. Wie bei jeder anderen Technologie werden die Tests zunächst nicht in bester Qualität durchgeführt. Die einschlägige Literatur (Links am Ende des Artikels) oder ein kompetenter Mentor können hier helfen, aber dies macht die Notwendigkeit selbstfüllender Zapfen nicht zunichte. Tests in dieser Hinsicht ähneln dem Rest des Codes: Um zu verstehen, wie sie sich auf das Projekt auswirken, können Sie dies erst tun, nachdem Sie eine Weile mit ihnen gelebt haben. Um den Schaden zu minimieren, ist es daher beim ersten Mal besser, die Anzahl und die schönen Zahlen nicht wie eine hundertprozentige Abdeckung zu verfolgen. Stattdessen sollten Sie sich auf die wichtigsten positiven Szenarien für Ihre eigene Anwendungsfunktionalität beschränken.
Lassen Sie sich nicht von Unit-Tests mitreißen. In Fortsetzung des Themas „Quantität gegen Qualität“ sollte beachtet werden, dass ehrliche Komponententests nicht zum ersten Mal durchgeführt werden sollten, da dies leicht zu einer übermäßigen Spezifikation der Anwendung führen kann. Dies wird wiederum zu einem schwerwiegenden Hemmfaktor für spätere Refactoring- und Anwendungsverbesserungen. Unit-Tests sollten nur verwendet werden, wenn in einer bestimmten Klasse oder Gruppe von Klassen eine komplexe Logik vorhanden ist, deren Überprüfung auf Integrationsebene unpraktisch ist.
Lassen Sie sich nicht von Stub-Klassen und Anwendungsmethoden mitreißen. Stubs (Stub, Mock) ist ein weiteres Werkzeug, das einen ausgewogenen Ansatz und die Aufrechterhaltung eines Gleichgewichts erfordert. Einerseits können Sie sich durch die vollständige Isolierung des Geräts auf die getestete Logik konzentrieren und nicht an den Rest des Systems denken. Andererseits erfordert dies zusätzliche Entwicklungszeit und kann wie bei Unit-Tests zu einer übermäßigen Spezifikation des Verhaltens führen.
Lösen Sie die Integrationstests von externen Systemen. Ein sehr häufiger Fehler bei Integrationstests ist die Verwendung einer realen Datenbank, von Nachrichtenwarteschlangen und anderen Systemen außerhalb der Anwendung. Natürlich ist die Möglichkeit, einen Test in einer realen Umgebung auszuführen, für das Debuggen und die Entwicklung hilfreich. Solche Tests in kleinen Mengen können sinnvoll sein, insbesondere um interaktiv zu laufen. Ihre weit verbreitete Verwendung führt jedoch zu einer Reihe von Problemen:
- Um die Tests auszuführen, müssen Sie die externe Umgebung konfigurieren. Installieren Sie beispielsweise eine Datenbank auf jedem Computer, auf dem die Anwendung zusammengestellt wird. Dies erschwert es neuen Entwicklern, in das Projekt einzutreten und CI zu konfigurieren.
- Der Status externer Systeme kann auf verschiedenen Computern variieren, bevor die Tests ausgeführt werden. Beispielsweise kann die Datenbank bereits die Tabellen enthalten, die die Anwendung mit Daten benötigt, die im Test nicht erwartet werden. Dies führt zu unvorhersehbaren Fehlern bei den Tests, und ihre Beseitigung erfordert einen erheblichen Zeitaufwand.
- Wenn an mehreren Projekten parallel gearbeitet wird, ist der nicht offensichtliche Einfluss einiger Projekte auf andere möglich. Beispielsweise können bestimmte Datenbankeinstellungen, die für eines der Projekte vorgenommen wurden, dazu beitragen, dass die Funktionalität eines anderen Projekts ordnungsgemäß funktioniert. Diese Funktion wird jedoch unterbrochen, wenn sie auf einer sauberen Datenbank auf einem anderen Computer gestartet wird.
- Tests werden über einen langen Zeitraum durchgeführt: Ein vollständiger Lauf kann mehrere zehn Minuten dauern. Dies führt dazu, dass Entwickler die lokale Ausführung von Tests beenden und ihre Ergebnisse erst nach dem Senden der Änderungen an das Remote-Repository anzeigen. Dieses Verhalten negiert die meisten Vorteile der Tests, die im ersten Teil des Artikels erörtert wurden.
Löschen Sie den Kontext zwischen Integrationstests. Um die Arbeit von Integrationstests zu beschleunigen, müssen Sie häufig denselben Kontext zwischen ihnen wiederverwenden. Sogar die offizielle Frühlingsdokumentation gibt eine solche Empfehlung ab. Gleichzeitig sollte der Einfluss von Tests aufeinander vermieden werden. Da sie in beliebiger Reihenfolge gestartet werden, kann das Vorhandensein solcher Verbindungen zu zufälligen, nicht reproduzierbaren Fehlern führen. Um dies zu verhindern, sollten die Tests keine Änderungen im Kontext hinterlassen. Wenn Sie beispielsweise eine Datenbank verwenden, reicht es normalerweise zur Isolierung aus, alle im Test festgeschriebenen Transaktionen zurückzusetzen. Wenn Änderungen am Kontext nicht vermieden werden können, können Sie die Neuerstellung mithilfe der Annotation
@DirtiesContext
konfigurieren.
, . , - . , . , , — , .
. , , . , , .
TDD (Test-Driven Development). TDD , , . , , . , , .
, ?
, :
- ( )? .
- , ( , CI)? .
- ? .
- ? . , , .
, . , , - . — .
Fazit
, . - , . , - . — , , -. , .
, , , !
GitHub