Warum ich DTI so mag!

Die tolle Abkürzung DTI ist meine eigene Erfindung. Was sie bedeutet will ich jetzt noch nicht erläutern. Ich halte es für eindrucksvoller, wenn Ihr Euch die Bedeutung nach und nach aus dem Artikel erschließt.

Wie Entwickler denken

Welcher Entwickler hat sich nicht schon mal in folgender Situation gesehen: es existiert ein Katalog von Anforderungen. In Windeseile hat man eine schöne Applikation gestrickt, die all das erfüllt und man ist stolz darauf, dass sie sogar ein kleines Bisschen mehr kann. Wow, sogar eine Woche schneller. Ich hab also echt viel Zeit für ausgiebige Tests und eine super Dokumentation. Zeigen wir's mal dem Kunden, dann kann ich mit dem letzten Schliff anfangen. Der Kunde ist natürlich begeistert, freut sich über dieses und jenes, wird inspiriert und bringt noch ein tolles Feature mit ein. Aufwendig ist es nicht, also baut man es halt noch schnell rein. Peng, jetzt knallt's. Mit diesem einen, dreimal-verfluchten Feature hat man sich die Anwendung zerschossen, muss schließlich doch größere Umbauten vornehmen, sucht verzweifelt nach Fehlern, die vorher nicht da waren und völlig unerklärlich sind. Resultat: die Anwendung wird mit Müh' und Not fertig, Tests und Dokumentation fehlen oder sind nur notdürftigst zusammengestopselt. Aus der schönen Anwendung wird eine klebrige Dauerbaustelle, der Kunde immer unzufriedener und selbst hasst man den Tag, an dem dieses Unglücksfeature erdacht wurde.

Der eine oder andere mag sich auch in folgender Situation wiederfinden: der Chef fragt: "Wie lange brauchst Du noch?" und man entgegnet lässig: "Ich bin fast fertig, nur noch die eine Routine auf den neuen Parameter umgestellt, und dann halt noch die Tests und die Doku. So in zwei, drei Tagen ist alles paletti!" Tja, die zwei, drei Tage vergehen, die ganze Woche vergeht. Auf unerklärliche Weise wird man weder mit den Tests noch mit der Doku noch mit diesem verflixten Programm fertig. Kaum hat man das eine soweit, fällt einem ein, dass man das andere noch mal anpassen muss.

Was läuft da schief? Was ist das, warum man immer wieder so in arge Bedrängnis und Zeitnot gerät? Schauen wir uns doch einmal an, wie so ein Entwickler denkt. Sagen wir ihm zum Beispiel, er soll einen Kommandozeilen-Client entwickeln, der unter anderem sich authentifizieren, eine Liste von Mandanten anzeigen und einen Mandanten auswählen soll. Ich würde mir ungefähr folgendes denken:
"Ich muss den Client aufrufen, der soll client heißen. Argumente sind ungeschickt abzufragen, daher nehme ich lieber Optionen. Der Client muss wissen, was er tun soll; also brauche ich eine Option -cmd. Für die Anmeldung brauche ich einen Benutzer (Option -user) und ein Kennwort (Option -pwd). Um den Mandanten einzugeben brauche ich auch eine Option, -mandant zum Beispiel." Kurz gesagt, der Entwickler denkt schon viel zu sehr über das WIE? nach, bevor das WAS? klar ist. Um den Mandanten auzuwählen muss man dann einen Mordsschlauch eintippen:

Eingabe:
client -cmd=selectMandant -user=ich -pwd=sagichnich -Mandant=1.

 

Wir haben gesehen, wie es aussieht, wenn man über das WIE? nachdenkt. Aber wie sieht es aus, wenn man über das WAS? nachdenkt? Der erste Schritt dazu ist, sich in die Rolle des Anwenders zu versetzen: "Ich möchte dem Client sagen, er soll einen bestimmten Mandanten auswählen. Das geht mit <client waehleMandant 1>. Und zurück haben will ich genau den ausgewählten Mandanten."

Eingabe
client waehleMandant 1
Ausgabe
Mandant ID: 1
Name : Mustermandant

 

Bis hierher muss ich noch gar nicht über das WIE? nachdenken. Und das brauche ich auch gar nicht. Mit dem WAS? kann ich sogar noch weiter machen, indem ich mir vorstelle: "Hm, den Mandanten 1 gibt's vielleicht gar nicht. Was soll mir das System dann sagen? Auf jeden Fall will ich keinen Stacktrace sehen, sondern eine schöne Meldung."

