Um eine ausreichende Codeabdeckung zu haben und neue Funktionen zu erstellen und alte zu überarbeiten, ohne befürchten zu müssen, dass etwas kaputt geht, müssen die Tests wartbar und leicht lesbar sein. In diesem Artikel werde ich über viele Techniken zum Schreiben von Unit- und Integrationstests in Java sprechen, die ich im Laufe der Jahre gesammelt habe. Ich werde mich auf moderne Technologien verlassen: JUnit5, AssertJ, Testcontainer, und ich werde Kotlin auch nicht ignorieren. Einige der Tipps werden Ihnen offensichtlich erscheinen, während andere möglicherweise gegen das verstoßen, was Sie in Büchern über Softwareentwicklung und -tests gelesen haben.
In einer Nussschale
- Schreiben Sie Tests präzise und spezifisch mit Hilfe von Hilfsfunktionen, Parametrisierung und verschiedenen Grundelementen der AssertJ-Bibliothek, missbrauchen Sie keine Variablen, überprüfen Sie nur, was mit der getesteten Funktionalität zusammenhängt, und kleben Sie nicht alle nicht standardmäßigen Fälle in einen Test
- , ,
- , -,
- KISS DRY
- , , , in-memory-
- JUnit5 AssertJ —
- : , , Clock - .
Given, When, Then (, , )
Der Test sollte drei Blöcke enthalten, die durch Leerzeilen getrennt sind. Jeder Block sollte so kurz wie möglich sein. Verwenden Sie lokale Methoden, um die Dinge kompakt zu halten.
Gegeben / Gegeben (Eingabe): Testvorbereitung, zum Beispiel Datenerstellung und Verspottungskonfiguration.
Wann (Aktion): Rufen Sie die getestete Methode auf.
Dann / Bis (Ausgabe): Überprüfen Sie die Richtigkeit des empfangenen Werts
//
@Test
public void findProduct() {
insertIntoDatabase(new Product(100, "Smartphone"));
Product product = dao.findProduct(100);
assertThat(product.getName()).isEqualTo("Smartphone");
}
Verwenden Sie die Präfixe "Ist *" und "Erwartet *".
//
ProductDTO product1 = requestProduct(1);
ProductDTO product2 = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(product1).isEqualTo(product2);
Wenn Sie Variablen in einem Übereinstimmungstest verwenden möchten, fügen Sie diesen Variablen die Präfixe "tatsächlich" und "erwartet" hinzu. Dies verbessert die Lesbarkeit Ihres Codes und verdeutlicht den Zweck der Variablen. Es macht es auch schwieriger, sie beim Vergleichen zu verwirren.
//
ProductDTO actualProduct = requestProduct(1);
ProductDTO expectedProduct = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(actualProduct).isEqualTo(expectedProduct); //
Verwenden Sie voreingestellte Werte anstelle von zufälligen
Vermeiden Sie es, zufällige Werte für Testeingaben einzugeben. Dies kann zu blinkenden Tests führen, die verdammt schwer zu debuggen sind. Wenn in einer Fehlermeldung ein zufälliger Wert angezeigt wird, können Sie ihn nicht bis zu dem Ort zurückverfolgen, an dem der Fehler aufgetreten ist.
//
Instant ts1 = Instant.now(); // 1557582788
Instant ts2 = ts1.plusSeconds(1); // 1557582789
int randomAmount = new Random().nextInt(500); // 232
UUID uuid = UUID.randomUUID(); // d5d1f61b-0a8b-42be-b05a-bd458bb563ad
Verwenden Sie für alles unterschiedliche vordefinierte Werte. Auf diese Weise erhalten Sie perfekt reproduzierbare Testergebnisse und finden durch die Fehlermeldung schnell die richtige Stelle im Code.
//
Instant ts1 = Instant.ofEpochSecond(1550000001);
Instant ts2 = Instant.ofEpochSecond(1550000002);
int amount = 50;
UUID uuid = UUID.fromString("00000000-000-0000-0000-000000000001");
Sie können es mit Hilfe von Hilfsfunktionen noch kürzer schreiben (siehe unten).
Schreiben Sie präzise und spezifische Tests
Verwenden Sie nach Möglichkeit Hilfsfunktionen
Isolieren Sie sich wiederholenden Code in lokale Funktionen und geben Sie ihnen aussagekräftige Namen. So bleiben Ihre Tests kompakt und auf einen Blick leicht lesbar.
//
@Test
public void categoryQueryParameter() throws Exception {
List<ProductEntity> products = List.of(
new ProductEntity().setId("1").setName("Envelope").setCategory("Office").setDescription("An Envelope").setStockAmount(1),
new ProductEntity().setId("2").setName("Pen").setCategory("Office").setDescription("A Pen").setStockAmount(1),
new ProductEntity().setId("3").setName("Notebook").setCategory("Hardware").setDescription("A Notebook").setStockAmount(2)
);
for (ProductEntity product : products) {
template.execute(createSqlInsertStatement(product));
}
String responseJson = client.perform(get("/products?category=Office"))
.andExpect(status().is(200))
.andReturn().getResponse().getContentAsString();
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("1", "2");
}
//
@Test
public void categoryQueryParameter2() throws Exception {
insertIntoDatabase(
createProductWithCategory("1", "Office"),
createProductWithCategory("2", "Office"),
createProductWithCategory("3", "Hardware")
);
String responseJson = requestProductsByCategory("Office");
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("1", "2");
}
- Verwenden Sie Hilfsfunktionen, um Daten (Objekte) (
createProductWithCategory()) und komplexe Prüfungen zu erstellen . Übergeben Sie nur die Parameter an die Hilfsfunktionen, die für diesen Test relevant sind. Verwenden Sie im Übrigen angemessene Standardeinstellungen. In Kotlin gibt es hierfür Standardparameterwerte, und in Java können Sie Methodenaufrufketten und Überladung verwenden, um Standardparameter zu simulieren. - Die Parameterliste mit variabler Länge macht Ihren Code noch eleganter (
ìnsertIntoDatabase()) - Hilfsfunktionen können auch verwendet werden, um einfache Werte zu erstellen. Kotlin macht es noch besser durch Erweiterungsfunktionen.
// (Java)
Instant ts = toInstant(1); // Instant.ofEpochSecond(1550000001)
UUID id = toUUID(1); // UUID.fromString("00000000-0000-0000-a000-000000000001")
// (Kotlin)
val ts = 1.toInstant()
val id = 1.toUUID()
Hilfsfunktionen in Kotlin können folgendermaßen implementiert werden:
fun Int.toInstant(): Instant = Instant.ofEpochSecond(this.toLong())
fun Int.toUUID(): UUID = UUID.fromString("00000000-0000-0000-a000-${this.toString().padStart(11, '0')}")
Variablen nicht überbeanspruchen
Der konditionierte Reflex des Programmierers besteht darin, häufig verwendete Werte in Variablen zu verschieben.
//
@Test
public void variables() throws Exception {
String relevantCategory = "Office";
String id1 = "4243";
String id2 = "1123";
String id3 = "9213";
String irrelevantCategory = "Hardware";
insertIntoDatabase(
createProductWithCategory(id1, relevantCategory),
createProductWithCategory(id2, relevantCategory),
createProductWithCategory(id3, irrelevantCategory)
);
String responseJson = requestProductsByCategory(relevantCategory);
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly(id1, id2);
}
Leider ist dies sehr Code-Überladung. Schlimmer noch, wenn Sie den Wert in der Fehlermeldung sehen, können Sie nicht zurückverfolgen, wo der Fehler aufgetreten ist.
"KISS ist wichtiger als DRY"
//
@Test
public void variables() throws Exception {
insertIntoDatabase(
createProductWithCategory("4243", "Office"),
createProductWithCategory("1123", "Office"),
createProductWithCategory("9213", "Hardware")
);
String responseJson = requestProductsByCategory("Office");
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("4243", "1123");
}
Wenn Sie versuchen, Tests so kompakt wie möglich zu schreiben (was ich ohnehin wärmstens empfehle), sind die wiederverwendeten Werte deutlich sichtbar. Der Code selbst wird kompakter und lesbarer. Schließlich führt Sie die Fehlermeldung zu der genauen Zeile, in der der Fehler aufgetreten ist.
Erweitern Sie vorhandene Tests nicht, um "noch eine Kleinigkeit hinzuzufügen".
//
public class ProductControllerTest {
@Test
public void happyPath() {
// ...
}
}
Es ist immer verlockend, einem vorhandenen Test einen Sonderfall hinzuzufügen, der die Grundfunktionalität überprüft. Infolgedessen werden die Tests jedoch größer und schwerer zu verstehen. Bestimmte Fälle, die über ein großes Codeblatt verteilt sind, sind leicht zu übersehen. Wenn der Test fehlschlägt, verstehen Sie möglicherweise nicht sofort, was genau ihn verursacht hat.
//
public class ProductControllerTest {
@Test
public void multipleProductsAreReturned() {}
@Test
public void allProductValuesAreReturned() {}
@Test
public void filterByCategory() {}
@Test
public void filterByDateCreated() {}
}
Schreiben Sie stattdessen einen neuen Test mit einem beschreibenden Namen, der sofort klar macht, welches Verhalten von dem zu testenden Code erwartet wird. Ja, Sie müssen mehr Buchstaben auf der Tastatur eingeben (ich möchte Sie daran erinnern, dass Hilfsfunktionen gut helfen), aber Sie erhalten einen einfachen und verständlichen Test mit einem vorhersehbaren Ergebnis. Dies ist übrigens eine großartige Möglichkeit, neue Funktionen zu dokumentieren.
Überprüfen Sie nur, was Sie testen möchten
Denken Sie an die Funktionalität, die Sie testen. Vermeiden Sie unnötige Überprüfungen, nur weil Sie können. Denken Sie außerdem daran, was bereits in zuvor geschriebenen Tests getestet wurde, und testen Sie es nicht erneut. Die Tests sollten kompakt sein und ihr erwartetes Verhalten sollte offensichtlich sein und keine unnötigen Details enthalten.
Angenommen, wir möchten ein HTTP-Handle testen, das eine Liste von Produkten zurückgibt. Unsere Testsuite sollte die folgenden Tests enthalten:
1. Ein großer Zuordnungstest, der überprüft, ob alle Werte aus der Datenbank in der JSON-Antwort korrekt zurückgegeben und im richtigen Format korrekt zugewiesen wurden. Wir können dies einfach mit den Funktionen
isEqualTo()(für ein einzelnes Element) oder containsOnly()(für mehrere Elemente) aus dem AssertJ-Paket schreiben, wenn Sie die Methode korrekt implementierenequals()...
String responseJson = requestProducts();
ProductDTO expectedDTO1 = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED));
ProductDTO expectedDTO2 = new ProductDTO("2", "envelope", new Category("smartphone"), List.of(States.ACTIVE));
assertThat(toDTOs(responseJson))
.containsOnly(expectedDTO1, expectedDTO2);
2. Mehrere Tests, die das korrekte Verhalten des Parameters? Category überprüfen. Hier möchten wir nur überprüfen, ob die Filter ordnungsgemäß funktionieren, nicht die Eigenschaftswerte, da wir dies zuvor getan haben. Daher reicht es für uns aus, die Übereinstimmungen der erhaltenen Produkt-IDs zu überprüfen:
String responseJson = requestProductsByCategory("Office");
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("1", "2");
3. Ein paar weitere Tests, bei denen Sonderfälle oder spezielle Geschäftslogik überprüft werden, z. B. ob bestimmte Werte in der Antwort korrekt berechnet wurden. In diesem Fall interessieren uns nur einige Felder aus der gesamten JSON-Antwort. Daher dokumentieren wir diese spezielle Logik mit unserem Test. Es ist klar, dass wir hier nichts anderes als diese Felder brauchen.
assertThat(actualProduct.getPrice()).isEqualTo(100);
In sich geschlossene Tests
Verstecken Sie keine relevanten Parameter (in Hilfsfunktionen)
//
insertIntoDatabase(createProduct());
List<ProductDTO> actualProducts = requestProductsByCategory();
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));
Es ist praktisch, Hilfsfunktionen zum Generieren von Daten und Überprüfen von Bedingungen zu verwenden, diese müssen jedoch mit Parametern aufgerufen werden. Akzeptieren Sie Parameter für alles, was im Test von Bedeutung ist und über den Testcode gesteuert werden muss. Zwingen Sie den Leser nicht, in die Hilfsfunktion zu springen, um die Bedeutung des Tests zu verstehen. Eine einfache Regel: Die Bedeutung des Tests sollte klar sein, wenn Sie den Test selbst betrachten.
//
insertIntoDatabase(createProduct("1", "Office"));
List<ProductDTO> actualProducts = requestProductsByCategory("Office");
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));
Bewahren Sie die Testdaten in den Tests selbst auf
Alles sollte drinnen sein. Es ist verlockend, einige der Daten in eine Methode zu übertragen
@Beforeund von dort wiederzuverwenden. Dies zwingt den Leser jedoch dazu, durch die Datei zu springen, um zu verstehen, was genau hier passiert. Auch hier helfen Ihnen die Hilfsfunktionen, Wiederholungen zu vermeiden und Ihre Tests verständlicher zu machen.
Verwenden Sie Komposition anstelle von Vererbung
Erstellen Sie keine komplexen Testklassenhierarchien.
//
class SimpleBaseTest {}
class AdvancedBaseTest extends SimpleBaseTest {}
class AllInklusiveBaseTest extends AdvancedBaseTest {}
class MyTest extends AllInklusiveBaseTest {}
Solche Hierarchien erschweren das Verständnis, und Sie werden höchstwahrscheinlich schnell den nächsten Nachfolger des Basistests schreiben, in dem viel Müll vernäht ist, den der aktuelle Test überhaupt nicht benötigt. Dies lenkt den Leser ab und führt zu subtilen Fehlern. Vererbung ist nicht flexibel: Glauben Sie, dass Sie alle Methoden einer Klasse verwenden können
AllInclusiveBaseTest, aber keine ihrer Eltern ? AdvancedBaseTest?Außerdem muss der Leser ständig zwischen verschiedenen Basisklassen wechseln, um das Gesamtbild zu verstehen.
„Es ist besser, Code zu duplizieren, als die falsche Abstraktion zu wählen“ (Sandi Metz)
Ich empfehle stattdessen die Komposition. Schreiben Sie kleine Snippets und Klassen für jede fixture-bezogene Aufgabe (starten Sie eine Testdatenbank, erstellen Sie ein Schema, fügen Sie Daten ein, starten Sie einen Mock-Server). Verwenden Sie diese Teile in einer Methode
@BeforeAlloder indem Sie die erstellten Objekte den Feldern der Testklasse zuweisen. Auf diese Weise können Sie jede neue Testklasse aus diesen Leerzeichen wie aus Lego-Teilen erstellen. Infolgedessen hat jeder Test seine eigenen verständlichen Vorrichtungen und stellt sicher, dass nichts Außergewöhnliches darin passiert. Der Test wird autark, da er alles enthält, was Sie brauchen.
//
public class MyTest {
//
private JdbcTemplate template;
private MockWebServer taxService;
@BeforeAll
public void setupDatabaseSchemaAndMockWebServer() throws IOException {
this.template = new DatabaseFixture().startDatabaseAndCreateSchema();
this.taxService = new MockWebServer();
taxService.start();
}
}
//
public class DatabaseFixture {
public JdbcTemplate startDatabaseAndCreateSchema() throws IOException {
PostgreSQLContainer db = new PostgreSQLContainer("postgres:11.2-alpine");
db.start();
DataSource dataSource = DataSourceBuilder.create()
.driverClassName("org.postgresql.Driver")
.username(db.getUsername())
.password(db.getPassword())
.url(db.getJdbcUrl())
.build();
JdbcTemplate template = new JdbcTemplate(dataSource);
SchemaCreator.createSchema(template);
return template;
}
}
Noch einmal:
"KISS ist wichtiger als DRY"
Einfache Tests sind gut. Vergleichen Sie das Ergebnis mit Konstanten
Produktionscode nicht wiederverwenden
Tests sollten den Produktionscode validieren und nicht wiederverwenden. Wenn Sie den Kampfcode in einem Test wiederverwenden, übersehen Sie möglicherweise einen Fehler in diesem Code, weil Sie ihn nicht mehr testen.
//
boolean isActive = true;
boolean isRejected = true;
insertIntoDatabase(new Product(1, isActive, isRejected));
ProductDTO actualDTO = requestProduct(1);
//
List<State> expectedStates = ProductionCode.mapBooleansToEnumList(isActive, isRejected);
assertThat(actualDTO.states).isEqualTo(expectedStates);
Denken Sie stattdessen beim Schreiben von Tests an Eingabe und Ausgabe. Der Test speist Daten in die Eingabe ein und vergleicht die Ausgabe mit vordefinierten Konstanten. In den meisten Fällen ist die Wiederverwendung von Code nicht erforderlich.
// Do
assertThat(actualDTO.states).isEqualTo(List.of(States.ACTIVE, States.REJECTED));
Kopieren Sie keine Geschäftslogik in Tests
Die Objektzuordnung ist ein Paradebeispiel für einen Fall, in dem Tests Logik aus dem Kampfcode in sich ziehen. Angenommen, unser Test enthält eine Methode
mapEntityToDto(), mit deren Ergebnis überprüft wird, ob das resultierende DTO dieselben Werte enthält wie die Elemente, die zu Beginn des Tests zur Basis hinzugefügt wurden. In diesem Fall kopieren Sie höchstwahrscheinlich den Kampfcode in den Test, der Fehler enthalten kann.
//
ProductEntity inputEntity = new ProductEntity(1, "envelope", "office", false, true, 200, 10.0);
insertIntoDatabase(input);
ProductDTO actualDTO = requestProduct(1);
// mapEntityToDto() , -
ProductDTO expectedDTO = mapEntityToDto(inputEntity);
assertThat(actualDTO).isEqualTo(expectedDTO);
Die richtige Lösung besteht darin
actualDTO, es mit einem manuell erstellten Referenzobjekt mit den angegebenen Werten zu vergleichen. Es ist extrem einfach, unkompliziert und schützt vor möglichen Fehlern.
//
ProductDTO expectedDTO = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED))
assertThat(actualDTO).isEqualTo(expectedDTO);
Wenn Sie nicht für ein gesamtes Referenzobjekt eine Übereinstimmung erstellen und prüfen möchten, können Sie das untergeordnete Objekt oder im Allgemeinen nur die Eigenschaften des Objekts überprüfen, die für den Test relevant sind.
Schreibe nicht zu viel Logik
Ich möchte Sie daran erinnern, dass es beim Testen hauptsächlich um Eingabe und Ausgabe geht. Senden Sie Daten und überprüfen Sie, was an Sie zurückgegeben wird. Es ist nicht erforderlich, komplexe Logik in die Tests zu schreiben. Wenn Sie Schleifen und Bedingungen in einen Test einführen, machen Sie ihn weniger verständlich und fehleranfälliger. Wenn Ihre Validierungslogik komplex ist, verwenden Sie die vielen AssertJ-Funktionen, um die Arbeit für Sie zu erledigen.
Führen Sie Tests in einer kampfähnlichen Umgebung durch
Testen Sie das größtmögliche Komponentenbündel
Es wird allgemein empfohlen, jede Klasse einzeln mit Mocks zu testen. Dieser Ansatz hat jedoch Nachteile: Auf diese Weise wird die Interaktion von Klassen untereinander nicht getestet, und jede Umgestaltung allgemeiner Entitäten unterbricht alle Tests auf einmal, da jede innere Klasse ihre eigenen Tests hat. Wenn Sie außerdem Tests für jede Klasse schreiben, gibt es einfach zu viele davon.
Isolierte Einheitentests jeder Klasse
Stattdessen empfehle ich, mich auf Integrationstests zu konzentrieren. Mit "Integrationstest" meine ich das Sammeln aller Klassen zusammen (wie in der Produktion) und das Testen des gesamten Bundles einschließlich der Infrastrukturkomponenten (HTTP-Server, Datenbank, Geschäftslogik). In diesem Fall testen Sie das Verhalten anstelle der Implementierung. Solche Tests sind genauer, näher an der realen Welt und beständig gegen Refactoring interner Komponenten. Im Idealfall reicht eine Testklasse aus.
Integrationstest (= alle Klassen zusammenfügen und das Bundle testen)
Verwenden Sie keine In-Memory-Datenbanken für Tests
Mit einer In-Memory-Basis testen Sie in einer anderen Umgebung, in der Ihr Code funktioniert.
Wenn Sie eine In-Memory-Basis ( H2 , HSQLDB , Fongo ) für Tests verwenden, opfern Sie deren Gültigkeit und Umfang. Solche Datenbanken verhalten sich oft unterschiedlich und führen zu unterschiedlichen Ergebnissen. Ein solcher Test kann erfolgreich bestanden werden, garantiert jedoch nicht den ordnungsgemäßen Betrieb der Anwendung in der Produktion. Darüber hinaus können Sie sich leicht in einer Situation befinden, in der Sie ein Verhalten oder Merkmal Ihrer Basis nicht verwenden oder testen können, da diese nicht in der In-Memory-Datenbank implementiert sind oder sich anders verhalten.
Lösung: Verwenden Sie dieselbe Datenbank wie im realen Betrieb. Wunderbare Testcontainer- Bibliothek bietet eine umfangreiche API für Java-Anwendungen, mit der Sie Container direkt aus Ihrem Testcode verwalten können.
Java / JVM
Verwenden -noverify -XX:TieredStopAtLevel=1
Fügen Sie
JVM -noverify -XX:TieredStopAtLevel=1Ihrer Konfiguration immer Optionen hinzu , um Tests auszuführen. Dies erspart Ihnen 1-2 Sekunden beim Starten der virtuellen Maschine, bevor die Tests beginnen. Dies ist besonders in den frühen Tagen Ihrer Tests nützlich, wenn Sie sie häufig über die IDE ausführen.
Bitte beachten Sie, dass Java 13
-noverifyveraltet ist.
Tipp: Fügen Sie diese Argumente der Konfigurationsvorlage „JUnit“ in IntelliJ IDEA hinzu, damit Sie dies nicht jedes Mal tun müssen, wenn Sie ein neues Projekt erstellen.
Verwenden Sie AssertJ
AssertJ ist eine äußerst leistungsstarke und ausgereifte Bibliothek mit einer umfangreichen und sicheren API sowie einer Vielzahl von Funktionen zur Wertüberprüfung und informativen Testfehlermeldungen. Viele praktische Validierungsfunktionen entlasten den Programmierer von der Notwendigkeit, komplexe Logik im Hauptteil der Tests zu beschreiben, sodass die Tests präzise sein können. Zum Beispiel:
assertThat(actualProduct)
.isEqualToIgnoringGivenFields(expectedProduct, "id");
assertThat(actualProductList).containsExactly(
createProductDTO("1", "Smartphone", 250.00),
createProductDTO("1", "Smartphone", 250.00)
);
assertThat(actualProductList)
.usingElementComparatorIgnoringFields("id")
.containsExactly(expectedProduct1, expectedProduct2);
assertThat(actualProductList)
.extracting(Product::getId)
.containsExactly("1", "2");
assertThat(actualProductList)
.anySatisfy(product -> assertThat(product.getDateCreated()).isBetween(instant1, instant2));
assertThat(actualProductList)
.filteredOn(product -> product.getCategory().equals("Smartphone"))
.allSatisfy(product -> assertThat(product.isLiked()).isTrue());
Vermeiden Sie die Verwendung von assertTrue()undassertFalse()
Die Verwendung von einfachen
assertTrue()oder assertFalse()führt zu kryptischen Testfehlermeldungen:
//
assertTrue(actualProductList.contains(expectedProduct));
assertTrue(actualProductList.size() == 5);
assertTrue(actualProduct instanceof Product);
expected: <true> but was: <false>
Verwenden Sie stattdessen AssertJ-Aufrufe, die sofort klare und informative Nachrichten zurückgeben.
//
assertThat(actualProductList).contains(expectedProduct);
assertThat(actualProductList).hasSize(5);
assertThat(actualProduct).isInstanceOf(Product.class);
Expecting:
<[Product[id=1, name='Samsung Galaxy']]>
to contain:
<[Product[id=2, name='iPhone']]>
but could not find:
<[Product[id=2, name='iPhone']]>
Wenn Sie den booleschen Wert überprüfen müssen, machen Sie die Nachricht mit der
as()AssertJ- Methode aussagekräftiger.
Verwenden Sie JUnit5
JUnit5 ist eine hervorragende Bibliothek für (Unit-) Tests. Es wird ständig weiterentwickelt und bietet dem Programmierer viele nützliche Funktionen wie parametrisierte Tests, Gruppierungen, bedingte Tests und Lebenszyklussteuerung.
Verwenden Sie parametrisierte Tests
Mit parametrisierten Tests können Sie denselben Test mit verschiedenen Eingabewerten ausführen. Auf diese Weise können Sie mehrere Fälle überprüfen, ohne zusätzlichen Code schreiben zu müssen. In JUnit5 hierfür ist die hervorragenden Werkzeuge
@ValueSource, @EnumSource, @CsvSourceund @MethodSource.
//
@ParameterizedTest
@ValueSource(strings = ["§ed2d", "sdf_", "123123", "§_sdf__dfww!"])
public void rejectedInvalidTokens(String invalidToken) {
client.perform(get("/products").param("token", invalidToken))
.andExpect(status().is(400))
}
@ParameterizedTest
@EnumSource(WorkflowState::class, mode = EnumSource.Mode.INCLUDE, names = ["FAILED", "SUCCEEDED"])
public void dontProcessWorkflowInCaseOfAFinalState(WorkflowState itemsInitialState) {
// ...
}
Ich empfehle dringend, diese Technik maximal zu verwenden, da Sie so mehr Fälle mit minimalem Aufwand testen können.
Abschließend möchte ich Ihre Aufmerksamkeit auf
@CsvSourceund lenken @MethodSource, das für eine komplexere Parametrisierung verwendet werden kann, bei der Sie auch das Ergebnis steuern müssen: Sie können es in einem der Parameter übergeben.
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"5, 3, 8",
"10, -20, -10"
})
public void add(int summand1, int summand2, int expectedSum) {
assertThat(calculator.add(summand1, summand2)).isEqualTo(expectedSum);
}
@MethodSourceBesonders effektiv in Verbindung mit einem separaten Testobjekt, das alle gewünschten Parameter und erwarteten Ergebnisse enthält. Leider ist die Beschreibung solcher Datenstrukturen (sogenannte POJOs) in Java sehr umständlich. Daher werde ich ein Beispiel mit Kotlin-Datenklassen geben.
data class TestData(
val input: String?,
val expected: Token?
)
@ParameterizedTest
@MethodSource("validTokenProvider")
fun `parse valid tokens`(data: TestData) {
assertThat(parse(data.input)).isEqualTo(data.expected)
}
private fun validTokenProvider() = Stream.of(
TestData(input = "1511443755_2", expected = Token(1511443755, "2")),
TestData(input = "151175_13521", expected = Token(151175, "13521")),
TestData(input = "151144375_id", expected = Token(151144375, "id")),
TestData(input = "15114437599_1", expected = Token(15114437599, "1")),
TestData(input = null, expected = null)
)
Gruppentests
Annotation
@Nestedvon JUnit5 ist praktisch zum Gruppieren von Testmethoden. Logischerweise ist es sinnvoll zu gruppieren bestimmte Arten von Tests (wie InputIsXY, ErrorCases) oder in der Gruppe jeder Testmethoden (zu sammeln GetDesignund UpdateDesign).
public class DesignControllerTest {
@Nested
class GetDesigns {
@Test
void allFieldsAreIncluded() {}
@Test
void limitParameter() {}
@Test
void filterParameter() {}
}
@Nested
class DeleteDesign {
@Test
void designIsRemovedFromDb() {}
@Test
void return404OnInvalidIdParameter() {}
@Test
void return401IfNotAuthorized() {}
}
}
Lesbare Testnamen mit @DisplayNameoder Backquotes in Kotlin
In Java können Sie Anmerkungen verwenden
@DisplayName, um Ihren Tests besser lesbare Namen zu geben.
public class DisplayNameTest {
@Test
@DisplayName("Design is removed from database")
void designIsRemoved() {}
@Test
@DisplayName("Return 404 in case of an invalid parameter")
void return404() {}
@Test
@DisplayName("Return 401 if the request is not authorized")
void return401() {}
}
In Kotlin können Sie Funktionsnamen mit Leerzeichen verwenden, indem Sie sie in einfache Anführungszeichen setzen. Auf diese Weise erhalten Sie Lesbarkeit der Ergebnisse ohne Code-Redundanz.
@Test
fun `design is removed from db`() {}
Externe Dienste simulieren
Um HTTP-Clients zu testen, müssen wir die Dienste simulieren, auf die sie zugreifen. Ich benutze oft MockWebServer von OkHttp für diesen Zweck . Alternativen sind WireMock oder Mockserver von Testcontainern .
MockWebServer serviceMock = new MockWebServer();
serviceMock.start();
HttpUrl baseUrl = serviceMock.url("/v1/");
ProductClient client = new ProductClient(baseUrl.host(), baseUrl.port());
serviceMock.enqueue(new MockResponse()
.addHeader("Content-Type", "application/json")
.setBody("{\"name\": \"Smartphone\"}"));
ProductDTO productDTO = client.retrieveProduct("1");
assertThat(productDTO.getName()).isEqualTo("Smartphone");
Verwenden Sie die Wartbarkeit, um asynchronen Code zu testen
Awaitility ist eine Bibliothek zum Testen von asynchronem Code. Sie können angeben, wie oft versucht werden soll, das Ergebnis zu überprüfen, bevor ein Test für nicht erfolgreich erklärt wird.
private static final ConditionFactory WAIT = await()
.atMost(Duration.ofSeconds(6))
.pollInterval(Duration.ofSeconds(1))
.pollDelay(Duration.ofSeconds(1));
@Test
public void waitAndPoll(){
triggerAsyncEvent();
WAIT.untilAsserted(() -> {
assertThat(findInDatabase(1).getState()).isEqualTo(State.SUCCESS);
});
}
Keine Notwendigkeit, DI-Abhängigkeiten aufzulösen (Spring)
Die Initialisierung des DI-Frameworks dauert einige Sekunden, bevor die Tests gestartet werden können. Dies verlangsamt die Rückkopplungsschleife, insbesondere in den frühen Entwicklungsstadien.
Daher versuche ich, DI nicht in Integrationstests zu verwenden, sondern die erforderlichen Objekte manuell zu erstellen und sie "zusammenzubinden". Wenn Sie die Konstruktorinjektion verwenden, ist dies am einfachsten. In der Regel validieren Sie in Ihren Tests die Geschäftslogik und benötigen dafür kein DI.
Darüber hinaus unterstützt Spring Boot seit Version 2.2 die verzögerte Initialisierung von Beans, wodurch Tests mit DI erheblich beschleunigt werden.
Ihr Code muss testbar sein
Verwenden Sie keinen statischen Zugriff. noch nie
Statischer Zugriff ist ein Anti-Pattern. Erstens werden Abhängigkeiten und Nebenwirkungen verschleiert, wodurch der gesamte Code schwer lesbar und anfällig für subtile Fehler wird. Zweitens behindert der statische Zugriff das Testen. Sie können keine Objekte mehr ersetzen, aber in Tests müssen Sie Mocks oder reale Objekte mit einer anderen Konfiguration verwenden (z. B. ein DAO-Objekt, das auf die Testdatenbank verweist).
Anstatt statisch auf den Code zuzugreifen, fügen Sie ihn in eine nicht statische Methode ein, instanziieren Sie die Klasse und übergeben Sie das resultierende Objekt an den Konstruktor.
//
public class ProductController {
public List<ProductDTO> getProducts() {
List<ProductEntity> products = ProductDAO.getProducts();
return mapToDTOs(products);
}
}
//
public class ProductController {
private ProductDAO dao;
public ProductController(ProductDAO dao) {
this.dao = dao;
}
public List<ProductDTO> getProducts() {
List<ProductEntity> products = dao.getProducts();
return mapToDTOs(products);
}
}
Glücklicherweise bieten DI-Frameworks wie Spring Tools, die den statischen Zugriff unnötig machen, indem Objekte ohne unsere Beteiligung automatisch erstellt und verknüpft werden.
Parametrieren
Alle relevanten Teile der Klasse müssen von der Testseite aus konfigurierbar sein. Solche Einstellungen können an den Klassenkonstruktor übergeben werden.
Stellen Sie sich zum Beispiel vor, Ihr DAO hat ein festes Limit von 1000 Objekten pro Anfrage. Um dieses Limit zu überprüfen, müssen Sie vor dem Testen 1001 Objekte zur Testdatenbank hinzufügen. Mit dem Konstruktorargument können Sie diesen Wert anpassbar machen: Lassen Sie in der Produktion 1000, beim Testen auf 2 reduzieren. Um die Arbeit des Limits zu überprüfen, müssen Sie der Testdatenbank nur 3 Datensätze hinzufügen.
Konstruktorinjektion verwenden
Feldinjektion ist böse und führt zu einer schlechten Testbarkeit des Codes. Sie müssen DI vor den Tests initialisieren oder seltsame Reflexionsmagie ausführen. Daher ist es vorzuziehen, eine Konstruktorinjektion zu verwenden, um abhängige Objekte während des Testens leicht zu steuern.
In Java müssen Sie einen kleinen zusätzlichen Code schreiben:
//
public class ProductController {
private ProductDAO dao;
private TaxClient client;
public ProductController(ProductDAO dao, TaxClient client) {
this.dao = dao;
this.client = client;
}
}
In Kotlin wird dasselbe viel präziser geschrieben:
//
class ProductController(
private val dao: ProductDAO,
private val client: TaxClient
){
}
Verwenden Sie nicht Instant.now() odernew Date()
Sie müssen die aktuelle Uhrzeit nicht durch Aufrufe
Instant.now()oder new Date()im Produktionscode abrufen, wenn Sie dieses Verhalten testen möchten.
//
public class ProductDAO {
public void updateDateModified(String productId) {
Instant now = Instant.now(); // !
Update update = Update()
.set("dateModified", now);
Query query = Query()
.addCriteria(where("_id").eq(productId));
return mongoTemplate.updateOne(query, update, ProductEntity.class);
}
}
Das Problem ist, dass die benötigte Zeit nicht durch den Test gesteuert werden kann. Sie können das erhaltene Ergebnis nicht mit einem bestimmten Wert vergleichen, da es ständig anders ist. Verwenden Sie stattdessen eine Klasse
Clockaus Java.
//
public class ProductDAO {
private Clock clock;
public ProductDAO(Clock clock) {
this.clock = clock;
}
public void updateProductState(String productId, State state) {
Instant now = clock.instant();
// ...
}
}
In diesem Test können Sie ein Scheinobjekt für erstellen
Clock, es an übergeben ProductDAOund das Scheinobjekt so konfigurieren , dass es zur gleichen Zeit zurückgegeben wird. Nach den Aufrufen können updateProductState()wir überprüfen, ob der von uns angegebene Wert in die Datenbank gelangt ist.
Trennen Sie die asynchrone Ausführung von der tatsächlichen Logik
Das Testen von asynchronem Code ist schwierig. Bibliotheken wie Awaitility sind eine große Hilfe, aber der Prozess ist immer noch kompliziert und wir könnten einen Blinktest erhalten. Wenn möglich, ist es sinnvoll, Geschäftslogik (normalerweise synchron) und asynchronen Infrastrukturcode zu trennen.
Wenn Sie beispielsweise die Geschäftslogik in den ProductController integrieren, können Sie sie problemlos synchron testen. Die gesamte asynchrone und parallele Logik verbleibt im ProductScheduler, der isoliert getestet werden kann.
//
public class ProductScheduler {
private ProductController controller;
@Scheduled
public void start() {
CompletableFuture<String> usFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.US));
CompletableFuture<String> germanyFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.GERMANY));
String usResult = usFuture.get();
String germanyResult = germanyFuture.get();
}
}
Kotlin
Mein Artikel Best Practices für Unit-Tests in Kotlin enthält viele Kotlin-spezifische Unit-Test-Techniken. (Anmerkung Übersetzung: Schreiben Sie in die Kommentare, wenn Sie an der russischen Übersetzung dieses Artikels interessiert sind).