Agile Modellierung mit
UML
Loading

6.1 Einführung in die Testproblematik

Zur Sicherung oder Verbesserung der Qualität von Software gibt es eine Reihe von Verfahren, die während eines Softwareentwicklungsprojekts oder projektübergreifend zum Einsatz kommen können. „Qualität“ ist ein viel gebrauchter Begriff, der jedoch nur schwer zu definieren ist. In [Lig90] werden eine Reihe von Qualitätseigenschaften genannt, von denen unter anderem die zur Produktnutzung wichtigen Eigenschaften funktionale Korrektheit und Robustheit durch Tests überprüft werden können. Weitere Qualitätseigenschaften wie Laufzeit- und Speichereffizienz lassen sich durch statistische Tests zumindest in eingeschränkter Form prüfen.

6.1.1 Testbegriffe

Abbildung 6.1 enthält mehrere Definitionen des Begriffs „Test“ aus der Literatur.

In der Literatur sind unterschiedliche Definitionen für den vielfach gebrauchten Begriff Test und die Tätigkeit Testen zu finden (teilweise ins Deutsche übersetzt):
  • Testen ist der Prozess, ein Programm mit der Absicht auszuführen, Fehler zu finden.“ [Mye01, S. 4]
  • Testen von Software ist die Ausführung der Softwareimplementierung auf Testdaten und die Untersuchung der Ergebnisse und des operationellen Verhaltens, um zu prüfen, dass die Software sich wie gefordert verhält. [Som10]
  • „Die Anwendung von Test-, Analyse- und Verifikationsverfahren dient im wesentlichen (sic) zur Überprüfung der Qualitätseigenschaften funktionale Korrektheit und Robustheit.“ [Lig90, S. 17]
  • Ein Test ist der Entwurf und die Implementierung einer speziellen Form eines Softwaresystems. Es prüft ein anderes Softwaresystem mit dem Ziel, Fehler zu finden. Tests werden entworfen, um das zu testende System zu analysieren und zu entscheiden, wie fehlerhaft es wahrscheinlich ist. Testentwürfe stellen Anforderungen an das automatisierte Testsystem, das die Tests automatisiert anwendet und evaluiert. Das Testsystem muss so entworfen werden, dass es mit den physischen Schnittstellen, der Struktur und der Laufzeitumgebung des zu testenden Systems zusammenarbeitet. Natürlich spielen manuelle Tests immer noch eine Rolle, aber Testen bedeutet hauptsächlich die Entwicklung eines automatisierten Systems, das anwendungsspezifische Tests implementiert. [Bin99, S. 41]
  • Es gibt zwei Arten von Tests: (1) Unit-Tests und (2) Akzeptanztests. Entwickler schreiben Unit-Tests gemeinsam mit dem Code. Anwender schreiben Akzeptanztests nachdem die Anwendungsfälle definiert sind. [AM01, S. 6]
  • „Unter Testen versteht man den Prozeß (sic) des Planens, der Vorbereitung und der Messung, mit dem Ziel, die Merkmale eines IT-Systems festzustellen und den Unterschied zwischen dem aktuellen und dem erforderlichen Zustand nachzuweisen.“ [PKS02, S. 528]
Abbildung 6.1: Begriffsdefinitionen für „Tests“

Typisch für den Extreme Programming-Ansatz ist das Fehlen einer eigenschaftsorientierten Definition des Begriffs „Test“. Stattdessen wird eine operative Beschreibung angegeben, die gleichzeitig festlegt, wer Tests entwickelt, und sich dabei auf zwei einfache Testarten einschränkt [AM01].

Besonders die Definition von [Bin99] ist als Grundlage für dieses Buch geeignet. Daraus lassen sich die in Abbildung 6.2 zusammengestellten Charakteristika für Tests ableiten, die in diesem Buch als Grundlage dienen. Es gibt allerdings eine Reihe von Ausnahmen dieser Charakterisierung, die ebenfalls als Tests bezeichnet werden. Dazu gehören manuelle, interaktive Tests, die zum Beispiel durch den Anwender beim Abnahmetest durchgeführt werden. Eine weitere Alternative ist das symbolische Testen, bei dem symbolische Berechnungen stattfinden, damit also das zu testende System nicht wirklich „abläuft“. Lasttests in real verteilten Systemen produzieren typischerweise nicht immer dieselben Ergebnisse, sondern ermitteln durch wiederholte Ausführung Durchschnittswerte. All diese Testarten sind nicht Gegenstand dieses Kapitels.

Die Definition vollständig automatisierter Tests gewinnt in den letzten Jahren immer mehr an Gewicht. So enthält [FG99] eine ausführliche Motivation und eine detaillierte Abgrenzung zu semi-automatischen oder manuellen Vorgehensweisen.

  1. Ein Test lässt – im Gegensatz zu einer statischen Analyse – das zu testende System ablaufen.
  2. Tests sind automatisiert. Da bei großen Systemen manuelle Tests sehr zeitraubend sind, würde sonst die Qualität der Tests leiden oder die Projektbeteiligten nur noch Tests durchführen.
  3. Ein automatisierter Test führt den Aufbau der Testdaten, den Test und die Prüfung des Testergebnisses selbständig durch. Der Erfolgsfall beziehungsweise das Scheitern werden durch den Testlauf erkannt und gemeldet.
  4. Eine Sammlung von Tests bildet selbst ein Softwaresystem, das gemeinsam mit dem zu prüfenden System abläuft.
  5. Ein Test ist exemplarisch. Er arbeitet auf einem Satz von Eingabedaten, den Testdaten.
  6. Ein Test ist wiederholbar und determiniert. Er produziert für dasselbe zu testende System immer dieselben Ergebnisse.
  7. Ein Test ist zielorientiert. Entweder er demonstriert die Anwesenheit und Auswirkungen eines Fehlers oder er zeigt, dass das System für den Testfall die geforderte Funktionalität hat und bezüglich der Testdaten robust ist.
  8. Ein Test kann bei einem modifizierten System exemplarisch die Verhaltensgleichheit mit dem Ursprungssystem nachweisen und so bei der Vermeidung von Fehlern während der Weiterentwicklung helfen.