Eingabe
client waehleMandant 1
Ausgabe
Es existiert kein Mandant mit der ID 1!

 

Angesport davon kann man jetzt richtig destruktiv werden und sich überlegen, wie man dieses Kommando sonst noch sabotieren kann. Zum Beispiel, in dem man die Mandanten-ID weglässt oder ... tja, der Aufruf bietet sonst keine Schwachstellen, sieht man einmal davon ab, dass client oder waehleMandant falsch geschrieben wurden. Das eine wird von der Kommandoshell abgefackelt und das andere muss der Client sowieso für alle Kommandos sauber bedienen. Und ich muss mir immer noch nicht überlegen, wie ich das implementiere.

Die drei Kernfragen

Nehmen wir jetzt einfach an, ich bin mit meinen Überlegungen über das WAS? am Ende. All das, was ich mir überlegt habe, möchte ich nicht vergessen, also sollte ich es aufschreiben. Dokumentieren ist das Stichwort und somit - Überraschung - der erste Buchstabe aus der Abkürzung DTI.

Ein Argument höre ich immer wieder, das (vermeintlich) gegen die Erstellung der Doku vor der Implementierung spricht: wenn die Doku zuerst geschrieben wird, aber ich weiß noch nicht, wie ich es implementiere und ich muss anders implementieren, dann muss ich die Doku ständig anpassen. Zugegeben, das Argument leuchtet schon ein. Problematisch ist an dieser Stelle, dass einfach falsch dokumentiert wird. Eine gute Dokumentation ist aber auch schwer zu erstellen.

Eine gute Dokumentation zeichnet sich dadurch aus, dass sie verständlich sein muss, ohne die Implementierung zu kennen. Über die Doku muss sich also die Black Box "MeineAnwendung" vollständig erschließen. Eigentlich dürfte es dann keine Frage sein, dass sich eine solche Dokumentation am Besten schreiben lässt, wenn die Implementierung nicht bekannt ist. Also am Besten noch bevor es überhaupt eine Implementierung gibt.

Trotzdem ist es immer noch schwer, überhaupt damit anzufangen. Wesentlich dafür ist zunächst die Überlegung, auf welcher Ebene ich mich befinde und wo die Doku dazu liegt. Von oben nach unten würde ich 5 verschiedene Ebenen unterscheiden: Meta-, Applikations-, Package-, Klassen- und Memberebene. Die erste, die Metaebene, ist die Dokumentation zur Dokumentation. Mit ihr würde ich anfangen. Auf jeder dieser Ebenen kann man auf die folgenden drei Fragen eingehen:

Wozu ist das gut, was ich gerade mache (Verantwortlichkeit, Nutzen)? Was will ich erreichen?
Das ist die Frage nach der Verantwortlichkeit, oder dem Nutzen. Auf Meta- und Applikationsebene ist dieses Frage sicher ausführlicher zu beantworten. Auf den unteren Ebenen sollte der erste Satz auf jeden Fall kurz und prägnant alles zusammenfassen, da dieser Satz in der JavaDoc für die Übersichtsanzeigen herangezogen wird. Die späteren Sätze sollten dann genauer werden.
In jedem Fall ist das die wichtigste Information: wenn nicht klar ist, wofür etwas gut ist, wird es auch niemand benutzen wollen.
Interessant ist sicher auch die Frage: wozu ist es nicht gut? Eine Abgrenzung verdeutlicht nochmals den Zweck.
Wie will ich es benutzen? Worauf muss ich bei der Benutzung achten?
Auf Metaebene ist die Antwort klar: lesen. Auf Applikationsebene zielt das eindeutig auf das Betriebshandbuch, das hier den Rahmen sprengen würde. Auf Packageebene kann Erwähnung finden, wie die öffentlichen Klassen und Interfaces zusammenspielen, wie konkrete Implementierungen instanziiert werden oder wie man mit Interfaces umgeht, die außerhalb zu implementiern sind. Auf Klassenebene könnten beispielsweise Statusübergänge beschrieben werden, welche Methoden in welcher Reihenfolge aufzurufen sind oder wann und wie die Klasse resetted werden darf. Auf Memberebene könnte u. a. betrachtet werden, welche internen Parameter unter welchen Umständen Verwendung finden, oder ob und welche Teile von Parametern benutzt werden, oder welche Parameter welchen Einfluss auf das Ergebnis haben.
Kurz gesagt, hier kann man alles aufführen, was in irgendeiner Form für die Benutzung wichtig ist.
Welcher Vertrag gilt (Vorbedingungen, Nachbedingungen, Invarianten)?
Diese Frage wird sich für Meta-, Applikations- und Packageebene in der Regel nicht stellen. Für Klassen sind primär die Invarianten interessant, wohingegen Vor- und Nachbedingungen für Members wichtig sind. Den Vertrag kann man gut in strukturierter Form erfassen, am Besten mit Annotations.

