Software Testing ist ein essenzieller und extrem umfangreicher Bestandteil der Software Entwicklung. Von vielen Entwicklern höre ich, wie sehr sie das Testing unterschätzt haben, nachdem ich ihnen erzähle, was alles zum Testing dazu gehört. In diesem Beitrag möchte ich speziell über Testgetriebenen Entwicklungsansätze sprechen. Diese wären Acceptance-Test-Driven-Development (ATDD), Behavioural-Driven-Development (BDD) und Test-Driven-Development (TDD).

Als Entwickler ist es ein Ding der Unmöglichkeit, Änderungen am Code vorzunehmen, ohne existierende Features potentiell zu zerstören. Dies gilt vor allem dann, wenn die Applikation garnicht oder nicht ausreichend getestet wurde. Umso schwieriger wird es dann, festzustellen, wann und durch welche Änderungen ein Feature kaputt gegangen ist! Am häufigsten tritt so ein verhalten auf wenn man mit CMS Systemen wie WordPress oder Drupal arbeitet. Wenn der Traffic auf der Webseite sehr niedrig ist, kann es sein, dass im Shop System erst lange Zeit in der Zukunft das erste Mal der Use-Case eintritt: Kunde bestellt 3-Teiliges Set mit Set-Rabatt über die Desktop-Ansicht , bezahlt mit PayPal und die Lieferung geht nach Niederlande. Erfahrungsgemäß kann ich sagen, dass allerhöchstens bei der Entwicklung von Plugins daran gedacht wird, Tests zu implementieren.

Ganz anders sieht es bei vollständig eigenst entwickelten Anwendungen aus (hoffentlich). Unit Tests, Integration Tests, Test-Automatisierung durch Pipelines, manuelle Tests duch Entwickler oder QA-Team, deployment auf staging Umgebungen um Systemabnahmetests durchzuführen, und so weiter und so weiter. Bis dann letztendlich das neue Feature, geprüft durch mindestens 6 Augen, endlich auf dem Produktivsystem landet. Meine bescheidene Meinung zu dem Thema ist: Testing ist 50% der Entwicklung!

Doch wie geht man da am besten vor? Vor allem wie geht man bei neuen Features vor? Schauen wir uns ein Beispiel an.

Schaltjahr-Problem

Viele von euch haben schon Mal das Schaltjahr Problem implementiert. Es gibt bestimmte Kriterien, wann ein Jahr ein Schaltjahr ist.

/**
 * Implement a method that returns true or false if the given year is a leap year.
 */
public function isLeapYear(int $year): bool
{
   // ToDo: Finish 
   return $year % 4 === 0;
}

Der 1582 von Papst Gregor XIII. eingeführte Gregorianische Kalender regelt Schaltjahre so: Ein Jahr ist ein Schaltjahr, wenn es durch 4 teilbar ist, außer es ist durch 100 teilbar. In diesem Fall gilt es nur als Schaltjahr, wenn es auch durch 400 teilbar ist. Diese Regel sorgt für langfristige Genauigkeit des Kalenders.

Aus der Beschreibung lässt sich ganz genau ableiten, dass es klare Anforderungen gibt, wann ein Jahr als ein Schaltjahr gilt. Aus den Testgetriebenen Entwicklungsansätzen würde sich an der Stellt sehr gut das Acceptance Test-driven Development eignen.

Acceptance-Test-Driven Development (ATDD) schließt diese Lücke, indem Anforderungen weitestgehend in natürlicher Sprache formuliert werden, aber gleichzeitig ihre Ausführbarkeit sichergestellt wird. Ziel von ATDD ist es, verständliche, wartbare und gleichzeitig ausführbare Testspezifikationen zu schreiben.

Hieraus lassen sich nun folgende Tests (Assertions ableiten).

public function testIsLeapYear(): bool
{
   // First leap year
   self::assertTrue(LeapYearService::isLeapYear(1584));
   self::assertTrue(LeapYearService::isLeapYear(2000));
   self::assertTrue(LeapYearService::isLeapYear(2004));
   self::assertTrue(LeapYearService::isLeapYear(1996));
   self::assertTrue(LeapYearService::isLeapYear(1600));
}

Genauso wichtig, oder meiner Meinung nach sogar wichtiger, ist es, die gegenfälle zu testen.