Abbildung 6.2: Charakterisierung von Tests

Wichtig ist auch die Unterscheidung zwischen der Aktivität des Testens mit dem Ziel der Fehlererkennung und der Fehlerbehebung, die nach dem Testen folgt. Weitere Maßnahmen zur Qualitätssicherung sind die Code-Inspektion, in der der Quellcode von Entwicklern auf mögliche Fehler untersucht wird, und die Verifikation, die für industrierelevante Systeme normalerweise (oder besser gesagt „noch“) nicht durchführbar ist, aber im Gegensatz zum Test die vollständige Korrektheit einer Implementierung gegenüber einer Spezifikation nachweisen könnte.


Tabelle 6.3: Tests auf verschiedenen Ebenen im System

Tabelle 6.3 ordnet Testbegriffe nach der Art des zu testenden Systemelements und charakterisiert, wer normalerweise diese Tests entwerfen und durchführen sollte. Je nach verwendetem Entwicklungsprozess liegt die Gewichtung der Testentwicklung mehr auf einem eigenständigen Testteam oder bei den Entwicklern selbst.

6.1.2 Ziele der Testaktivitäten

Im Portfolio der Qualitätssicherungsmaßnahmen spielen Tests eine wesentliche Rolle, da eine systematische, zielgerichtete Erstellung und Durchführung automatisierter Tests mit vertretbarem Aufwand möglich ist. Testcode übertrifft meist die Größenordnung von dem zu testenden Code, jedoch ist er deutlich einfacher strukturiert und im Gegensatz zum Produktionscode ist seine Eleganz und Redundanzfreiheit deutlich weniger wichtig. So ist es akzeptabel, dass Tests im Copy-und-Paste-Verfahren entwickelt werden [Den91Fow99], obwohl es sich auch bei Tests lohnt, wiederverwendbare Abstraktionen in eigene Methoden auszulagern. Da das Auktionssystem mit hohen Geldbeträgen arbeitet und strengen zeitlichen Rahmenbedingungen unterliegt, wurde dort die notwendige Qualität in Bezug auf Fehlerfreiheit der Software besonders hoch eingeschätzt. Dementsprechend ist 63% des insgesamt entwickelten Codes Teil des Testsystems.1

Die zusätzlichen Kosten für die Entwicklung und Wartung einer automatisierten Testsammlung sind dementsprechend vertretbar. Eine Untersuchung in [KFN93] schätzt ab, dass der Break-Even zwischen Kosten und Nutzen automatisierter Tests gegenüber etwa zehn manuellen Testdurchgängen erreicht wird. Bei sich dynamisch weiterentwickelnden Systemen, bei denen auch nach einer Installation noch Änderungen vorgenommen werden sind zehn manuelle Testdurchgänge schnell erreicht. Durch die mittlerweile zur Verfügung stehenden Werkzeuge und Frameworks wie JUnit [JUn11BG98BG99] steigt außerdem die Effizienz der Testfalldefinition, so dass der Break-Even weiter gesenkt werden dürfte. Vorteile automatisierter Tests sind:

  • Fehler in der Programmlogik, bei Randwertbetrachtungen und falschen Codierungen werden frühzeitig und fast immer bereits mit der Entstehung ausgemerzt.
  • Das Zutrauen der Entwickler in den eigenen Code sowie den Code von Kollegen ist aufgrund der vorhandenen Tests signifikant höher als üblich.
  • Das Selbstvertrauen eines Entwicklers, auch den nicht von ihm selbst entwickelten Code auf geänderte Anforderungen anzupassen, steigt aufgrund automatisiert wiederholbarer Tests und dem darin eingebetteten Wissen über die Systemfunktionalität.
  • Eine ausführliche Sammlung von Testfällen lässt sich neben der eigentlichen Systemspezifikation als zweites Modell für das System verstehen. Dieses Modell enthält zwar nur exemplarische Beschreibungen des Systems und diese sind sehr implizit im Testfall verborgen. Es hat jedoch den unschätzbaren Vorteil der Ausführbarkeit.
  • Ein gescheiterter Test kann als Fehlerbeschreibung verstanden werden, der das Fehlersymptom dokumentiert. So können Nutzer einer Schnittstelle gegenüber den Implementierern Fehler nachweisen und die Behebung des Fehlers sehr einfach prüfen.
  • Automatisierte Tests sind für die wiederholte Prüfung des Systemverhaltens bei einem sich weiterentwickelnden System unverzichtbar. Interaktive Regressionstests würden den wiederholten Testaufwand so vergrößern, dass dafür im Verlauf des Projekts sehr viel personelle Ressourcen gebunden wären.
  • Nach [Fow99] sowie eigener Erfahrung ist es hilfreich, den Einstieg in fremden und insbesondere ungetesteten Code dadurch vorzunehmen, dass auf Basis eines Codereviews das erwartete, aber gegebenenfalls in Details unklare Verhalten des Systems in Form von neu definierten Testfällen geprüft wird. Das dadurch entwickelte Verständnis für den Code ist intensiver und als Nebeneffekt entstehen (weitere) Tests.
  • Letztendlich ist eine ausführliche Testsammlung auch eine Dokumentation dem Kunden gegenüber, der zwar normalerweise nicht über die Kapazität und das Wissen verfügt, die Testfälle, wohl aber die Erfolgsmeldungen der Tests zu verstehen. Kunden sind dadurch auch leichter in der Lage, spätere Verbesserungen und Erweiterungen am System durch andere Entwickler vornehmen zu lassen.

