Testen am Beispiel

Veröffentlicht: 2022-02-15

Wir setzen unsere Reihe von Blogs über alles rund ums Testen fort. In diesem Blog konzentrieren wir uns auf reale Beispiele.

Während die Beispiele in diesem Beitrag mit JUnit 5 und AssertJ geschrieben wurden, sind die Lektionen auf jedes andere Unit-Testing-Framework anwendbar.

JUnit ist das beliebteste Testframework für Java. AssertJ ist eine Java-Bibliothek, die Entwicklern hilft, aussagekräftigere Tests zu schreiben.

Grundlegende Teststruktur

Das erste Beispiel eines Tests, den wir uns ansehen werden, ist ein einfacher Rechner zum Addieren von 2 Zahlen.

 Klasse RechnerSollte {

     @Test // 1
     ungültige Summe () {
         Rechner Rechner = neuer Rechner(); // 2
         int result = rechner.sum(1, 2); // 3
         assertThat(Ergebnis).isEqualTo(3); // 4
     }
}

Ich ziehe es vor, beim Schreiben von Tests die Namenskonvention ClassShould zu verwenden, um das Wiederholen von should oder test in jedem Methodennamen zu vermeiden. Hier können Sie mehr darüber lesen.

Was macht der obige Test?

Lassen Sie uns den Test Zeile für Zeile unterbrechen:

  1. @Test Annotation teilt dem JUnit-Framework mit, welche Methoden als Tests ausgeführt werden sollen. Es ist vollkommen normal, private Methoden in der Testklasse zu haben, die keine Tests sind.
  2. Dies ist die Vorbereitungsphase unseres Tests, in der wir die Testumgebung vorbereiten. Alles, was wir für diesen Test brauchen, ist eine Calculator -Instanz.
  3. Dies ist die Aktionsphase , in der wir das Verhalten auslösen, das wir testen möchten.
  4. Dies ist die Assert- Phase, in der wir prüfen, was passiert ist und ob alles wie erwartet gelöst wurde. Die Methode assertThat(result) ist Teil der AssertJ-Bibliothek und hat mehrere Überladungen.

Jede Überladung gibt ein spezielles Assert Objekt zurück. Das zurückgegebene Objekt hat Methoden, die für das Objekt sinnvoll sind, das wir an die Methode assertThat . In unserem Fall ist dieses Objekt AbstractIntegerAssert mit Methoden zum Testen von ganzen Zahlen. isEqualTo(3) prüft, ob result == 3 . Wenn dies der Fall ist, wird der Test bestanden und andernfalls fehlschlagen.

Wir werden uns in diesem Blogbeitrag nicht auf Implementierungen konzentrieren.

Eine andere Art, über Arrange , Act , Assert nachzudenken , ist Given , When , Then .

Nachdem wir unsere sum geschrieben haben, können wir uns einige Fragen stellen:

  • Wie kann ich diesen Test verbessern?
  • Gibt es weitere Testfälle, die ich abdecken sollte?
  • Was passiert, wenn ich eine positive und eine negative Zahl addiere? Zwei negative Zahlen? Einmal positiv und einmal negativ?
  • Was passiert, wenn ich den ganzzahligen Wert überlaufe?

Lassen Sie uns diese Fälle hinzufügen und den bestehenden Testnamen ein wenig verbessern.

Wir werden in unserer Implementierung keine Überläufe zulassen. Wenn sum überläuft, werfen wir stattdessen eine ArithmeticException .

 Klasse RechnerSollte {

     privater Rechner Rechner = neuer Rechner();

     @Prüfen
     void sumPositiveNumbers() {
         int summe = rechner.summe (1, 2);
         assertThat(sum).isEqualTo(3);
     }

     @Prüfen
     void sumNegativeNumbers() {
         int summe = rechner.summe(-1, -1);
         assertThat(sum).isEqualTo(-2);
     }

     @Prüfen
     void sumPositiveAndNegativeNumbers() {
         int summe = rechner.summe(1, -2);
         assertThat(sum).isEqualTo(-1);
     }

     @Prüfen
     void failWithArithmeticExceptionWhenOverflown() {
         assertThatThrownBy(() -> rechner.sum(Integer.MAX_VALUE, 1))
             .isInstanceOf (ArithmeticException.class);
     } 

}

JUnit erstellt eine neue Instanz von CalculatorShould , bevor jede @Test Methode ausgeführt wird. Das bedeutet, dass jeder CalculatorShould einen anderen calculator haben wird, damit wir ihn nicht in jedem Test instanziieren müssen.