public function testIsNotLeapYear(): bool
{
   // Leap years started 1584
   self::assertFalse(LeapYearService::isLeapYear(1580));
   self::assertFalse(LeapYearService::isLeapYear(2001));
   self::assertFalse(LeapYearService::isLeapYear(2003));
   self::assertFalse(LeapYearService::isLeapYear(1995));
   self::assertFalse(LeapYearService::isLeapYear(1800));
}

Wir können davon ausgehen, dass unsere Tests alle Fälle abdecken, an denen ein Jahr ein Schaltjahr ist, und wann es genau nicht der Fall ist. Nun heißt es die eigentliche Methode zum ermitteln eines Schaltjahres zu implementieren. Durch unsere Anwendung von ATDD sind wir in der Lage, unmittelbar Feedback zu erhalten, ob unsere Methode genau richtig implementiert ist oder nicht. Aus der Beschreibung eines Schaltjahres haben wir Präzise Anforderungen ableiten können, welche Kriterien zu einem Schaltjahr dazu gehören.

Buchungs-System

Schauen wir uns nun ein etwas komplexeres Beispiel an. Angenommen wir implementieren ein Raumbuchungssystem für unsere Konferenzräume in unserem Bürogebäude.

Es gibt Konferenzräume für bis zu 10 Leute und 2 Konferenzräume für 200 Leute. Die großen können nur von der Chef Abteilung gebucht werden und die kleinen können von jedem außer Praktikanten bzw. Azubis gebucht werden. Konferenzräume können nicht gebucht werden, wenn sie jemand anderes bereits gebucht hat. Konferenzräume können nicht außerhalb der Betriebszeiten gebucht werden (08:00 – 19:00 Uhr) ebensowenig können sie an Wochenenden gebucht werden.

Anders als beim Schaltjahr-Problem lassen sich nicht auf anhieb alle Testfälle als Assertions definieren. Hier eignet sich die Verwendung von Behavioural-Driven-Development.

Was wir haben sind 2 Arten von Räumen, 3 verschiedene Rollen, Betriebszeiten vs. Nicht-Betriebszeiten und Werktage vs. Wochenende. Und selbsterständlich gebuchte Räume vs nicht gebuchte Räume.

class RoomBookingService
{
  public function bookRoom(array $request)
  {
    return success(201, "Room booked");     
  }
}

// ---------------
class RoomBookingTest
{
    public function testBookRoom()
    {
       self::assertEquals(201, RoomBookingService::bookRoom([]));
    }
}

Beim Behavioural-Driven Development geht man kleinschrittig vor. Zunächst wird sichergestellt, dass das Framework die Request zulässt, dann tastet man sich Kleinschrittig voran. Wir fragen ab ob der Benutzer eingeloggt ist. Dann schauen wir ob die Raum-ID real ist. Dann prüfen wir die Zeiten. Und so weiter und so weiter. Die Reihenfolge spielt keine Rolle. Beim Behavioural-Driven Development entwickelt man einen kleinen Schritt, lässt die Tests laufen, korrigiert die Fehler sodass die Tests wieder grün sind, und setzt dann mit der Feature Implementierung fort.

class RoomBookingService
{
  public function bookRoom(array $request)
  {
    // Eingeloggter Benutzer notwendig.
    if(! $request->user()) {
       return abort(401, "Unauthenticated");
    }

    return success(201, "Room booked");     
  }
}

// ---------------
class RoomBookingTest
{
    public function testBookRoomUnauthenticated()
    {
       self::assertEquals(401, RoomBookingService::bookRoom([]));
    }

    public function testBookRoomAuthenticated()
    {
       $this->authenticator->authenticate(self::ANY_USER);
       self::assertEquals(201, RoomBookingService::bookRoom([]));
    }
}

Hier haben wir den Test in 2 aufgeteilt, weil der ursprüngliche Test fehlgeschlagen ist. Es ist Good Practice und von großem Vorteil, die Tests aufzuteilen, sobald man Änderungen vorgenommen hat. Man kann durch das durchlesen der Tests rückschließen, wie man Schritt für Schritt vorangekommen ist. Als nächstes schauen wir uns Azubi vs. Nicht-Azubi an.