Die Entwicklung von Tests ist zielorientiert. Während Tests für einzelne Methoden, Klassen und kleine Subsysteme vor allem dazu dienen, Fehler zu entdecken und für eine Behebung herauszuarbeiten und damit zu dokumentieren, dienen Integrations- und Systemtests vor allem dazu, zu demonstrieren, dass Fehler (weitgehend) abwesend sind und dass das implementierte System sich den vorgegebenen Beschreibungen/Spezifikationen gemäß verhält.

6.1.3 Fehlerkategorien

In einem Softwaresystem können mehrere Fehlerkategorien unterschieden werden. Abbildung 6.4 enthält eine Begriffsbestimmung für die wichtigsten Fehlerkategorien.

Versagen
(engl.: failure) ist die Unfähigkeit eines Systems oder einer Komponente eine geforderte Funktionalität in den spezifizierten Grenzen zu erbringen. Versagen manifestiert sich durch falsche Ausgaben, fehlerhafte Terminierung oder nicht eingehaltene Zeit- und Speicher-Rahmenbedingungen.
Mangel
(engl.: fault) ist ein fehlender oder falscher Code.
Fehler
(engl.: error) ist eine Aktion des Anwenders oder eines Systems der Umgebung, das ein Versagen herbeiführt.
Auslassung
(engl.: omission) ist das Fehlen von geforderter Funktionalität.
Überraschung
(engl.: surprise) ist Code, der keine geforderte Funktionalität unterstützt und daher nutzlos ist.
Abbildung 6.4: Begriffsdefinitionen für Fehler nach [Bin99]

Ein Mangel in der Software drückt sich dadurch aus, dass er bei Ausführung des mangelhaften Codes zu einem Versagen des Softwaresystems führen kann. Mit einem Test lässt sich das Versagen von Software in der Testsituation erkennen und darauf zurückschließen, dass die Software einen Mangel besitzt. Ein Versagen kann auf eine Kombination von Mängeln zurückzuführen sein. Umgekehrt kann derselbe Mangel zu unterschiedlichen Formen des Versagens führen, so dass mitunter detektivische Arbeit notwendig ist, um den Ort eines Mangels einzugrenzen. Zum einen kann dies durch Debugging mit manueller Verfolgung von Einzelschritten im System erfolgen. Besser ist es jedoch, Tests auf jeder Ebene des Systems verfügbar zu haben, so dass ein Mangel bereits bei der kleinsten möglichen Systemkonfiguration erkannt wird. Durch die Definition weiterer Tests kann ein Mangel gezielt lokalisiert werden.

Oft wird auch der Begriff Bug als Oberbegriff für Versagen und Fehler definiert.2

Auslassungen und Überraschungen lassen sich nicht durch automatisierte Tests feststellen. Für die Erkennung von Auslassungen sind formale oder informelle Beschreibungen der geforderten Funktionalität notwendig, die für eine statische Analyse oder einen vergleichenden Review verwendet werden. Beispielsweise werden Auslassungen durch nicht übersetzbare Programme und durch Abnahmetests entdeckt. Überraschungen sind demgegenüber weniger problematisch. Sie führen zwar bei der Systementwicklung zu unnötigem Mehraufwand, stören aber die wesentliche Funktionalität nicht. Statische Analysen können zum Beispiel unerreichbaren Code in einer Methode erkennen.

6.1.4 Begriffsbestimmung für Testverfahren

Im Kontext von Tests gibt es eine Reihe weiterer Begriffe, die teilweise auch in diesem Kapitel bereits verwendet wurden und noch einer Klärung bedürfen. Abbildung 6.5 beschreibt die wesentlichsten in Kurzform.

Validierung
dient zur Prüfung, ob das System die vom Anwender geforderten Anforderungen erfüllt (nach [Boe81]). Dies geschieht zum Beispiel durch Prototyping während des Projekts und Abnahmetests an dessen Ende.
Verifikation
dient zum Nachweis, dass das implementierte System die formale Spezifikation erfüllt, also korrekt ist (nach [Boe81]).
System im Test
wird auch das „zu testende System“, Testling [Den91Bal98], Prüfling [Lig90] und Testobjekt [PKS02] genannt.
Testverfahren
ist eine Vorgehensweise zur Erstellung und Durchführung von Tests. Die Testtheorie kennt eine Reihe von Verfahren, die speziell die Entwicklung von Testdaten behandeln.
Testdaten
(engl.: test point, [Bin99]). Die Testdaten bestehen aus einem konkreten Satz von Werten für die Eingabe eines Tests, die auch die Objektstruktur mit den zu testenden Objekten beinhaltet.
Test-Sollergebnis
ist das erwartete Ergebnis eines Tests. Dieses kann explizit durch einen Datensatz oder implizit durch ein Prüfprädikat zum Beispiel als Vergleich mit dem Ergebnis eines Testorakels gegeben sein.
Testfall
(engl.: test case) besteht aus einer Beschreibung des Zustands des zu testenden Systems und der Umgebung vor dem Test, den Testdaten und dem Test-Sollergebnis.
Testsammlung
(engl.: test suite) ist eine Menge von Testfällen.
Testlauf
(oder auch Testablauf, engl.: test run) ist die Durchführung eines Tests einschließlich der tatsächlichen Ergebnisse (Test-Istergebnisse). Ein Testtreiber organisiert die Durchführung vom Aufbau der Testdaten bis zur Prüfung des Testerfolgs.
Testerfolg
ist genau dann eingetreten, wenn das Istergebnis und das Sollergebnis konform sind. Ansonsten ist der Test gescheitert.
Testurteil
ist die binäre Aussage, ob der Test erfolgreich war oder gescheitert ist.
Abbildung 6.5: Begriffsdefinitionen für Tests