shouldFailWithArithmeticExceptionWhenOverflown Test verwendet eine andere Art von assert . Es prüft, ob ein Codeabschnitt fehlgeschlagen ist. Die Methode assertThatThrownBy führt das von uns bereitgestellte Lambda aus und stellt sicher, dass es fehlgeschlagen ist. Wie wir bereits wissen, geben alle assertThat Methoden einen spezialisierten Assert , mit dem wir überprüfen können, welche Art von Ausnahme aufgetreten ist.

Dies ist ein Beispiel dafür, wie wir testen können, ob unser Code fehlschlägt, wenn wir es erwarten. Wenn wir Calculator zu irgendeinem Zeitpunkt umgestalten und ArithmeticException bei einem Überlauf nicht auslöst, schlägt unser Test fehl.

ObjectMother-Entwurfsmuster

Das nächste Beispiel ist eine Validator-Klasse, um sicherzustellen, dass eine Person-Instanz gültig ist.

 Klasse PersonValidatorSollte {

    privater PersonValidator-Validator = neuer PersonValidator();

    @Prüfen
    void failWhenNameIsNull() {
        Person person = new Person(null, 20, new Address(...), ...);

        assertThatThrownBy(() -> validator.validate(person))
            .isInstanceOf (InvalidPersonException.class);
    }

    @Prüfen
    void failWhenAgeIsNegative() {
        Person person = new Person("John", -5, new Address(...), ...);

        assertThatThrownBy(() -> validator.validate(person))
            .isInstanceOf (InvalidPersonException.class);

    }
}

ObjectMother- Entwurfsmuster wird häufig in Tests verwendet, die komplexe Objekte erstellen, um die Instanziierungsdetails vor dem Test zu verbergen. Mehrere Tests können sogar dasselbe Objekt erstellen, aber verschiedene Dinge darauf testen.

Test Nr. 1 ist Test Nr. 2 sehr ähnlich. Wir können PersonValidatorShould umgestalten, indem wir die Validierung als private Methode extrahieren und ihr dann illegale Person -Instanzen übergeben, in der Erwartung, dass sie alle auf die gleiche Weise fehlschlagen.

 Klasse PersonValidatorSollte {

     privater PersonValidator-Validator = neuer PersonValidator();

     @Prüfen
     void failWhenNameIsNull() {
         shouldFailValidation(PersonObjectMother.createPersonWithoutName());
     }

     @Prüfen
     void failWhenAgeIsNegative() {
         shouldFailValidation(PersonObjectMother.createPersonWithNegativeAge());
     }

     private void shouldFailValidation(Person invalidPerson) {
         assertThatThrownBy(() -> validator.validate(invalidPerson))
             .isInstanceOf (InvalidPersonException.class);
   
     }
 }

Zufälligkeit testen

Wie sollen wir die Zufälligkeit in unserem Code testen?

Nehmen wir an, wir haben einen PersonGenerator mit generateRandom , um zufällige Person -Instanzen zu generieren.

Wir beginnen damit, Folgendes zu schreiben:

 Klasse PersonGeneratorSollte {

     privater Personengeneratorgenerator = neuer Personengenerator();

     @Prüfen
     void generateValidPerson() {
         Person person = generator.generateRandom();
         behaupte, dass (Person).
    }
}

Und dann sollten wir uns fragen:

  • Was versuche ich hier zu beweisen? Was muss diese Funktion leisten?
  • Soll ich nur überprüfen, ob die generierte Person eine Nicht-Null-Instanz ist?
  • Muss ich beweisen, dass es Zufall ist?
  • Muss die generierte Instanz einigen Geschäftsregeln folgen?

Wir können unseren Test mit Dependency Injection vereinfachen.

 Öffentliche Schnittstelle Zufallsgenerator {
     Zeichenfolge generiertRandomString();
     int generateRandomInteger();
}

Der PersonGenerator hat jetzt einen weiteren Konstruktor, der auch eine Instanz dieser Schnittstelle akzeptiert. Standardmäßig verwendet es die JavaRandomGenerator Implementierung, die Zufallswerte mit java.Random .

Im Test können wir jedoch eine andere, vorhersehbarere Implementierung schreiben.

 @Prüfen
 void generateValidPerson() {
     RandomGenerator randomGenerator = new PredictableGenerator("John Doe", 20);
     PersonGenerator-Generator = new PersonGenerator(randomGenerator);
     Person person = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
}