class RoomBookingService
{
  public function bookRoom(array $request)
  {
    // Eingeloggter Benutzer notwendig.
    if(! $request->user()) {
       return abort(401, "Unauthenticated");
    }

    $userRole = $request->user()->role;
    if (in_array($userRole, [Role::AZUBI, ROLE::PRAKTIKANT])) {
       return abort(403, "Unbefugter Benutzer");
    }

    return success(201, "Room booked");     
  }
}

// ---------------
class RoomBookingTest
{
    public function testBookRoomUnauthenticated()
    {
       self::assertEquals(401, RoomBookingService::bookRoom([]));
    }

    public function testBookRoomAuthenticatedAsAzubi()
    {
       $this->authenticator->authenticate(self::AZUBI_USER);
       self::assertEquals(403, RoomBookingService::bookRoom([]));
    }

    public function testBookRoomAuthenticatedAsDeveloper()
    {
       $this->authenticator->authenticate(self::DEVELOPER_USER);
       self::assertEquals(201, RoomBookingService::bookRoom([]));
    }
}

Hier könnte man den 3. Test-Case hinzufügen, ob wir der Chef sind oder ein Vollzeit-Mitarbeiter. Es gibt kein richtig oder falsch. Doch es bleibt übersichtlicher wenn wir Kleinschrittig vorgehen. Als nächstes versuchen wir einen konkreten Raum zu buchen. Wie eben gesagt, ist ein großer Vorteil beim Behavioural-Driven-Development, dass man alle falsch-assertions weiterhin in der Test-Klasse findet, weil wir die Tests in richtig und falsch aufteilen. So verhindern wir in Zukunft, dass Unauthentifizierte Benutzer oder Benutzer mit der falschen Rolle Räume buchen. Andernfalls würde es heißen: „Wir haben einen Bug im System. Aus irgendeinem Grund können Azubis Räume buchen. Hussam könntest du dir das anschauen. Hier ist die Ticket-Nummer…“. Ein weiterer dezenter Vorteil ist, dass die Entwickler sich auf diese Weise mit dem Return-Early Pattern vertraut machen. Anstatt verschachtelten Code zu haben, wo alle kriterien abgefragt werden, bleibt unser Code leserlich und offen für Anpassungen und Erweiterungen.

class RoomBookingService
{
  public function bookRoom(array $request)
  {
    // Eingeloggter Benutzer notwendig.
    if(! $request->user()) {
       return abort(401, "Unauthenticated");
    }

    $userRole = $request->user()->role;
    if (in_array($userRole, [Role::AZUBI, ROLE::PRAKTIKANT])) {
       return abort(403, "Unbefugter Benutzer");
    }

    $roomId = $request['room_id'];
    try {
        $room = RoomRepository::findRoom($room);
    } catch(RoomNotFoundException $ex) {
        return abort(404, "Room not found!");
    }

    return success();     
  }
}

// ---------------
class RoomBookingTest
{
    public function testBookRoomUnauthenticated()
    {
       self::assertEquals(401, RoomBookingService::bookRoom([]));
    }

    public function testBookRoomAuthenticatedAsAzubi()
    {
       $this->authenticator->authenticate(self::AZUBI_USER);
       self::assertEquals(403, RoomBookingService::bookRoom([]));
    }

    public function testBookRoomAuthenticatedAsDeveloperWithFakeRoom()
    {
       $this->authenticator->authenticate(self::DEVELOPER_USER);
       $request = [ 'room_id' => self::FAKE_ROOM_ID ];
       self::assertEquals(404, RoomBookingService::bookRoom($request));
    }

    public function testBookRoomAuthenticatedAsDeveloperWithRealRoom()
    {
       $this->authenticator->authenticate(self::DEVELOPER_USER);
       $request = [ 'room_id' => self::MEETING_ROOM_ID ];
       self::assertEquals(201, RoomBookingService::bookRoom($request));
    }

}

Bei API Requests ist es immer wichtig, die Eingaben des Users ausgiebig zu validieren. Hier im Pseudo-Code validiere ich nur dass der Raum mit der übergebenen ID existiert. In echtem Szenario würde man jeden Eingabeparameter auf Hand und Fuß validieren. Wir werden in unserem Beispiel noch die Uhrzeiten validieren. Doch wenn ihr mehr über Validation wissen wollt kann ich euch diese Dokumentation zu Validation empfehlen.