Der Begriff „Testling“ ist zwar ein Kunstwort, trifft aber die Bedeutung des zu testenden Systems beziehungsweise der Systemkomponente, weshalb in diesem Buch dieser Begriff von [Den91] übernommen wird.4

Besonderer Beachtung bedarf, dass das Scheitern eines Tests bedeutet, dass Test und Implementierung nicht konform sind oder während des Tests eine unerwartete Exception aufgetreten ist. Damit ist der Test oder die Implementierung mangelhaft, dies bleibt zu klären. In [Mye79] wird darauf hingewiesen, dass ein in diesem Sinn gescheiterter Test seinen Testzweck, nämlich die Fehlerfindung erfüllt hat und somit als Erfolg für den Test gewertet werden kann.

6.1.5 Suche geeigneter Testdaten

Ein wesentlicher Problemkreis beim Testen ist die effiziente Entwicklung einer systematischen und alle wesentlichen Fälle überdeckenden Sammlung von Testfällen. Sind für einen Testfall die Testdaten gegeben, so ist das Sollergebnis meist aus der Spezifikation abgeleitet oder vom Entwickler beziehungsweise Anwender festgelegt worden. Die Vorgabe eines Sollergebnisses kann aufwändig werden und ist fehleranfällig. Jedoch testen sich das Produktionssystem und die Testsammlung gegenseitig, so dass auch Fehler in Testfällen erkannt und behoben werden können.

Als wesentliche Schwierigkeit bleibt daher die Identifikation geeigneter Testdaten und die Festlegung, wieviele Testfälle für eine adäquate Sammlung von Tests ausreichend sind.

Bereits in [Mye79] wurden Heuristiken und Verfahren zur Entwicklung von Testdatensätzen beschrieben, die in [Lig90] und [Bei04] in verfeinerter Form diskutiert werden. Dazu gehören Verfahren, die sich am Kontrollfluss der Implementierung orientieren, indem sie alle Anweisungen, Verzweigungen, Bedingungsvariationen oder Pfade innerhalb einer Methode nach bestimmten Kriterien überdecken. Andere Verfahren identifizieren zusätzlich Äquivalenzklassen von Testdaten und Grenzwertbereiche.

Datenflussorientierte Testverfahren nutzen Attribut- und Variablenzugriffe, um Testdaten zu entwickeln. Den kontrollfluss- und datenflussorientierten Verfahren ist gemeinsam, dass sie die Implementierung des Systems als bekannt voraussetzen und die Erstellung der Testfälle basierend auf der Analyse der Implementierung beruht.

Demgegenüber steht die Klasse der funktionalen oder spezifikationsbasierten Tests. Sie basieren nicht auf der Implementierung, sondern einer Spezifikation und prüfen die funktionalen Eigenschaften eines Systems. Sie erkennen also Konformitätsfehler des Systems, beziehungsweise demonstrieren die Übereinstimmung zu der spezifizierten Funktionalität.

Zur Bestimmung der Qualität einer Sammlung von Tests werden Metriken verwendet, die eine Testüberdeckung nach verschiedenen Kriterien messen. Jedoch ist auch eine vollständige Testüberdeckung nach diesen Metriken keine Garantie für ein korrektes System. Deshalb wird in der Praxis von der eher dogmatischen Testtheorie verstärkt zu einer erfahrungsgetriebenen, zum Beispiel durch Testmuster [Bin99], Checklisten [PKS02] und „Best Practices“ beschriebenen Vorgehensweise übergegangen. Es ist den pragmatischen Testmustern anzumerken, dass Elemente der Testtheorie Eingang gefunden haben, ohne jedoch dogmatisch deren Erfüllung zu 100% zu fordern. So kann zum Beispiel aus dem Extreme Programming-Ansatz gefolgert werden, dass die Anweisungsüberdeckung als Minimalziel gefordert und nach Möglichkeit eine minimale Pfadüberdeckung gewünscht ist.

6.1.6 Sprachspezifische Fehlerquellen

Eine zu obigen Punkten orthogonale Fehlerkategorisierung ergibt sich aus der Frage, ob ein System zum einen robust und zum anderen konform zur Spezifikation ist. Die Robustheit einer Implementierung kann durch anormale Abstürze (Exceptions) aufgrund von nicht initialisierten Attributen, Referenzen auf nicht existierende Objekte und ähnlichen Problemen gestört werden. Typisch für Mängel in der Robustheit ist, dass nicht gegen eine Spezifikation getestet wird, sondern sprachspezifische Fehlerquellen zu eliminieren sind.

C++ ist ein Paradebeispiel für außerordentlich viele Fehlerquellen, die aus der hohen Anzahl von ungesicherten C++-Konstrukten resultieren. Die dem Entwickler überlassene Speicherverwaltung, die Zeigerarithmetik und ungeprüfte Zugriffe auf Felder sind nur einige der möglichen Fehlerquellen.

Java ist, obwohl syntaktisch der Sprache C++ ähnlich, in Bezug auf derartige Fehlerquellen wesentlich robuster. Viele Fehlerquellen werden in Java durch restriktive Kontextbedingungen in der Sprache und damit bereits durch statische Analysen eliminierbar. Zum Beispiel wird durch eine ausgefeilte Datenflussanalyse [GJSB05] geprüft, ob Variablen besetzt wurden, bevor sie benutzt werden. Die ESC/Java-Erweiterung [RLNS00] um Zusicherungen erlaubt eine noch weitergehende statische Analyse, erfordert jedoch eine detaillierte Beschreibung von Zusicherungen im Java-Code. Dennoch kann dadurch die Anzahl der Fehlerquellen weiter reduziert werden.

