Testen spart Zeit, Kosten und ist kreativ!

Test First

Frank Hoffmann, Oliver Pospisil

Nachdem in diesem Magazin viel über Model Driven Architecture (MDA) und die Vorteile einer Trennung von fachlichen und technischen Aspekten berichtet wurde [1], geht es in diesem Beitrag um die Architektur von Tests. Anhand eines kleinen Beispiels wird gezeigt, wie ein solches Verfahren die Projektsteuerung unterstützt, die Kundenzufriedenheit steigert und den Entwicklern Raum für Kreativität schafft. Das hier gezeigte Vorgehen basiert auf dem Test-First-Ansatz, der im Umfeld von Extreme Programming entstanden ist [2].

Die Projektverantwortlichen werden mit einem systematisierten Testverfahren in die Lage versetzt, das Projekt jederzeit zu steuern, da ihnen die Tests zeigen, welche Teile des Systems schon funktionieren und an welchen noch gearbeitet werden muss. Die Auftraggeber können dadurch jederzeit den Zeit- und Kostenrahmen beobachten und gegebenenfalls steuernd eingreifen. Die Entwickler sehen, welche Qualität ihr Code hat und können ihre Energie und Kreativität gezielt auf die Teile des Codes fokussieren, die noch der Arbeit bedürfen.

Da heutige Projekte iterativ entstehen und unter einem enormen Zeit- und Kostendruck durchgeführt werden, gewinnen automatisierte und systematische Tests immer mehr an Bedeutung. Sie zeigen allen Beteiligten zuverlässig den Status des Projekts und pictureen damit die Planungsgrundlage für die Zukunft.

Wie viel testen?

Art und Umfang der Tests bestimmen maßgeblich die Qualität des Systems. Je mehr und detailliertere Tests durchgeführt werden sollen, um so mehr Zeit und damit Geld muss an dieser Stelle investiert werden. Der Charakter der Tests sollte deshalb zur geforderten und gewünschten Qualität des Systems passen.

Um dieses angemessene Verhältnis zu erreichen, hilft oft schon eine einfache Grenzwert- und Risikobetrachtung weiter. Zunächst sollte festgelegt werden, innerhalb welcher Grenzen das System und damit jede einzelne Funktionalität laufen soll. Wie sieht der Normalfall aus und welche Ausnahmen soll das System auch noch fehlerfrei verarbeiten können?

Nach der Grenzwertbetrachtung wird im Rahmen der Risikobetrachtung abgewogen, was für Schäden durch Fehlfunktionen hervorgerufen können und wie damit umgegangen werden soll. So kann ein Fehler im System z.B. dazu führen, dass Menschen zu Schaden kommen können. Dies muss vermieden werden, bedeutet aber noch nicht, dass das System an sich 100 Prozent fehlerfrei sein muss.

Auf Basis der Grenzwert- und Risikobetrachtung kann nun mit dem Auftraggeber festlegt werden, wieviel in die Qualität des Systems investiert wird. Entsprechend werden die Tests priorisiert. Wenn eine 100-prozentige Fehlerfreiheit gefordert wird, dann werden die Tests entsprechend aufwändig und damit kostspielig.

Voraussetzungen für erfolgreiche Tests

Es hat sich bewährt, Abnahmetests systematisch, einheitlich und so früh wie möglich durchzuführen. Denn bis zu den Tests weiß keiner mit Bestimmtheit, ob das System wirklich macht, was es soll. Eine Projektsteuerung ist damit quasi unmöglich. Die Qualitätsnorm ISO 9000-3 hilft solche Probleme von vornherein zu vermeiden.

Gemäß dieser Norm werden alle Wünsche, die der Kunde in dem System umgesetzt sehen möchte, als fachliche oder technische Anforderung beschrieben und zu jeder Anforderung mindestens ein Abnahmekriterium definiert, das als Abnahmetest implementiert werden kann. Jedes Abnahmekriterium besteht aus einer Situation, einer Aktion und einem erwarteten Ergebnis (Abb. 1).