Wir sind mit Behavioural-Driven-Development nun sehr weit gekommen und haben nun 4 Test-Cases ohne tatsächlich einen Raum gebucht zu haben. Da wir nun Zugriff auf den Raum haben, können wir prüfen, welche Rollen welchen Raum buchen dürfen. Hier ist weiterhin zu empfehlen, sich an das Return-Early pattern zu halten!

class RoomBookingService
{
  public function bookRoom(array $request)
  {
    // Eingeloggter Benutzer notwendig.
    if(! $request->user()) {
       return abort(401, "Unauthenticated");
    }

    $userRole = $request->user()->role;
    if (in_array($userRole, [Role::AZUBI, ROLE::PRAKTIKANT])) {
       return abort(403, "Unbefugter Benutzer");
    }

    $roomId = $request['room_id'];
    $room = null;
    try {
        $room = RoomRepository::findRoom($room);
    } catch(RoomNotFoundException $ex) {
        return abort(404, "Room not found!");
    }

    // Prüfe ob jemand der NICHT CHEF-Rolle hat, versucht, den großen Konferenz-Raum zu buchen.
    if (!array_in($userRole, [Role::CHEF]) && $room->category === RoomCategory::AUDITORIUM) {
        // Normalerweise würde man ein standhafteres Pattern verwenden. Hier prüfen wir nur ob der Benutzer nicht die Chef Rolle hat.
        return abort(403, "Unauthorized access to the big Auditorium Room");
    }

    return success();     
  }
}

// ---------------
class RoomBookingTest
{
    public function testBookRoomUnauthenticated()
    {
       self::assertEquals(401, RoomBookingService::bookRoom([]));
    }

    public function testBookRoomAuthenticatedAsAzubi()
    {
       $this->authenticator->authenticate(self::AZUBI_USER);
       self::assertEquals(403, RoomBookingService::bookRoom([]));
    }

    public function testBookRoomAuthenticatedAsDeveloperWithFakeRoom()
    {
       $this->authenticator->authenticate(self::DEVELOPER_USER);
       $request = [ 'room_id' => self::FAKE_ROOM_ID ];
       self::assertEquals(404, RoomBookingService::bookRoom($request));
    }

    public function testBookRoomAuthenticatedAsDeveloperWithRealRoom()
    {
       $this->authenticator->authenticate(self::DEVELOPER_USER);
       $request = [ 'room_id' => self::MEETING_ROOM_ID ];
       self::assertEquals(201, RoomBookingService::bookRoom($request));
    }

    public function testBookRoomAuthenticatedAsDeveloperWithAuditoriumRoom()
    {
       $this->authenticator->authenticate(self::DEVELOPER_USER);
       $request = [ 'room_id' => self::AUDITORIUM_ROOM_ID ];
       self::assertEquals(403, RoomBookingService::bookRoom($request));
    }

    public function testBookRoomAuthenticatedAsChefWithAuditoriumRoom()
    {
       $this->authenticator->authenticate(self::CHEF_USER);
       $request = [ 'room_id' => self::AUDITORIUM_ROOM_ID ];
       self::assertEquals(201, RoomBookingService::bookRoom($request));
    }
}

Nun da wir die Raum-Typen Anforderungen erfüllt haben, heißt es Zeiten validieren und existierende Raumbuchungen prüfen. Kleiner Tipp: Schämt euch nicht beeim auswählen der Methoden-Namen der Tests wirklich zu erklären was gerade passiert. In der Pipeline ist es manchmal schwer nachzuvollziehen, warum ein Test schiefgelaufen ist, wenn die von uns definierten Error-Messages nicht zu sehen sind. Dennoch werden wir die Test-Methoden-Namen immer zu sehen kriegen. Je deutlicher die Methode beschreibt, wer was wann wie macht, können Entwickler sofort nachvollziehen, was falsch gelaufen ist und das händisch in der Applikation nachbilden. Dies entspricht auch dem Ansatz des BDD.