Weitere Java-Fehlerquellen werden durch Laufzeitüberprüfungen entdeckt und durch Exceptions dem Programm gemeldet. Dazu gehören zum Beispiel das Überschreiten von Arraygrenzen, die Division durch 0 oder illegale Typkonversionen. Damit lässt sich ein Programm relativ leicht robust gestalten. Jedoch sollte die dafür notwendige Verwendung von Exceptions soweit wie möglich begrenzt werden, da die Verarbeitung von Exceptions Charakteristika der goto-Anweisung aufweist und leicht zu unübersichtlichem Code führt. Als generelles Prinzip sollten nur externe Fehlerquellen, wie eine nicht vorhandene Datei, eine nicht erreichbare Datenbank oder eine abgebrochene Internet-Verbindung, mit Exceptions behandelt werden und intern zu verantwortende Fehlerquellen, wie Division durch 0 oder falsche Arraygrenzen durch explizite Abfragen abgesichert werden.

Unabhängig von der Art des Abfangens solcher Fehler ist eine robuste Behandlung des Fehlers und dementsprechender Tests notwendig, die demonstrieren, dass der Fehler korrekt behandelt wird. Auch dafür ist es sinnvoll, möglichst wenig Exceptions durch die Aufrufhierarchie verfolgen zu müssen.

Obwohl Java gegenüber C++ sehr viel sicherer entworfen wurde, gibt es auch in Java eine Reihe von Fehlerquellen. Von diesen Fehlerquellen können viele durch restriktive Programmierung verhindert werden. So sollte in Java ein Attribut der Oberklasse nicht verschattet werden, indem in der Unterklasse ein gleichnamiges Attribut definiert wird.

Objektorientierte Programme haben mit ihrer hohen Dynamik, der Vererbungshierarchie und des dynamischen Bindens von Methoden eine deutlich höhere Komplexität, als dies noch bei prozeduralen Sprachen der Fall war. Objektorientierte Methoden sind meist sehr viel kleiner als das Prozeduren waren und interagieren stärker mit anderen Methoden. Dadurch entsteht zum Beispiel die in Frameworks so wichtige Flexibilität durch Adaption von Methoden in Unterklassen [FPR01FSJ99]. Jedoch erfordert diese Möglichkeit zur Redefinition zusätzlichen Aufwand beim Test. Insbesondere reicht es nicht mehr, nur innerhalb einer Methode alle möglichen Kontrollflüsse zu testen, sondern es müssen alle potentiellen Konstellationen der Zusammenarbeit von Methoden in allen Unterklassen geprüft werden. Die Aufgabe potenziert sich, wenn mehrere Objekte kollaborieren, von denen jedes aus einer von mehreren Unterklassen stammen kann. Es ist daher oft nicht mehr durchführbar, Tests, die alle Kombinationen von Methodenaufrufen und Objektstrukturen überdecken, zu entwickeln.

Da UML/P als Zielsprache Java nutzt, sind die in Java vorhandenen Probleme weitgehend in der UML/P wiederzufinden. Eine Ausnahme ist zum Beispiel die erwähnte Verschattung von Attributen, die in UML aufgrund entsprechender Kontextbedingungen nicht dargestellt werden kann und daher bei einer Codegenerierung nach Java nicht entsteht.

Bei einer konstruktiven Nutzung von UML-Diagrammen zur Codegenerierung entstehen für das resultierende Programm eine Reihe von Möglichkeiten, die vorgegebene Spezifikation zu verletzen. Je nach Form der Generierung und der dabei umgesetzten Konzepte, werden bestimmte Fehler der zugrunde liegenden Sprache Java vermieden oder neue Probleme eingeführt. Beispielsweise werden gemäß der in Abschnitt 5.1.3 angegebenen Transformation einschränkende Kardinalitäten von Assoziationen normalerweise nicht konstruktiv umgesetzt. Dadurch kann eine Verletzung der Invarianten auftreten, die nur dadurch verhindert werden kann, dass die Umgebung der Assoziation die Invariante beachtet. Dies ist in Tests zu prüfen. Ähnlich werden Zustands- oder Nachbedingungen in Statecharts nicht notwendig konstruktiv sichergestellt und sind daher zu testen. Andererseits kann, wie in Abschnitt 5.1.3 gezeigt, durch Generierung geeigneter Funktionalität beispielsweise sichergestellt werden, dass eine bidirektionale Assoziation immer konsistent ist, so dass dadurch Tests entfallen können.

Die Definition von sprachspezifischen Tests, die Robustheitsfehler erkennen können, hängt daher wesentlich von der Form des generierten Codes ab. Da der Codegenerator in wesentlichen Elementen parametrisierbar sein muss, ist damit eine Vorhersage, welche sprachspezifischen Tests für UML/P notwendig sind, schwer durchführbar. Es ist daher hilfreich, für Stellen, an denen ein Generator es nicht verhindert, Invarianten zu verletzen, geeignete Testfälle zu deren Prüfung zu definieren.

6.1.7 UML/P als Test- und Implementierungssprache

Wie in Abschnitt 4.1 diskutiert, kann die UML/P im Softwareentwicklungsprozess mehrere Rollen einnehmen. Sie ist gleichzeitig als Implementierungssprache und als Sprache zur Definition von Tests geeignet. Damit übernimmt sie ähnliche Aufgabenstellungen wie die jeweils benutzte Programmiersprache in Extreme Programming-Projekten. Dort werden Tests und Implementierung ebenfalls in derselben Sprache formuliert. Erfahrungen mit der UML als Sprache zur Testmodellierung zeigen außerdem, dass die Effizienz der Entwickler verbessert wird [BMJ01BPR04]. Jedoch ist es wichtig, die UML in einer testbaren Form einzusetzen [BL01Rum03]. Unter Testbarkeit wird generell die Fähigkeit verstanden, aus dem Modell Tests abzuleiten oder – idealerweise – automatisch zu generieren.

