Testgetriebene Entwicklung: Eine Einführung
Von Frust zu Freude
Im Alltag eines Entwicklers sind Software-Tests unabdingbar, um eine reibungslose Funktionalität und langfristige Stabilität des Produktes zu gewährleisten. Damit das Schreiben von Tests zu keiner undankbaren Aufgabe wird, die möglichst lange vor sich her prokrastiniert wird, haben es sich bereits viele Autoren zur Aufgabe gemacht, Vorgehensweisen und Strategien für eine optimierte Testentwicklung zu verfassen. Oft wird dabei auf die Methode TDD (Test Driven Development) verwiesen.
Dieser Artikel soll zeigen, worum es bei der testgetriebenen Entwicklung geht und warum die Umsetzung dieser Methodik in der Praxis oft auf Probleme stößt. Außerdem sollen die Ursachen für diese Probleme analysiert und Lösungsvorschläge diskutiert werden. Die in diesem Artikel aufgeführten Beispiele sind in der Programmiersprache Java erstellt und sind dem Spring-Boot-Ökosystem zuzuordnen. Unabhängig davon sind die Informationen in diesem Artikel aber für alle testbaren Programmiersprachen bzw. zu testenden Anwendungen nutzbar.
TDD
Bei TDD werden Tests vor der eigentlichen Implementierung des Codes geschrieben. Zunächst schlägt die Ausführung dieser Tests dann fehl, da erst im Anschluss der Code geschrieben wird, welcher die Tests erfolgreich bestehen lässt. Ziel der testgetriebenen Entwicklung ist es, eine bessere Codequalität zu liefern, da mit diesem Vorgehen eine frühzeitige Fehlererkennung und eine umfassende Testabdeckung einhergehen.
In der Theorie erscheint diese Methodik als eine vielversprechende Lösung zur Gewährleistung einer hochwertigen Codequalität. In der Praxis stoßen Entwickler jedoch häufig auf Hindernisse, die dazu führen können, dass Tests entweder erst spät im Entwicklungsprozess oder sogar überhaupt nicht implementiert werden. Typische Barrieren könnten (gestützt auf persönliche Erfahrungen) sein:
- Ich weiß nicht, wie ich an den Test für mein Feature herangehen soll.
- Die Implementierung des Tests nimmt zu viel Zeit in Anspruch.
Obwohl es zahlreiche weitere Gründe für das Scheitern der testgetriebenen Entwicklung in der Praxis geben mag, liegt der Fokus dieses Artikels auf diesen beiden Hindernissen, da sie in meiner beruflichen Laufbahn eine entscheidende Rolle gespielt haben.
Die Hindernisse für die Etablierung einer testgetriebenen Softwareentwicklung sollen in diesem Artikel analysiert und Lösungsansätze praxisnah am Beispiel einer klassischen Spring Boot Anwendung diskutiert werden. Hierbei ist mir wichtig zu betonen, dass dieser Artikel keine universelle "Anleitung" zur erfolgreichen Implementierung der testgetriebenen Entwicklung bietet oder verspricht. Vielmehr möchte ich Ihnen, gemäß Immanuel Kants Maxime ("Sapere Aude"), das Rüstzeug zur Verfügung stellen, um die testgetriebene Entwicklung eigenständig zu erleichtern, indem Sie Ihre eigenen Fähigkeiten und Ihren Verstand einsetzen.
Herangehensweise Testimplementierung
Wenn man nicht weiß, welche Testmethode man für das aktuell zu testende Feature wählen soll, kann das dazu führen, dass man gezwungen ist, unzählige Abhängigkeiten zu befriedigen oder ein aufwändiges Test-Setup aufzubauen, etc... Das Ergebnis jedoch bleibt immer dasselbe: Die Erstellung der Tests nimmt zu viel Zeit in Anspruch – Zeit, die wir Software-Entwickler im Projektalltag im Zweifel nicht haben und dazu führt, dass man Tests weglässt bzw. leidenschaftslos oder undurchdacht implementiert, um stumpf eine gewisse Testabdeckung zu erfüllen. Aufgrund dessen ist es wichtig, die für den Anwendungsfall richtige Testart zu identifizieren. Dabei unterscheidet man grundsätzlich zwischen Unit-, Integrations- und End-to-End-Tests (E2E-Tests).
Testmethoden und deren Einordnung
Unit-Tests sind Softwaretests, bei denen einzelne Teile (Units) des Codes isoliert getestet werden um sicherzustellen, dass z. B. alleinstehende Funktionen das erwartete Ergebnis zurückliefern. Integrations-Tests sind Tests, bei denen mehrere Komponenten oder Module einer Anwendung zusammen getestet werden, um die ordnungsgemäße Interaktion zwischen diesen Komponenten sicherzustellen. Diese Tests haben oft eine längere Laufzeit, weisen dafür aber auch eine höhere Aussagekraft auf. E2E-Tests simulieren das Verhalten eines Endbenutzers um zu garantieren, dass alle Komponenten einer Anwendung korrekt interagieren und den erwarteten Funktionsfluss gewährleisten. Es ist sehr wahrscheinlich, dass Sie bereits mit dem Konzept der Testpyramide vertraut sind (vgl. Abb. 1).
Diese Pyramide soll darstellen, wie die Verteilung von verschiedenen automatisierten Tests in einem Portfolio balanciert sein sollte. UI-Tests meinen in der Abbildung von Martin Fowler die von mir zuvor angesprochenen E2E-Tests und mit Service-Tests sind Integrations-Tests gemeint. Sucht man im Netz nach TDD, finden sich unzählige Tutorials und Anleitungen, welche beschreiben, dass zunächst Unit-Tests und anschließend die entsprechende Business Logik zu implementieren sind. Dies ist auch nachvollziehbar, da Unit-Tests nach Martin Fowler den größten Teil der Test-Suite ausmachen sollten.
Nun ist jedoch eine häufige Fehlannahme, dass man dazu gezwungen ist, die eigene Business-Logik mit Unit-Tests abzutesten. Doch woher weiß man, welche Testart für das Testen meines Features am besten geeignet ist? Für die Auswahl der "richtigen" Testmethode existiert kein Rezept, mit welchem nach Schema-F eine Entscheidung getroffen werden kann, da diese Auswahl von verschiedenen Faktoren wie der Komplexität des Codes, die Zusammenhänge mit anderen Modulen oder der Zielsetzung des Tests abhängig ist. Die Antwort auf diese Frage ist also wie so oft: "It depends."
Aufgrund dessen möchte ich an dieser Stelle einige Überlegungen und Beispiele darstellen, die dabei helfen können, die richtige Entscheidung zu treffen.
Unit-Tests
Unit-Tests sind besonders gut dafür geeignet, einzelne Methoden zu testen, welche Business-Logik für ein Feature der zu testenden Anwendung beinhaltet. So könnte zum Beispiel eine Verwaltungssoftware für Lehrer automatisch die Note für einen geprüften Test errechnen (die Prüfung auf ungültige Methodenparameter wurde hier bewusst weggelassen):
public int calculateGrade(double achievedPoints, double maxPoints) {
double percentage = achievedPoints / maxPoints * 100;
if (percentage >= 90) {
return 1; // Note 1 (Sehr Gut)
.
.
.
}
Und der dazugehörige Unit-Test:
@Test
void testCalculateSum() {
GradeCalculator calculator = new GradeCalculator();
int result = calculator.calculateGrade(10, 20);
assertEquals(5, result);
}
Integrations-Tests
Integrations-Tests hingegen bieten sich als Testmethode an, wenn man Features testen möchte, bei denen mehrere Komponenten miteinander interagieren müssen oder wenn potenzielle Probleme aufgedeckt werden sollen, die bei der Zusammenführung von Komponenten auftreten können. Ein Beispiel hierfür wäre das Testen einer Methode, welche alle Schüler einer Klasse von einer Datenbank abrufen soll. Die beiden Komponenten, die in diesem Fall miteinander interagieren müssen, sind die Java-Backend-Anwendung und die Datenbank.
@Query(value = """
SELECT s.* FROM student s
LEFT JOIN class c ON s.class_id = c.id
WHERE c.designation = :classDesignation""", nativeQuery = true)
List<Student> getStudentsFromClass(@Param("classDesignation") String classDesignation);
Und der dazugehörige Integrations-Test:
@Test
void getStudentsFromClass() {
final var classDesignation = "1B";
List<Student> students = underTest.getStudentsFromClass(classDesignation);
assertNotNull(students);
assertFalse(students.isEmpty());
for (Student student : students) {
assertEquals(student.getSchoolClass().getDesignation(), classDesignation);
}
}
E2E-Tests
Abschließend sind E2E-Tests besonders dann effektiv, wenn man die Anwendung im Ganzen bzw. alle Komponenten der Anwendung gemeinsam testen möchte. Da E2E-Tests sowohl in der Erstellung als auch in der Ausführung vergleichsweise ressourcenintensiv sein können, ist es nach Martin Fowlers Überlegung üblich, nur die Kernfunktionen der Anwendung und nicht jeden Grenzfall (edge case) mit E2E-Tests abzudecken. Bleiben wir beim Beispiel unserer Verwaltungssoftware, könnte man z. B. die Registrierung eines Schülers an der Schule über einen in Cypress implementierten E2E-Test abbilden.
describe('Student-Tests', () => {
it('sign up student', () => {
cy.visit('http://localhost:8080');
// count students in an student-table.
const studentRowSelector = '[data-cy="studentRow"]'
cy.get(studentRowSelector).then(($elements) => {
cy.wrap($elements.length).as('studentsCount')
});
// add student with an form.
cy.get('[data-cy="signupStudent"]').click();
cy.get('#name').clear().type('Max');
cy.get('#firstName').clear().type('Mustermann');
cy.get('[data-cy="addStudent"]').click();
// check success
cy.get('@studentsCount').then((studentsCount) => {
cy.get(studentRowSelector).should('have.length', studentsCount + 1);
});
})
})
Ist die Auswahl der für den spezifischen Fall am besten geeigneten Testart gelungen, ist man sehr geschickt den meisten zeitfressenden Hürden oder Unannehmlichkeiten bei der Testentwicklung aus dem Weg gegangen.
Im nächsten Abschnitt möchte ich darstellen, wie man durch Vorbereitung die Entwicklung von Tests noch effizienter gestalten kann, damit man als Entwickler mehr Zeit für die eigentliche Feature-Implementierung hat.
Effiziente Testentwicklung
Wie im letzten Abschnitt schon angekündigt, kann eine entsprechende Vorbereitung den Aufwand, der für die Erstellung eines Tests notwendig ist, deutlich schmälern. Doch wie lassen sich Tests am besten vorbereiten?
In meiner beruflichen Praxis habe ich festgestellt, dass die Bereitstellung von Testdaten einen erheblichen zeitlichen Aufwand erfordert, bedingt oft durch eine gründliche Analyse der verschiedenen Szenarien und Randfälle um sicherzustellen, dass die Tests eine ausreichende Abdeckung bieten. Darüber hinaus müssen möglicherweise verschiedene Datentypen und -zustände berücksichtigt werden, um die realen Nutzungsbedingungen möglichst genau zu simulieren. Außerdem ist die Bereitstellung von Testdaten eine immer wiederkehrende Aufgabe, da die allermeisten Tests eine Datengrundlage benötigen. Aufgrund dessen möchte ich folgend zwei Methoden zur Vorbereitung von Testdaten in einer Test-Suite darstellen.
Test data builder
Oft ist es für Unit-Tests notwendig, ein oder mehrere Objekte zu initialisieren. Je nach Komplexität des Objektes kann dies jedoch aufwändig werden, da ggf. alle Felder des Objektes initialisiert werden müssen. Nehmen wir als Beispiel eine Testmethode, welche prüfen soll, ob zwei Schüler derselben Schulklasse zugeordnet sind:
@Test
void shouldNotifyStudentsInSameClass() {
var schoolClass = new SchoolClass(UUID.randomUUID(),"3A");
var student1 = new Student(
"Mustermann",
"Max",
schoolClass
);
var student2 = new Student(
"Maurer",
"Manuel",
schoolClass
);
assertTrue(student1.isInClassWith(student2));
}
Wie im Code-Snippet zu erkennen, ist der Hauptbestandteil des Tests der Erstellung der "Student" Objekte zuzuordnen. Bei komplexeren Objekten mit 10 oder 15 Feldern kann sich dies sehr mühselig gestalten. Werden diese "Student"-Objekte zusätzlich noch in anderen Tests benötigt (ggf. in angepasster Form), ist es zu empfehlen, diese Erstellung mithilfe eines Builder-Patterns zu abstrahieren.
Nach Implementierung eines "Builder" in der "Student"-Klasse ist es möglich, folgende Helfer-Methode zu implementieren:
public static Student.StudentBuilder validSample() {
var schoolClass = SchoolClassTest.validSample().build();
return Student.builder()
.id(UUID.randomUUID())
.name("Mustermann")
.firstName("Max")
.schoolClass(schoolClass);
}
Durch Verwendung dieser Helfer-Methode können in den zu implementierenden Tests, mit konstantem Aufwand (unabhängig von der Komplexität des Objektes), Standard "Student"-Objekte erstellt und leicht für eventuelle Randfälle angepasst werden. Des Weiteren müssen bei Anpassung des "Student"-Objektes nur wenige Stellen der Test-Suite angeglichen werden, da durch die Verwendung dieser Methode die Objekterstellung zentralisiert wurde.
Abschließend zeigt das folgende Code-Snippet noch einmal die Verwendung der Helfer-Methode im eingangs formulierten Testfall.
@Test
void shouldNotifyStudentsInSameClass() {
var schoolClass = SchoolClassTest.validSample().build();
var student1 = StudentTest.validSample()
.schoolClass(schoolClass)
.build();
var student2 = StudentTest.validSample()
.schoolClass(schoolClass)
.build();
assertTrue(student1.isInClassWith(student2));
}
Database Seeding
Für Integrations- oder E2E-Tests ist es oft notwendig, dass die Datenbank, mit der die Anwendung kommuniziert, einen bestimmten Status aufweist. Da das Schaffen einer Datengrundlage sehr aufwendig werden kann, hat man die Möglichkeit davon auszugehen, dass die für den Test relevanten Daten bereits auf der für den Test genutzten Datenbank vorhanden sind.
Bereits in mehreren Projekten konnte ich jedoch beobachten, wie die Datenbank bzw. die Daten des Testsystems als Datengrundlage für die Ausführung von Tests in einer Pipeline oder für die lokale Entwicklung verwendet wurden. Dies führte jedoch oft zu Problemen, da es nie eindeutig war, welche Daten nun zur Ausführung des Tests zur Verfügung standen. Des Weiteren konnten Tests teilweise nicht mehr deterministisch ausgeführt werden, da Testdaten verloren gegangen sind oder manipuliert wurden. Aufgrund dessen möchte ich an dieser Stelle von dieser Herangehensweise abraten und stattdessen eine Alternative aufzeigen.
Mithilfe von Tools wie Liquibase oder Flyway kann eine In-Memory-Datenbank wie H2 oder eine Docker-Container-basierte Lösung mit Postgres zum Start eines Tests mit Daten gefüllt werden. Somit stellt man bei jeder Testausführung einen konsistenten Datenbank-State sicher und kann sich darauf verlassen bzw. kann nachvollziehen, welche Testdaten zur Ausführung des Tests vorhanden waren.
Den Effekt des Datenbank-Seedings konnte man bereits im Codebeispiel zu den im ersten Abschnitt dieses Artikels vorgestellten Integrations-Tests beobachten. An dieser Stelle wurden schon vor der Ausführung des Tests mehrere Schüler und die Schulklasse "1B" in der für den Test genutzten Datenbank hinterlegt.
Abschließend möchte ich noch darauf hinweisen, dass die Test- und Entwicklungsdaten nicht im Produktiv-Kontext der Anwendung zur Verfügung zu stellen. Aufgrund dessen wurde das Liquibase-Script, welches für das Database-Seeding verantwortlich ist, so konfiguriert, dass es nur ausgeführt wird, wenn der Liquibase-Context "dev" oder "test" aktiviert ist. Der Liquibase-Context lässt sich wiederum über die Umgebungsvariablen bzw. Konfiguration der Spring-Anwendung steuern.
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<!-- ~~~ v1.0.0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<include file="v1.0.0/01_student.xml" relativeToChangelogFile="true"/>
<!-- ~~~ Test / Development Data ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<include file="development_data.xml" relativeToChangelogFile="true" context="dev,test"/>
</databaseChangeLog>
Fazit
In diesem Artikel wurde darüber diskutiert, warum die Methode der testgetriebenen Entwicklung in der Praxis oft auf Probleme bzw. Hürden stößt. Durch die Auswahl der richtigen Testmethode und mithilfe von Konzepten für die Vorbereitung von Tests, konnten wir zeigen, wie eine schnellere und effizientere Testentwicklung diese Hürden geschickt beseitigt.
Die Kernbotschaft dieses Artikels ist, dass die Implementierung von Tests so wenig Schmerzen wie möglich verursachen sollte, damit die Wahrscheinlichkeit, sich von der testgetriebenen Entwicklung abzuwenden, minimiert wird. Abschließen möchte ich diesen Artikel mit einem Zitat eines erfahrenen Kollegen: "Tests sollten als Geschenk an dein zukünftiges Ich gesehen werden und nicht als notwendiges Übel".
Thorsten Hindermann
am 04.07.2024Es ist nicht so einfach eine gute Balance zu finden zwischen nicht zu wenig und nicht zu viele zu testen. Ist es zu wenig, dann nützt TDD nichts, ist es zu viel wird es als Belastung angesehen und dann doch nicht gemacht.
Aber wenn man den Nutzen der Tests einsieht, dass man dann in seiner eigentlichen Entwicklung mal "beherzt" Refactoring durchführen kann, das ist dann schon etwas. Und die Tests sagen einem sofort, ob die Code-Umstellung gelungen ist oder nicht und man bricht als Entwickler die Funktion seiner APIs nicht, auf deren Funktionsfähigkeit andere Entwickler vertrauen.