Dieser Test beweist, dass der PersonGenerator zufällige Instanzen generiert, wie vom RandomGenerator angegeben, ohne auf Details des RandomGenerator .

Das Testen des JavaRandomGenerator bringt keinen wirklichen Mehrwert, da es sich um einen einfachen Wrapper um java.Random . Wenn Sie es testen, testen Sie im Wesentlichen java.Random aus der Java-Standardbibliothek. Das Schreiben offensichtlicher Tests führt nur zu zusätzlicher Wartung mit wenig oder gar keinem Nutzen.

Um das Schreiben von Implementierungen zu Testzwecken wie PredictableGenerator zu vermeiden, sollten Sie eine Mock-Bibliothek wie Mockito verwenden.

Als wir PredictableGenerator geschrieben haben, haben wir die Klasse RandomGenerator tatsächlich manuell RandomGenerator . Sie hätten es auch mit Mockito stubben können:

 @Prüfen
 void generateValidPerson() {
     RandomGenerator randomGenerator = mock(RandomGenerator.class);
     when(randomGenerator.generateRandomString()).thenReturn("John Doe");
     when(randomGenerator.generateRandomInteger()).thenReturn(20);

     PersonGenerator-Generator = new PersonGenerator(randomGenerator);
     Person person = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
 }

Diese Art, Tests zu schreiben, ist aussagekräftiger und führt zu weniger Implementierungen für bestimmte Tests.

Mockito ist eine Java-Bibliothek zum Schreiben von Mocks und Stubs. Es ist sehr nützlich, wenn Sie Code testen, der von externen Bibliotheken abhängt, die Sie nicht einfach instanziieren können. Es ermöglicht Ihnen, Verhalten für diese Klassen zu schreiben, ohne sie direkt zu implementieren.