Wird die UML/P als Implementierungssprache verwendet, so ist für eine systematische Testentwicklung die Kenntnis notwendig, welche sprachspezifischen und typisch objektorientierten Probleme die UML/P behebt, aber auch mit sich bringt. Ein typisches Problem der Umsetzung von bidirektionalen Assoziationen ist die Wahrung der Konsistenz zwischen den Attributen auf beiden Seiten, die die Assoziation speichern. Bei der Generierung von Code aus einem Klassendiagramm, der nicht mehr manuell verändert werden darf, kann diese Konsistenz durch den in Abschnitt 5.1.3 beschriebenen Code konstruktiv gesichert werden. Sie muss also nicht mehr durch Tests geprüft werden.

Andererseits führt die Verwendung eines Attributs des referenzierten Objekts als Qualifikator in einer qualifizierten Assoziation Redundanz ein, die bei Änderung des Attributwerts zur Inkonsistenz führen kann. Eine statische Analyse des Codes kann feststellen, ob eine Änderung dieses Attributs überhaupt stattfindet. Ist das jedoch der Fall, so sind dynamische Tests notwendig, um festzustellen, ob dadurch die Konsistenz für eine qualifizierte Assoziation verletzt wird.

Um also in der UML/P formulierte Implementierungen auf ihre Robustheit zu prüfen, ist es notwendig die Notationen der UML/P und ihre Umsetzung selbst einer kritischen Analyse auf mögliche Fehlerquellen zu untersuchen. Dabei ist zu beachten, dass die UML/P nicht nur aus mehreren Diagrammarten und der OCL besteht, sondern auch Java-Code explizit als Methodenrümpfe und als prozedurale Aktionen in Statecharts erlaubt. Dadurch bleiben wie bereits erwähnt viele der für Java typischen Fehlerquellen erhalten. Die potentiellen Fehlerquellen der Implementierungssprache UML/P hängen aber weitgehend von der konkreten Umsetzung durch den parametrisierten Codegenerator ab und können daher nicht allgemein diskutiert werden.

Die Untersuchung nach UML/P-Fehlerquellen beinhaltet wie im obigen Beispiel der Assoziationen auch die Frage, wie diese Fehlerquellen zu behandeln sind. Dazu gibt es fünf wesentliche Strategien:

  1. Eine statische Analyse der UML/P-Modelle kann klären, ob an einem generierten Element bzw. seinen Instanzen auf unzulässige Weise Manipulationen vorgenommen werden können. Das kann entweder in Form einer Kontextbedingung verboten oder durch Warnungen mitgeteilt werden. In der UML/P gehören dazu zum Beispiel Manipulationen auf mit frozen gekennzeichneten Attributen oder Assoziationen.
  2. Der Codegenerator fügt eine Laufzeitprüfung hinzu, die zwar den Versuch zur verbotenen Manipulation nicht verhindert, aber diese zum Beispiel durch die Ausgabe einer Exception anzeigt. Java macht dies beispielsweise bei illegalen Array-Zugriffen. UML/P kann dieses Konzept zum Beispiel bei qualifizierten Assoziationen übernehmen. Jedoch müssen diese Exceptions als Teil des in Abschnitt 4.2.2 diskutierten API dem Entwickler bekannt gegeben werden.
  3. Der Codegenerator entwirft nicht nur den Code zur Umsetzung eines UML/P-Konstrukts, sondern auch Testcode, der zur Laufzeit prüft, ob eine Eigenschaft eingehalten wird. Beispielsweise kann die eingeschränkte Kardinalität einer Assoziation durch die Prüfung einer Invariante gesichert werden. Diese Form der Prüfung der Invariante ähnelt der oben beschriebenen Laufzeitprüfung. Sie unterscheidet sich aber einerseits darin, dass sie nur im instrumentierten Produktionscode existiert. Zum anderen wirft sie keine vom Produktionssystem zu verarbeitende Exception, sondern meldet das Scheitern eines Tests.
  4. Der Codegenerator nutzt ein Modell als Spezifikation des erwarteten Verhaltens und extrahiert daraus Testfälle nach einem Überdeckungskriterium. Beispielsweise sind bestimmte Statecharts für eine Generierung von Zustands-, Transitions- oder Pfad-überdeckenden Testfällen geeignet.
  5. Der Entwickler entwirft selbst weitere Tests, um Eigenschaften des aus einem UML/P-Konstrukt generierten Codes zu überprüfen.

Der letzte Punkt ist eigentlich nicht notwendig, wenn der Codegenerator korrekt funktioniert. Jedoch ist ein Codegenerator, wie in Kapitel 4 beschrieben, parametrisiert. Das heißt, Skripte können die Codegenerierung in einer flexiblen Weise steuern. Möglicherweise wird dadurch Code generiert, der beispielsweise die Konsistenz der bidirektionalen Assoziation nicht durch geeignete Maßnahmen sicherstellt oder sogar selbst verletzt. Das bedeutet, dass Tests für solche Zwecke typischerweise den Codegenerator beziehungsweise seine Skripte prüfen und damit ihre Berechtigung haben.

Es ist daher für jedes Projekt einzeln zu entscheiden, welche Teile des Systems wie intensiv getestet werden. Dies hängt natürlich auch von den Projektzielen, der zu erreichenden Qualität und der Einsatzform des Produkts ab.