Behavior-Driven Development (BDD) ist eine Softwareentwicklungspraktik, die auf Test-Driven Development (TDD) aufbaut, indem sie den Fokus auf das Verhalten des Systems aus der Perspektive des Benutzers oder Stakeholders legt. BDD verwendet eine natürliche Sprache, um Szenarien zu beschreiben, die den gewünschten Systemzustand und das Verhalten in bestimmten Situationen darstellen. Diese Szenarien werden häufig in einem Format wie „Given-When-Then“ geschrieben. BDD fördert die Zusammenarbeit zwischen Entwicklern, Testern und Nicht-Technikern, um eine gemeinsame Vorstellung der Anforderungen zu schaffen.

Um jetzt den Blog Post nicht zu einem Roman zu machen, überlasse ich es euren Fähigkeiten, euch zu überlegen wie man die letzten paar Anforderungen überprüft und implementiert. Note: Dies ist Pseude-Code.

Test Driven Development

Test-Driven-Development und Behavioural-Driven-Development können oft verwechselt werden. Jedoch ist das nur ein semantischer Unterschied. BDD wurde vom TDD abgeleitet und mehr auf die Benutzer-auf-System-Sicht fokussiert. Mit BDD lassen sich Fehlverhalten des Benutzers nachbilden, sollte mal ein Fehler auftreten oder ein Test schief laufen. TDD allerdings fokussiert sich viel mehr auf die technische Schicht. Ganz häufig kommt TDD zum Einsatz, wenn man sequenziell Daten schreiben will, externe APIs ansprechen will und mit den Empfangenen Daten was verarbeiten will oder verschiedene System-Komponenten (RabbitMQ oder ähnliches) auf Hand und Fuß testen will. Bei TDD ist die Prämisse „Test First“. Wenn man ganz ohne Tests ein komplexes Feature mit einer externen API implementieren möchte, tendiert man häufig dazu, den Debugger oder mit Logs / Consolen-Outputs zu arbeiten. Das ist für den Moment wirksam, doch sobald das Feature abgeschlossen und released ist, ist es extrem anfällig für Fehler. Die Debugging-Session von vor 4 Monaten geht von vorne los. Mit TDD hat man anstattdessen aussagekräftige Tests, die genau sagen an welcher Stelle das Feature fehlgeschlagen ist. „Externe API Version hat sich geändert?“ Test sagt genau das! „Schema der empfangenen Daten hat sich geändert?“ Test sagt genau das! „Sequenzielles befüllen der Datenbank schlägt fehl weil du einen neuen Unique-Constraint eingeführt hast?“ Test sagt genau das!

Fazit

Testgetriebenen Entwicklungsansätze sind eine Denk- und Arbeitsweise für Entwickler, wie sie langfristig erfolgreich Features entwickeln, gleichzeitig diese offen für Anpassungen und Erweiterungen lassen.Zwar dauert die Entwicklung mit TDD im Vergleich zur herkömmlichen Methode etwa 25 % länger, was bei einem 3-Tage-Feature zusätzliche 6 Stunden bedeutet. Diese Zeitinvestition zahlt sich jedoch aus: Die heute investierten 6 Stunden ersparen in Zukunft 3 Bug-Tickets, die jeweils 6–8 Stunden für Reproduktion und Fix benötigen würden. So wird die Fehlerbehebung effizient minimiert.

Während TDD besonders effektiv für die Entwicklung neuer Features ist, um von Anfang an eine solide und flexible Codebasis zu schaffen, gibt es für das Testen bestehender Funktionen andere bewährte Ansätze. Regressionstests stellen sicher, dass Änderungen keine unerwarteten Fehler in bereits funktionierendem Code verursachen, und exploratives Testen bietet eine kreative Möglichkeit, Schwachstellen oder unerwartete Probleme in bestehenden Features aufzudecken. Beide Methoden ergänzen TDD und sorgen für eine umfassende Teststrategie, die Qualität und Stabilität langfristig sichert.

Testing ist ein unverzichtbarer Bestandteil der Softwareentwicklung, da es nicht nur Fehler frühzeitig aufdeckt, sondern auch die Qualität und Sicherheit jeder Anwendung maßgeblich verbessert. Durch systematisches Testen wird gewährleistet, dass Funktionen wie vorgesehen arbeiten, unvorhergesehene Probleme verhindert werden und Sicherheitslücken geschlossen bleiben. Regelmäßige Tests bieten Entwicklern und Nutzern gleichermaßen Vertrauen in die Zuverlässigkeit und Stabilität des Systems, was letztlich zu einem besseren Produkterlebnis führt.