Mockito ermöglicht auch eine andere Syntax zum Erstellen und Einfügen von Mocks, um die Boilerplate zu reduzieren, wenn wir mehr als einen Test haben, der dem ähnelt, an den wir gewöhnt sind:

 @ExtendWith(MockitoExtension.class) // 1
 Klasse PersonGeneratorSollte {

     @Mock // 2
     Zufallsgenerator Zufallsgenerator;

     @InjectMocks // 3
     private PersonGenerator-Generator;

     @Prüfen
     void generateValidPerson() {
         when(randomGenerator.generateRandomString()).thenReturn("John Doe");
         when(randomGenerator.generateRandomInteger()).thenReturn(20);

         Person person = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

1. JUnit 5 kann „Erweiterungen“ verwenden, um seine Fähigkeiten zu erweitern. Diese Anmerkung ermöglicht es, Mocks durch Anmerkungen zu erkennen und sie richtig einzufügen.

2. @Mock Annotation erstellt eine simulierte Instanz des Felds. Dies entspricht dem Schreiben von mock(RandomGenerator.class) in unseren Testmethodentext.

3. Die Annotation @InjectMocks erstellt eine neue Instanz von PersonGenerator und fügt Mocks in die generator ein.

Weitere Einzelheiten zu JUnit 5-Erweiterungen finden Sie hier.

Weitere Einzelheiten zur Mockito-Injektion finden Sie hier.

Es gibt einen Fallstrick bei der Verwendung von @InjectMocks . Es kann die Notwendigkeit beseitigen, eine Instanz des Objekts manuell zu deklarieren, aber wir verlieren die Kompilierzeitsicherheit des Konstruktors. Wenn zu irgendeinem Zeitpunkt jemand dem Konstruktor eine weitere Abhängigkeit hinzufügt, erhalten wir hier keinen Kompilierungsfehler. Dies könnte zu fehlerhaften Tests führen, die nicht leicht zu erkennen sind. Ich ziehe es vor, @BeforeEach zu verwenden, um die Instanz manuell einzurichten:

 @ExtendWith(MockitoExtension.class)
Klasse PersonGeneratorSollte {

     @Spotten
     Zufallsgenerator Zufallsgenerator;

     private PersonGenerator-Generator;

     @BeforeEach
     void setUp() {
         Generator = neuer Personengenerator (Zufallsgenerator);
     }

     @Prüfen
     void generateValidPerson() {
         when(randomGenerator.generateRandomString()).thenReturn("John Doe");
         when(randomGenerator.generateRandomInteger()).thenReturn(20);

         Person person = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

Testen zeitkritischer Prozesse

Ein Stück Code ist oft von Zeitstempeln abhängig, und wir neigen dazu, Methoden wie System.currentTimeMillis() zu verwenden, um den Zeitstempel der aktuellen Epoche zu erhalten.

Das sieht zwar gut aus, ist aber schwer zu testen und zu beweisen, ob unser Code richtig funktioniert, wenn die Klasse intern Entscheidungen für uns trifft. Ein Beispiel für eine solche Entscheidung wäre die Bestimmung des aktuellen Tages.

 Klasse IndexerSollte {
     privater Indexer indexer = neuer Indexer();
     @Prüfen
     void generateIndexNameForTomorrow() {
         String indexName = indexer.tomorrow("my-index");
         // Dieser Test würde heute funktionieren, aber was ist morgen?
        assertThat(indexName)
           .isEqualTo("mein-index.2022-02-02");
     }
}

Wir sollten Dependency Injection erneut verwenden, um den Tag bei der Generierung des Indexnamens „steuern“ zu können.

Java hat eine Clock -Klasse, um solche Anwendungsfälle zu handhaben. Wir können eine Instanz einer Clock an unseren Indexer , um die Zeit zu steuern. Der Standardkonstruktor könnte Clock.systemUTC() für Abwärtskompatibilität verwenden. Wir können jetzt Aufrufe von clock.millis() System.currentTimeMillis() .

Durch das Einfügen einer Clock können wir eine vorhersehbare Zeit in unseren Klassen erzwingen und bessere Tests schreiben.

Testen von Methoden zur Dateierstellung

  • Wie sollten wir Klassen testen, die ihre Ausgabe in Dateien schreiben?
  • Wo sollten wir diese Dateien speichern, damit sie auf jedem Betriebssystem funktionieren?
  • Wie können wir sicherstellen, dass die Datei nicht bereits existiert?

Beim Umgang mit Dateien kann es schwierig sein, Tests zu schreiben, wenn wir versuchen, diese Probleme selbst anzugehen, wie wir im folgenden Beispiel sehen werden. Der folgende Test ist ein alter Test von zweifelhafter Qualität. Es sollte testen, ob ein DogToCsvWriter Hunde serialisiert und in eine CSV-Datei schreibt:

 Klasse DogToCsvWriterShould {

     privater DogToCsvWriter-Writer = new DogToCsvWriter("/tmp/dogs.csv");
     
     @Prüfen
     void convertToCsv() {
         writer.appendAsCsv(neuer Hund(Rasse.CORGI, Farbe.BRAUN, "Monty"));
         writer.appendAsCsv(neuer Hund(Breed.MALTESE, Color.WHITE, "Zoe"));

         String csv = Files.readString("/tmp/dogs.csv");

         assertThat(csv).isEqualTo("Monty,corgi,brown\nZoe,maltese,white");
     }
}

Der Serialisierungsprozess sollte vom Schreibprozess entkoppelt werden, aber konzentrieren wir uns auf die Korrektur des Tests.

Das erste Problem mit dem obigen Test ist, dass er unter Windows nicht funktioniert, da Windows-Benutzer den Pfad /tmp/dogs.csv nicht auflösen können. Ein weiteres Problem ist, dass es nicht funktioniert, wenn die Datei bereits existiert, da sie nicht gelöscht wird, wenn der obige Test ausgeführt wird. Es funktioniert möglicherweise in einer CI/CD-Pipeline, aber nicht lokal, wenn es mehrmals ausgeführt wird.

JUnit 5 hat eine Anmerkung, die Sie verwenden können, um einen Verweis auf ein temporäres Verzeichnis zu erhalten, das vom Framework für Sie erstellt und gelöscht wird. Während der Mechanismus zum Erstellen und Löschen temporärer Dateien von Framework zu Framework unterschiedlich ist, bleiben die Ideen gleich.

 Klasse DogToCsvWriterShould {

     @Prüfen
     void convertToCsv(@TempDir Pfad tempDir) {
         Pfad dogsCsv = tempDir.resolve("dogs.csv");
         DogToCsvWriter-Writer = new DogToCsvWriter(dogsCsv);
         writer.appendAsCsv(neuer Hund(Rasse.CORGI, Farbe.BRAUN, "Monty"));
         writer.appendAsCsv(neuer Hund(Breed.MALTESE, Color.WHITE, "Zoe"));

         Zeichenfolge csv = Files.readString (dogsCsv);

         assertThat(csv).isEqualTo("Monty,corgi,brown\nZoe,maltese,white");
     }
}

Mit dieser kleinen Änderung sind wir uns nun sicher, dass der obige Test unter Windows, macOS und Linux funktioniert, ohne dass wir uns um absolute Pfade kümmern müssen. Es löscht auch die erstellten Dateien nach dem Test, sodass wir es jetzt mehrmals ausführen können und jedes Mal vorhersehbare Ergebnisse erhalten.

Befehl vs. Abfrage testen

Was ist der Unterschied zwischen einem Befehl und einer Abfrage?

  • Befehl : Wir weisen ein Objekt an, eine Aktion auszuführen, die einen Effekt erzeugt, ohne einen Wert zurückzugeben (void-Methoden)
  • Abfrage : Wir bitten ein Objekt, eine Aktion auszuführen und ein Ergebnis oder eine Ausnahme zurückzugeben

Bisher haben wir hauptsächlich Abfragen getestet, bei denen wir eine Methode aufgerufen haben, die einen Wert zurückgegeben oder in der Aktionsphase eine Ausnahme ausgelöst hat. Wie können wir void -Methoden testen und sehen, ob sie korrekt mit anderen Klassen interagieren? Frameworks bieten verschiedene Methoden zum Schreiben dieser Art von Tests.

Die Assertionen, die wir bisher für Abfragen geschrieben haben, begannen mit assertThat . Beim Schreiben von Befehlstests verwenden wir einen anderen Satz von Methoden, da wir nicht mehr die direkten Ergebnisse von Methoden untersuchen, wie dies bei Abfragen der Fall war. Wir wollen die Interaktionen unserer Methode mit anderen Teilen unseres Systems „verifizieren“.

 @ExtendWith(MockitoExtension.class)
 Klasse FeedMentionServiceShould {

     @Spotten
     privates FeedRepository-Repository;

     @Spotten
     privater FeedMentionEventEmitter-Emitter;

     privater FeedMentionService-Dienst;

     @BeforeEach
     void setUp() {
         service = new FeedMentionService(repository, emitter);
     }

     @Prüfen
     void insertMentionToFeed() {
         lange FeedId = 1L;
         Erwähnung erwähnen = ...;

         when(repository.upsertMention(feedId, Erwähnung))
             .thenReturn(UpsertResult.success(feedId, Erwähnung));

         FeedInsertionEvent-Ereignis = neues FeedInsertionEvent (FeedId, Erwähnung);
         erwähnenService.insertMentionToFeed (Ereignis);

         Verify(Emitter).ErwähnungInsertedToFeed(FeedId, Erwähnung);
         verifyNoMoreInteractions (Emitter);
     }
}

In diesem Test haben wir zuerst unser Repository verspottet, um mit einem UpsertResult.success zu antworten, wenn wir aufgefordert wurden, die Erwähnung in unserem Feed zu aktualisieren. Es geht uns hier nicht darum, das Repository zu testen. Die Repository-Methoden sollten im FeedRepositoryShould getestet werden. Indem wir uns über dieses Verhalten lustig gemacht haben, haben wir die Repository-Methode nicht wirklich aufgerufen. Wir haben ihm einfach gesagt, wie er beim nächsten Anruf reagieren soll.

Wir haben dann unseren mentionService angewiesen, diese Erwähnung in unseren Feed einzufügen. Wir wissen, dass es das Ergebnis nur ausgeben sollte, wenn es die Erwähnung erfolgreich in den Feed eingefügt hat. Durch die Verwendung der Methode verify können wir sicherstellen, dass die Methode erwähnenInsertedToFeed mit unserer Erwähnung und unserem Feed aufgerufen wurde und nicht erneut mit mentionInsertedToFeed verifyNoMoreInteractions wurde.

Abschließende Gedanken

Das Schreiben von Qualitätstests basiert auf Erfahrung, und der beste Weg, um zu lernen, ist, es zu tun. Die in diesem Blog geschriebenen Tipps stammen aus der Praxis. Es ist schwer, einige der Fallstricke zu erkennen, wenn Sie ihnen noch nie begegnet sind, und hoffentlich sollten diese Vorschläge Ihr Codedesign robuster machen. Zuverlässige Tests werden Ihr Selbstvertrauen stärken, Dinge zu ändern, ohne jedes Mal ins Schwitzen zu geraten, wenn Sie Ihren Code bereitstellen müssen.

Sind Sie daran interessiert, dem Team von Mediatoolkit beizutreten?
Schauen Sie sich unsere offene Stelle als Senior Frontend Developer an!