Bereits Abschnitt 4.1.2 diskutiert vom Standpunkt der Codegenerierung, welche UML/P-Teile sich konstruktiv oder für Tests einsetzen lassen. Nicht direkt in konstruktiven Code übersetzbare Objektdiagramme, OCL-Bedingungen und Statecharts bilden dennoch nicht notwendigerweise Testmodelle, die vom Codegenerator zur Generierung von Tests verwendet werden können.5

Abbildung 4.3 skizziert den typischen Einsatz von UML/P-Diagrammen für Tests und Implementierung. Während der Produktionscode ein vollständiges System darstellt, ist der Testcode nur in Kombination mit dem Produktionscode lauffähig. Der Produktionscode wird außerdem für den Einsatz im Test instrumentiert. Das heißt, er wird durch zusätzliche Codestücke erweitert, damit der Testcode auch während des Testablaufs auf alle notwendigen Informationen zugreifen kann. Dazu gehören zum Beispiel spezielle Funktionen zum Besetzen und Auslesen gekapselter Attribute, wenn die entsprechenden get- und set-Funktionen nicht standardmäßig zur Verfügung stehen oder zusätzliche Effekte haben können, die zum Beispiel zur Erhaltung der Konsistenz zwischen mehreren Attributen dienen. So wird Code eingefügt, der Invarianten und OCL-Methodenspezifikationen zur Laufzeit prüft sowie die Protokollierung von Methodenaufrufen zum Vergleich mit vorgegebenen Aufrufreihenfolgen ermöglicht. Bei konventioneller Programmierung mit Java müssen für solche Aufgaben Testmonitore oder Adapter entwickelt werden [Wil01], die jedoch nur einen begrenzten Zugang zu den Testlingen haben.

Die Instrumentierung darf das funktionale Verhalten des Produktionscodes nicht verändern, weshalb es verboten ist, in OCL-Bedingungen modifizierende Methoden einzusetzen. Nur so ist gewährleistet, dass der für die Freigabe bestimmte, nicht instrumentierte Produktionscode dasselbe Verhalten besitzt wie der getestete Code.

Es kann notwendig sein, denselben Produktionscode für verschiedene Tests unterschiedlich zu instrumentieren. Zum Beispiel sind nur für manche Tests Aufrufreihenfolgen irrelevant. Auch kann es sehr ineffizient werden, wenn alle OCL-Invarianten in allen Tests geprüft werden. Dauert die Ausführung von Tests zu lange, so werden diese unpraktikabel. Die Instrumentierung ist daher entweder abhängig vom gerade ausgeführten Test zu individualisieren oder durch boolesche Flags zur Laufzeit parametrisieren. Letzteres hat den Nachteil, dass dadurch der instrumentierte Code groß werden kann, aber den Vorteil, dass keine wiederholte Codegenerierung und Übersetzung des generierten Codes notwendig ist. Die Besetzung der Flags kann durch den Testtreiber direkt oder durch Stereotypen in der Testbeschreibung festgelegt werden. Aufgrund der stetig leistungsfähiger werdenden Rechner und der Effizienz guter Compiler kann davon ausgegangen werden, dass die Codeinstrumentierung für Testzwecke und die damit mögliche Simulation von Umgebung und Verteilung sowie die dynamische Prüfung von Invarianten, Vor- und Nachbedingungen im Produktionscode praktikabel sind.

Der instrumentierte Produktionscode und der Testcode testen sich gegenseitig. Ist ein Testfall gescheitert, so kann der Testling, aber auch der Testfall selbst fehlerhaft sein. In der auch im Auktionsprojekt beobachteten Praxis sind deutlich häufiger die Testfälle selbst fehlerhaft, zum Beispiel weil eine Änderung in der Funktionalität einer Methode im Testfall nicht adäquat nachgezogen wurde. Unangenehm wird es wenn beide, der Testling und der Testfall, konsistent falsch sind und fälschlicherweise ein Testerfolg gemeldet wird. Dieses Problem tritt besonders dann auf, wenn ein Entwickler sowohl den Produktionscode als auch den Test entwirft und dabei einen Denkfehler wiederholt. Dem wird zum Beispiel im Extreme Programming-Ansatz dadurch entgegnet, dass zwei Entwickler gleichzeitig und gemeinsam am Code arbeiten. Zusätzlich sollten Tests der darüber liegenden Schichten bzw. Integrationsstufen in der Lage sein, einen solchen Fehler dennoch zu entdecken.

6.1.8 Eine Notation für die Testfalldefinition