Obwohl mit diesen drei Fragen rein die Black-Box-Sicht beschrieben wird und gute Chancen bestehen, dass es nicht notwendig ist, umzudisponieren, kann es dennoch notwendig sein. Da dies jedoch nur selten der Fall sein wird, kann man die paar Mal durchaus in den sauren Apfel beißen. Wenn man feststellt, dass es häufiger passiert, sollte man sich vorher einen Prototypen basteln.

Test, Test, 1, 2, 3

So, was haben wir bisher? Wir wissen jetzt, was der Benutzer will, wenn er unser neues Feature benutzt. Wir wissen, was er erwartet, wenn er einen Fehler macht oder das Programm auf einen Fehler stößt. Wir wissen jetzt genau, wie die Definitions- und Wertebereiche aussehen. Und wir haben jetzt schon eine ganze Menge Testfälle, die wir leicht aus der Dokumentation ableiten können mit jeder neuen Version durchführen müssen. Wenn ich schon soweit bin, mit den Testfällen, dann kann ich sie auch gleich implementieren.

Wir sind also jetzt beim zweiten Buchstaben aus DTI, dem Testen, angelangt. Auf Grund der genauen Beschreibung, WAS? getan werden soll, können wir sehr einfach die Tests schreiben. Es sollte sich dabei lediglich um eine Fleißaufgabe handeln. Natürlich ist es interessant, gewisse technische Probleme zu lösen. Zum Beispiel müssten bei unserer beispielhaften Teilspezifikation von oben die Ausgabetexte auf die Konsole überprüft werden. Für die Integrationstests auf der Kommandozeile hebe ich mir meist nur das Ausgabeprotokoll auf, so dass man sehen kann, welcher Befehl zu welcher Ausgabe geführt hat. Aber in den Unittests vergleiche ich durchaus die Texte, die in der Ausgabe landen. Das ist kein Problem, wenn man nur mit einer einzigen Sprache auskommt, aber bei Internationalisierung wird das unter Umständen schon etwas schwieriger.

Bisher habe ich mir über die echte Implementierung noch keine Gedanken gemacht. Ich habe immer noch keine Lust, das zu tun, denn ich will ja erst mal testen. Gehen wir mal anhand eines Beispiels vor.

Der Kommandozeilen-Client ist schon relativ gut definiert und die ersten Testfälle sind einfach abzuleiten. Testfall 1 soll sein:

Eingabe:
client waehleMandant
Ausgabe
Bitte geben Sie die Mandanten-ID an.

 

Um den Testfall automatisiert genau so testen zu können, bräuchte ich eine Kommandozeilen-Emulation, über die ich die Kontrolle über Ein- und Ausgabe habe. Ich entschließe mich aber dafür, den Batch separat zu testen und dafür die Kommandozeile in der Weise zu simulieren, dass ich die main-Methode der Hauptklasse aus Java-Testcode heraus aufrufe. Die main-Methode nimmt ein Array von Strings entgegen; "client" kommt darin nicht vor, denn dieses Wort selektiert die Batch-Datei, die dann den echten Java-Aufruf durchführt. Der einzige Parameter an die main-Methode ist also "waehleMandant". (An der Stelle fallen mir gleich noch zwei Testfälle ein: <client> und <client unbekanntesKommando>, die ich auch gleich als Aller-Erstes dokumentiere.) Ein Teil meines Tests wird vermutlich so aussehen:

@Test

public void testMain() {

    Sring[] arguments = new String[] {"waehleMandant"};

    Main.main(arguments);

}

 