Abb. 1: Verhältnis zwischen. Anforderung und Akzeptanzkriterium

Am Anfang des Entwicklungsprozesses werden, basierend auf den Geschäftsprozessen, die Anforderungen mit den entsprechenden Abnahmekriterien formuliert (siehe Abb. 2). Im Rahmen der Modellierung gemäß MDA folgt dann die getrennte Darstellung der plattformspezifischen und plattformunabhängigen Elemente. Im Anschluss daran werden die Tests gemäß den Qualitätsvorgaben des Auftraggebers (siehe Kasten Wie viel testen?) implementiert, die dann den Produktivcode testen.

Testbeispiel

Anhand eines Beispiels für eine technische Anforderung werden im Folgenden die einzelnen Elemente detailliert betrachtet. Unser Beispiel zeigt den Anfang einer möglichen Testhierarchie, bei der zunächst die technischen Anforderungen des Systems und dann die Fachlichen getestet werden:

Anforderung:

  • Es soll getestet werden, ob J2SE in der Version 1.3.0 auf dem Testrechner installiert ist. Zu dieser Anforderung gibt es folgendes Abnahmekriterium:

Situation:

  • Das Kommando java -version liegt vor.
  • Es ist Java 1.3.0 installiert.

Eine Situation beschreibt, in welchem Zustand sich das System zu Beginn des Tests befindet. In unserem Fall ist Java in der Version 1.3.0 (Standard Edition) auf dem Testrechner installiert. Damit kann der Befehl java -version ausgeführt werden.

Aktion:

  • Das Kommando wird mit execute gestartet. Die Aktion beschreibt, was beim Test passieren soll. Hier soll eine Methode execute mit dem Kommando java -version als Parameter aufgerufen werden.

Erwartetes Ergebnis:

  • Die Ausführung gelingt und die Ausgaben auf stderr und stdout werden in den stderr und stdout des rufenden Prozesses umgeleitet.

Die Ausgabe lautet:

  • java version 1.3.0
  • Java(TM) 2 Runtime Environment, Standard Edition (build 1.3.0-C)
  • Java HotSpot(TM) Client VM (build 1.3.0-C, mixed mode)

Das erwartete Ergebnis beschreibt, was nach ausführen der Aktion als Ergebnis vorliegen soll, wenn der Test positiv verlaufen ist. Hier wird erwartet, dass auf der Standardausgabe die Informationen über die Version der installierten Java-Version ausgegeben werden sollen. Mit diesem Vorgehen ist sichergestellt, dass vor der Implementierung feststeht, was genau gefordert ist.

Von der Anforderung zum Test

Eine Hauptforderung beim Vorgehen gemäß Test-First besteht darin, die Tests vor dem Produktivcode zu implementieren. Dieses Vorgehen vermeidet, dass Produktivcode implementiert wird, der nicht getestet werden kann und führt in der Praxis zu einem einfacheren Produktivcode.

Gleichzeitig können so auch Lücken in der Beschreibung einer Anforderung bzw. ihrer Abnahmekriterien so frühzeitig aufgedeckt werden, dass die Kosten für die Behebung gering bleiben. Denn je später eine Lücke oder gar ein Fehler erkannt wird, um so kostspieliger wird seine Behebung.

Als Faustregel gilt, dass ein Fehler, der erst bei der Inbetriebnahme erkannt wird, relativ gesehen 100 Mal mehr Kosten für die Beseitigung verursacht, als wäre er bei der Anforderungsaufnahme erkannt worden. Die Kosten, die bei der Beseitigung eines Fehlers entstehen, der beim Testen entdeckt wird, ist immer noch um den Faktor 10 niedriger, als würde er im laufenden Betrieb entdeckt werden. Es wird deutlich, dass durch eine angemessene Anforderungsaufnahme und entsprechende Tests viel Geld und Zeit eingespart werden kann.

Nachdem die Voraussetzungen geschaffen wurden, damit genau die Funktionalität getestet wird, die gefordert wird, kann mit der Implementierung des Abnahmetests begonnen werden.