Für bestimmte Zwecke werden auch eigenständige Testnotationen verwendet. Die Telekommunikationsindustrie nutzt zum Beispiel vorrangig TTCN [ISO92GS02] in Kombination mit MSCs [IT11Krü00] und SDL [IT07b]. Die Verwendung einer speziellen Testnotation wird als Vorteil gegenüber der Benutzung einer Programmiersprache zur Definition von Testtreibern empfunden, weil sie abstrakter und damit übersichtlicher ist und Veränderungen der Funktionalität damit leichter in den Tests nachgezogen werden können. Dies ist allerdings nur richtig, wenn die in einer Programmiersprache wie Java implementierten Tests von einer gut entworfenen Bibliothek von Hilfsfunktionen unterstützt werden. Bei der Verwendung einer abstrakten Testnotation ist viel Aufwand in die Entwicklung des Testwerkzeugs zur Interpretation der Testnotation und in die Ansteuerung von Soft- und Hardwarekomponenten sowie die Schulung der Entwickler zu investieren. Bei der Verwendung der Programmiersprache Java zur Testfalldefinition spiegelt sich dieser Aufwand in der Benutzung des Frameworks wider, das die implementierten Hilfsfunktionen zur Verfügung stellt. Damit haben beide Vorgehensweisen einiges gemeinsam. Jedoch hat die Verwendung derselben Programmiersprache für Tests und Implementierung einige Vorteile gegenüber der Verwendung einer eigenständigen Testnotation:

  • Der Aufwand, eine Testnotation zu erlernen, ist nicht zu vernachlässigen und entfällt bei der Nutzung einer Sprache, die auch als Realisierungssprache verwendet wird.
  • Testnotationen sind in ihrer Beschreibungsmächtigkeit typischerweise eingeschränkt. Das führt dazu, dass entweder bestimmte Tests nicht formuliert werden können, oder die Testnotation ad hoc erweitert wird. Letzteres ist beispielsweise nicht möglich, wenn das genutzte Werkzeug nicht veränderbar ist. Falls es möglich ist, bedeutet es einen enormen Zusatzaufwand, der bei der Erweiterung eines entsprechenden Frameworks normalerweise deutlich geringer ausfällt.
  • Die Integration zwischen Testnotation und Implementierungssprache ist am besten, wenn beide identisch sind oder aufeinander aufbauen. Ansonsten sind die Konzepte der Implementierungssprache (zum Beispiel Attribute oder Methodenaufrufe) in der Testnotation in geeigneter Weise verfügbar zu machen, um in Tests auf sie zugreifen zu können.

Aus diesen Gründen ist es nicht verwunderlich, dass in vielen und insbesondere agilen Softwareentwicklungsprojekten für die Realisierung von Tests dieselbe Notation verwendet wird, wie zur Programmierung. In Projekten, die UML/P, also eine Kombination aus Modellierungstechniken der UML und Java nutzen, ist es deshalb von Vorteil, für die Modellierung von Tests ebenfalls UML/P einzusetzen:

  • Für die Modellierung von Tests steht mit der UML/P eine abstrakte Notation zur Verfügung.
  • Die Kombination der UML mit Java erlaubt es, nicht direkt in UML formulierbare Sonderfälle von Tests dennoch innerhalb eines integrierten Rahmens zu beschreiben.
  • Der bereits genannte Aufwand zur Einarbeitung in eine neue Testnotation entfällt, beziehungsweise reduziert sich darauf, zu verstehen, wie die bereits zur Systementwicklung eingesetzte UML/P auch zur Testmodellierung eingesetzt wird.
  • Die mentale Hürde zur Entwicklung von Tests in einer neuen Notation entfällt.
  • Ein konzeptioneller Bruch zwischen Testnotation und Modellierungs- beziehungsweise Implementierungssprache existiert nicht.
  • Es sind keine zusätzlichen notationellen oder technischen Kenntnisse zur Modellierung von Tests notwendig, so dass Entwickler grundsätzlich in der Lage sind, selbst Tests zu definieren.

Die UML/P vereinigt also die Vorteile einer abstrakten Notation für Testfälle mit der guten Integration von Test- und Implementierungssprache. Durch diese integrierte Verwendung der UML/P ist es tatsächlich realistisch, in kurzer Zeit parallel zur Entwicklung eine ausreichende Testsammlung zu erstellen, die die Qualität der erstellten Software demonstriert. Deshalb ist es nicht überraschend, dass nicht nur bei der Entwicklung von Geschäftssoftware, sondern auch bei der Entwicklung eingebetteter Systeme, wie etwa bei Telekommunikationssystemen, verstärkt Java parallel zu Testnotationen, wie etwa MSC, zum Einsatz kommen.6

Da ein Test meistens aus mehreren UML-Diagrammen besteht, ist dennoch ein Stück zusätzlicher Syntax notwendig, um Tests in kompakter Form zu definieren. Der hier dargestellte Vorschlag beschränkt sich auf die Referenzierung von UML-Diagrammen, OCL-Bedingungen und der Einbindung von Java-Code, um Teile des Tests zu formulieren. Abbildung 6.6 zeigt das vollständige Schema für die Definition von Tests. Jeweils ungenutzte Anteile können entfallen.

test Test    Testling, z.B. Methode oder Klasse z.B. Auction.bid {
  name:        Generierungsziel für den Test, z.B. AuctionTest.testBid
  testdata:    Objektdiagramme bereiten den Testdatensatz vor
  tune:        Java-Code erlaubt individuelle, zusätzliche Anpassung der
               Testdaten
  driver:      Java-Methodenaufruf(e) | Sequenzdiagramm
  methodspec:  OCL-Methodenspezifikationen werden bei einem Methodenaufruf
               geprüft
  interaction: Sequenzdiagramme werden als Ablaufbeschreibungen geprüft
  oracle:      Java-Methodenaufruf | Statechart  produziert vergleichbare
               Orakelergebnisse
  comparator:  Java-Code | OCL-Code  vergleicht Testergebnis mit
               Orakelergebnis
               Default ist Übereinstimmung von Struktur und Attributinhalten
  statechart:  Statechart for Objektname from Anfangszustand
               to { Zielzustände }
               Der Test bewirkt Transitionsübergänge im genannten Objekt
               vom Anfangszustand in einen der Zielzustände
  assert:      Objektdiagramme | OCL-Bedingungen | Java-Prüfcode
               Boolesche Bedingungen über das Testergebnis
  cleanup:     Java-Code räumt benutzte Ressourcen auf
}
Abbildung 6.6: Schablone für die Definition eines Tests

Die einzelnen Bestandteile werden im weiteren Verlauf dieses Kapitels diskutiert. Dabei wird auch eine tabellenartige Variante dieses Schemas verwendet, die für die übersichtliche Definition mehrerer Tests geeignet ist.


Bernhard Rumpe. Agile Modellierung mit UML. Springer 2012