Die Ergebnisse zu überprüfen fällt an dieser Stelle ziemlich schwer. Zum Einen hat main() keinen Rückgabewert, weshalb ich auf diesem Wege kein einziges Ergebnis zum Vergleichen bekomme. Dennoch gibt es einige Ergebnisse, die ich gerne prüfen möchte, zum Beispiel den Exit-Code mit dem System.exit() aufgerufen wird, dann natürlich die Ausgabe auf die Konsole und schließlich soll bei späteren korrekten Aufrufen der ausgewählte Mandant ja auch dauerhaft ausgewählt sein. Da ich mir bisher über die Implementierung noch keine Gedanken gemacht habe, habe ich auch noch keine Ahnung, wie ich die drei Überprüfungen vornehmen soll. Deshalb beschließe ich, mich zunächst damit zu bescheiden, die Ausgaben mitzuprotokollieren und den ersten Integrationstest so stehen zu lassen. Zur Überprüfung muss halt das Testprotokoll gelesen werden.

Da die main-Methode durch den voraussichtlichen Aufruf von System.exit() nicht besonders gut zu testen ist, möchte ich die Verantwortlichkeit von Main.main() möglichst gering halten. Am Besten enthält sie nur die Instanziierung von Main, das Delegieren der Verarbeitung und am Besten auch das Delegieren vom Aufruf von System.exit(). Wem es noch nicht aufgefallen ist, hier mache ich mir zum ersten Mal Gedanken über die echte Implementierung von irgendwas. In diesem Falle von Main.main(). Ich bin also endlich, endlich beim I von DTI, der Implementierung, angelangt. Aber nicht für lange, denn bevor ich die Implementierung von Main.main() vornehme, schreibe ich in die JavaDoc von Main.main() meine Gedanken von vorhin hinein.

/**

 * <p>Ermöglicht den Programmstart aus dem Start-Script des jeweiligen

 * Betriebssystems heraus. Instanziiert <code>Main</code>, startet die

 * Verarbeitung und ruft das Programmende auf.</p>

 *

 * <p>Wird lediglich aus dem Start-Script des jeweiligen Betriebssystems

 * aufgerufen. Kein direkter Aufruf aus anderen Methoden.</p>

 *

 * <dl>

 *   <dt>Vorbedingungen</dt>

 *   <dd>theArguments != <code>null</code></dd>

 * </dl>

 *

 * @param theArguments aus der Kommandozeile

 */

public void static main(final String[] theArguments) {

}

 

Das Tüpfelchen auf dem i

Jetzt, da ich die Dokumentation zu Main.main() fertig habe, und meine "Tests" gewissermaßen abgeschlossen habe, kann ich zuletzt die Implementierung vornehmen.

public void static main(final String[] theArguments) {

    Main client = new Main();

    client.start(theArguments);

    client.end();

}

 

Das ist nur das Gerüst der main-Methode. Später wird sich erweisen, dass es für das Testen günstig ist, den Exit-Code aus der start-Methode an die end-Methode zu übergeben. Eine andere Möglichkeit wäre, den Exit-Code als statische Member zu halten. Um dann noch testbar zu bleiben, dürfte diese Member dann keine Private, sondern müsste Package-Visible sein, was aber nicht schön ist und wir deshalb vermeiden möchten.

public void static main(final String[] theArguments) {

    Main client = new Main();

    ExitCode exitCode = client.start(theArguments);

    client.end(exitCode);

}

 

Spätestens jetzt sollte ich mir Gedanken darüber machen, welche Verantwortlichkeit die Klasse Main hat und diese Dokumentieren.

/**

 * <p>Verantwortlich für den Programmstart und das Programmende. Delegiert

 * das Aufbauen der Konfiguration und den echten Programmsablauf an die

 * entsprechenden Klassen. Gibt lediglich den Exit-Code an das Start-Script

 * zurück. Ist nicht dafür verantwortlich, weitere Ausgaben an den Benutzer

 * zu leiten.</p>

 *

 * <p>Diese Klasse ist nur für die Verwendung aus einem Start-Script heraus

 * vorgesehen. Wird der Kommandozeilen-Client aus einer anderen Applikation

 * heraus benötigt, so ist die Klasse {@link Client} zu verwenden.</p>

 *

 * <p>Es gibt folgende Exit-Codes mit den entsprechenden Bedeutungen:</p>

 * <dl>

 *   <dt>0</dt><dd>fehlerfrei</dd>

 *   <dt>1</dt><dd>fehlerhafte Benutzereingaben</dd>

 *   <dt>2</dt><dd>Programmfehler aufgetreten</dd>

 * </dl>

 */