Um dem Entwickler seine Arbeit zu erleichtern, soll in den Sourcecode sowohl der Text der Anforderung als auch die Beschreibungen für die Situation, Aktion und das erwartete Ergebnis des Abnahmekriteriums als Kommentar übernommen (Listings 1 bis 4) werden. Die Ausschnitte zeigen, dass im Sourcecode Anforderung und Abnahmekriterium 1:1 abgepictureet wurden. Dies macht den Code lesbarer und erleichtert damit die Kommunikation zwischen den Projektbeteiligten.

Listing 1

/**
* EXPECTED RESULT
* Die Ausführung gelingt und der Ausgaben auf stderr und stdout werden
* in den stderr und stdout des rufenden Prozesses umgeleitet.
* Die Ausgabe lautet
*
* java version "1.3.0"
* Java(TM) 2 Runtime Environment, Standard Edition (build 1.3.0-C)
* Java HotSpot(TM) Client VM (build 1.3.0-C, mixed mode)
*/
private boolean expectedResult() throws Exception {
boolean result=false;
// >>>{EXPECTEDRESULT}{001}{wf03000001}
String regex = "java version.*nJava.TM.*2 Runtime Environment, Standard Edition.*nJava HotSpot.TM. Client VM.*";
Perl5Matcher matcher = new Perl5Matcher();
Perl5Compiler compiler = new Perl5Compiler();
Perl5Pattern pattern = (Perl5Pattern)compiler.compile(regex,Perl5Compiler.MULTILINE_MASK);
if (matcher.contains(resultString.toString(),pattern)) {
result = true;
}
// return result;
}

Der Aufwand für Tests lässt sich erheblich durch den Einsatz einer Anforderungsdatenbank und Generierungstechnologie mindern. In einer Anforderungsdatenbank können die einzelnen Anforderungen mit ihren Abnahmekriterien strukturiert abgelegt werden. Dadurch können auch die Veränderungen, die eine Anforderung erfährt, besser verfolgt werden. Mit einem Textverarbeitungsprogramm ist dies nur unzureichend möglich, besonders, wenn es sich um ein großes System mit vielen Anforderungen und Abnahmekriterien handelt. Zwar bieten diese Programme auch die Möglichkeit der Historisierung, allerdings um den Preis der Übersichtlichkeit.

Ein weiterer Vorteil einer Anforderungsdatenbank wird deutlich, wenn der Schritt von der Anforderung zur Implementierung des Testcodes betrachtet wird. Durch den Einsatz eines Testgenerators kann der Coderahmen für die Abnahmetests auf Basis der Abnahmekriterien aus der Anforderungsdatenbank generiert werden.

Eine weitere Produktivitätssteigerung wird durch den Einsatz eines geeigneten Testframeworks erzielt. Hier haben sich bekanntlich die Frameworks bewährt, die auf Basis von JUnit entstanden sind [3]. Das Zusammenspiel aller drei Komponenten verspricht den größten Produktivitätsgewinn, denn aus den Informationen in der Anforderungsdatenbank kann ein Generator JUnit-konforme Tests generieren und gleichzeitig die nötige Infrastruktur bereitstellen (Listing 5), sodass die Tests auf Knopfdruck laufen.

Die Zeit, die nötig ist, um dies per Hand fehlerfrei zu implementieren, kann jetzt in den kreativen Teil der Testimplementierung investiert werden. Das steigert die Qualität der Test und damit die Qualität des entstehenden Systems und hilft Zeit bzw. Kosten zu sparen.

Listing 5