public class Main {

}

 

Bis jetzt sind noch keine Invarianten bekannt. Falls später welche Auftreten, muss ich entsprechend nachdokumentieren. Bevor ich diesen Artikel schließe, möchte ich noch auf die Methoden Main.start() und Main.end() eingehen. Ich beginne wieder mit der Dokumentation.

/**

 * <p>Parsed die Kommandozeilen-Parameter, baut die Konfiguration auf und

 * startet die Anwendung. Gibt einen Exit-Code zurück, der davon abhängt,

 * wie erfolgreich die Applikation gelaufen ist.</p>

 *

 * <dl>

 *   <dt>Vorbedingungen</dt>

 *   <dd>theArguments != <code>null</code></dd>

 *

 *   <dt>Nachbedingungen</dt>

 *   <dd>result != <code>null</code></dd>

 * </dl>

 *

 * @param theArguments aus der Kommandozeile

 */

ExitCode start(final String[] theArguments) {

    ExitCode result = null;

 

    // Nachbedingung

    assert result != null : "Nachbedingung verletzt: der ExitCode ist [null]!"

    return result;

}

 

/**

 * <p>Beendet die Applikation per {@link System.exit(int)}.</p>

 *

 * <dl>

 *   <dt>Vorbedingungen</dt>

 *   <dd>theExitCode != <code>null</code></dd>

 * </dl>

 *

 * @param theExitCode der an das Start-Script übergeben wird

 */

void end(final ExitCode theExitCode) {

}

 

In die start-Methode habe ich die Prüfung der Nachbedingung sofort hingeschrieben. Nachbedingungen lassen sich durch Tests praktisch nicht erfassen, da sie per Definition immer zutreffen, daher empfiehlt es sich deren Prüfung sofort zu implementieren. Hierbei handelt es sich nämlich nicht um eine echte Implementierung, sondern lediglich um eine Fixierung des Vertrages. Ist man erst mal soweit, lassen sich auch die Tests zur Einhaltung der Vorbedingungen gut dazu schreiben.

@Test (expected=IllegalArgumentException.class)

public void testStartWithNull() {

    Main cut = new Main();

    cut.start(null);

}

 

@Test (expected=IllegalArgumentException.class)

public void testEndWithNull() {

    Main cut = new Main();

    cut.end(null);

}

 

Einen Testfall, der dazu führt, dass System.exit() aufgerufen wird, wird es vermutlich nicht geben, weil sonst die Durchführung der Tests angehalten wird. Für die Methode start() wird es wenigstens drei Testfälle geben, da jeder Exit-Code einmal als Ergebnis überprüft werden sollte. Um den Rahmen nicht zu sprengen möchte ich an dieser Stelle jedoch abbrechen.

In aller Kürze

DTI bedeutet also, zuerst dokumentieren, dann testen und erst zuletzt implementieren. Am Wertvollsten finde ich dabei, dass man mit Ende der Implementiererei wirklich sagen kann, man ist fertig. Weitere Vorteile dieses Vorgehens sind eine saubere Dokumentation mit der man etwas anfangen kann, gut testbarer und damit wartbarer Code, hohe Qualität und gut sichtbarer Entwicklungsfortschritt. Meiner Erfahrung nach resultiert dieses Vorgehen auch in einer hochwertigen Architektur und in einem gut erreichten Open-Closed-Priciple.

Man könnte DTI auch so beschreiben: Keine Implementierung ohne sie vorher getestet zu haben. Und kein Test, ohne vorher zu wissen (dokumentiert zu haben), was zu testen ist.

Manch einem mag durch den Kopf gehen, dass dies alles übertrieben scheint. Für jede Methode alle einzelnen Vor- und Nachbedingungen zu entwerfen, abzutesten und zu implementieren ist enormer Aufwand. Dem kann ich nur widersprechen. Wenn man sich erst mal an dieses Vorgehen gewöhnt hat, fließt es schnell von der Hand, so dass die Entwicklung hiermit kaum mehr Zeit in Anspruch nimmt, als die herkömmliche Entwicklung. In erster Linie erspart man sich langwierige Debugging- und Test-Prozesse, und das ist enorm viel Zeit.