/*
Copyright (C) 1999-2002 BITPlan GmbH


$Header: Y:\Source\Java\com\bitplan\common\aktest/RCS/testall.java,v 1.2 2002/05/23 08:39:54 wf Exp $
$Id: testall.java,v 1.2 2002/05/23 08:39:54 wf Exp $
// >>>{RCS-Log section}{.testall.java}{.testall.java}
$Log: testall.java,v $
Revision 1.2  2002/05/23 08:39:54  wf
implemented

Revision 1.1  2002/04/17 06:32:36  wf
Initial revision

// package com.bitplan.common.aktest;

import com.bitplan.common.Args; // commandline - parsing
import java.io.FileOutputStream;
import java.io.PrintStream;
import junit.framework.*;
import junit.extensions.TestSetup;
// >>>{AK-TESTSUITE-INCLUDE-SECTION}{testall}{testall}
// no implementation yet !!!
// public class testall extends TestSetup {
/**
* ID for GNU Revision Control System - will show in the class file and
* can be looked for
* using the ident command of RCS
*/
public final String RCSID="$Id: testall.java,v 1.2 2002/05/23 08:39:54 wf Exp $";


// >>>{AK-TESTSUITE-GLOBALS-SECTION}{testall}{testall}
// no implementation yet !!!
// public testall() {
super(new TestSuite("testall"));
}

/**
*  add the testsuite represented by the given class
* @param pClass test class to add to the suite
*/
public void addTestSuite(Class pClass) {
((TestSuite)fTest).addTestSuite(pClass);
}

/**
* Set up the testall testsuite
* implement as needed
*/
public void setUp() {
// >>>{AK-TESTSUITE-SETUP-SECTION}{testall}{testall}
// no implementation yet !!!
// public void tearDown() {
// >>>{AK-TESTSUITE-TEARDOWN-SECTION}{testall}{testall}
// no implementation yet !!!
// new testall();

/**
* get the suite of tests
* @return - the suite of tests
*/
public static Test suite() {
suite= new testall();
suite.addTestSuite(AF1000_001.class);
// >>>{AK-TESTSUITE-SUITE-SECTION}{testall}{testall}
// no implementation yet !!!
// return suite;
}

/**
* the testresults of this suite
*/
public static TestResult testresult=new TestResult();

/**
* print one line underlined to the given PrintStream
* @param line - the line to print
* @param out - the PrintStream to use for output
*/
public void printUnderline(String line,PrintStream out) {
out.println(line);
String underline="";
for (int i=0;ipublic void showTestFailures(java.util.Enumeration failures,String title, PrintStream out) {
if (failures.hasMoreElements()) {
printUnderline(title,out);
}
for (;failures.hasMoreElements() ;) {
TestFailure tf=(TestFailure)failures.nextElement();
out.println(tf.failedTest().getClass().getName());
}
out.println();
}

/**
* show the results of this suite
* @param - the PrintStream to use for showing the results
*/
public void showResults(PrintStream out) {
printUnderline("Test results for testall",out);
out.println("    runs: "+testresult.runCount());
out.println("failures: "+testresult.failureCount());
out.println("  errors: "+testresult.errorCount());
out.println();
showTestFailures(testresult.failures(),"Failures",out);
showTestFailures(testresult.errors(),"Errors",out);
if (testresult.wasSuccessful()) {
out.println("congratulations: testall ok");
}	else{
out.println("sorry: testall failed - please call again");
}
out.close();
}

/**
* allow starting of this testsuite directly from this class
* param args - the commandline arguments
*/
public static void main(String commandline[]) {
Args args=new Args(commandline,"-gui:flg:use gui:"
+"-output:str:output file:");
if (args.getOptionalFlag("-gui")) {
junit.swingui.TestRunner.run(new testall().getClass());
} else {
PrintStream out=System.out;
String outputFilename=args.getOptionalString("-output");
if (outputFilename!=null) {
try {
out=new PrintStream(new FileOutputStream(new java.io.File(outputFilename)));
} catch (Exception e) {
System.err.println("couldn't create "+outputFilename);
}
}
suite().run(testresult);
suite.showResults(out);
if (testresult.wasSuccessful())
System.exit(0);
else
System.exit(testresult.failureCount()+testresult.errorCount());
}
}

} // class testall
Geschrieben von
Frank Hoffmann, Oliver Pospisil
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.