TESTEN OBJEKTORIENTIERTER PROGRAMME MIT JUNIT Praktikumsaufgabe zur Lehrveranstaltung Softwaretechnologie II (WS 00/01) Professur Softwaretechnologie TU Dresden, Fakultät Informatik DANIEL SCHUSTER 29.01.01
AUFGABENSTELLUNG Erstellen Sie eine frei gewählte Anwendung (möglichst dir Lösung numerischer Probleme) in Java und verwenden Sie bei der Erstellung systematisch das Test-Framework JUnit. Wählen Sie dabei geeignete Testfälle und bewerten Sie das Framework. PROBLEMATIK DES TESTENS Software weist wie jedes industriell entwickelte Produkt Fehler auf. Nur ein Teil dieser Fehler wird durch den Compiler bzw. die IDE erkannt. Trotzdem ist der syntaktisch korrekte Quellcode meist noch sehr fehlerhaft. Ein beträchtlicher Teil der Implementationszeit wird für das Debuggen verwendet. Solche Fehler können z.b. bei Copy and Paste von Quellcode entstehen, wie folgendes Java-Fragment zeigt: private String dirname; public String getdirname return dirname; private String cfgname; public String getcfgname return dirname; In diesem Beispiel war die Variable dirname mit der zugehörigen get-methode schon vorhanden, für cfgname wurde einfach dieses Fragment kopiert und dabei das Ändern der return-anweisung vergessen. Frühzeitiges Testen kann helfen, solche Fehler aufzuspüren und ein Gesamtsystem inkrementell aus einwandfrei funktionierenden Komponenten aufzubauen. Man unterscheidet hierbei die Unit-Tests einzelner Klassen oder Packages, die Integrations-Tests für das Zusammenspiel der Komponenten, sowie den abschließenden System-Test. Das Problem fehlerhafter Software lässt sich nur lösen, wenn Test und Entwicklung eine verzahnte Einheit bilden. ( test a little, code a little, test... ) Bei konsequentem Testen des
entwickelten Codes wird der Quellcode für das Testen ebenso umfangreich werden wie das Programm selbst. Da aber jeder Softwareentwickler meist unter Leistungsdruck steht, wird er nicht die nötige Zeit aufbringen, um die Unit-Tests wirklich vollständig durchzuführen, dies resultiert in unstabilerem Code und somit geringerer Produktivität, was den Leistungsdruck weiter erhöht. Um aus diesem Teufelskreis zu entkommen, braucht der Programmierer äußere Beeinflussung, die durch die Hilfe eines Test-Frameworks geleistet werden kann. Das Test- Framework stellt nicht nur Methoden für ein einheitliches Testen bereit, es erzieht auch den Programmierer zum konsequenten Testen. Ein solches Framework für die Programmiersprache Java ist JUnit von Kent Beck und Erich Gamma. Ich habe Version 3.4 vom 2.11.00 verwendet. BENUTZUNG DES FRAMEWORKS Für jede im Programm vorhandene Klasse wird eine Testklasse angelegt. So z.b. für eine Klasse Article die Klasse TestArticle usw. Hier werden Methoden eingefügt, die über assert() oder assertequals() entsprechende Soll-Ist-Vergleiche durchführen. Der eigentliche Test verläuft in 3 Phasen: 1. Initialisierung von Testwerten und variablen über setup() 2. Durchführung des Tests 3. Ressourcenfreigabe mit teardown() Der TestCase bündelt alle Testmethoden, in der TestSuite können die Testfälle zusammengeführt werden. So lässt sich ein Baum von Testfällen aufbauen. Die Methoden im TestCase müssen mit test... beginnen und beinhalten typischerweise assert-anweisungen. Die TestSuites können entweder über eine main()-methode von Hand gestartet werden, oder sie werden aus einer mitgelieferten Umgebung heraus aufgerufen. Hier sind weitere wichtige Methoden des Frameworks aufgelistet: public int counttestcases(); liefert Zahl der Test Cases, die von diesem Objekt erfasst werden.
public void run ( TestResult result ); führt Test aus und sammelt Ergebnisse in result result wird framework-intern ausgewertet assert([string msg,] boolean condition); assertequals([string msg,] double expected, double actual,double delta); assertequals([string msg,] long expected, long actual); assertequals([string msg,] Object expected, Object actual); assert[not]null([string msg,] Object object); assertsame([string msg,] Object expected, Object actual); fail([string msg]); Die folgende Übersicht zeigt die Verwendung von verschiedenen Entwurfsmustern im Framework. Dabei ist vor allem das Composite Muster bedeutsam, es ermöglicht, eine baumartige Struktur von Testklassen aufzubauen, die der Organisationsstruktur der zu testenden Anwendung entsprechen.
TESTANWENDUNG: POLYNOMOPERATIONEN Um die Arbeit mit JUnit zu demonstrieren, habe ich als Bespielanwendung ein Mini- Framework zur Polynomverarbeitung implementiert. Zuerst brauche ich die Basisklasse Polynom und definiere mir einfache Konstruktoren: public class Polynom public static final int MAX_POWER=10; private double[] fpoly; public Polynom() fpoly=new double[max_power+1]; clear(); public Polynom(double InitialValue) this(); fpoly[0]=initialvalue; public Polynom(double[] InitialArray) this(); System.arraycopy(InitialArray,0,fPoly,0, fpoly.length>initialarray.length?initialarray.length:fpoly.length); public void clear() if(fpoly!=null) for(int i=0;i<fpoly.length;i++) fpoly[i]=0.0; Zusätzlich benötigt man noch eine einfache get-methode und eine equals-methode, dies soll als minimalster Klassenprototyp genügen: public boolean equals(polynom p) boolean result=true; for(int i=0;i<fpoly.length;i++) if(fpoly[i]!=p.get(i))result=false; return result; public double get(int index) if((index>=fpoly.length) (index<0)) return -1.0; return fpoly[index]; Nach der Devise code a little, test a little ist als nächstes gleich eine entsprechende Testklasse zu schreiben. Normalerweise würde man hier für jede Klasse eine Testklasse
schreiben und dann in Suites zusammenfügen, um in großen Projekten den Überblick zu behalten. Für dieses kleine Beispiel reicht es aus, eine Testklasse zu erstellen, die Tests für alle Klassen durchführt. package junit.swt; import junit.framework.*; public class JunitTest extends TestCase private double[] p0=0.0,0.0,0.0,0.0; private double[] p2=2.0,0.0,0.0,0.0; private double[] px=0.0,1.0,0.0,0.0; private double[] psquare=0.0,0.0,1.0,0.0; private Polynom P0; private Polynom P1; private Polynom PX; public JunitTest(String name) super(name); public static void main(string args[]) junit.textui.testrunner.run(junittest.class); protected void setup() double[] Xar=0.0,1.0; P0=new Polynom(); P1=new Polynom(1.0); PX=new Polynom(Xar); public void testequals() assertequals(p0,p0); public void testconstructors() assertequals(p0,new Polynom()); assertequals(p2,new Polynom(2.0)); Dieser TestCase kann nun direkt über die Main- Methode ausgeführt werden und führt die angegebenen Tests aus, die alle mit test.. beginnen. Komfortabler ist allerdings junit.swingui.testrunner, eine swing-basierte grafische Oberfläche.
Der TestRunner fängt alle Exceptions und Errors ab und prüft zusätzlich noch die definierten assertions. Die oben angezeigten Fehler liegen in der Test-Klasse selbst, hier liegt ein wesentlicher Stolperstein des Frameworks, da man aus einer fehlgeschlagenen assertion nicht immer schlussfolgern kann, ob nun die Testklasse oder die getestete Klasse fehlerhaft ist. Die bereinigte Testklasse, für die dann auch der TestRunner grün gibt, sieht so aus: package junit.swt; import junit.framework.*; public class JunitTest extends TestCase private double[] p0=0.0; private double[] p2=2.0; private double[] px=0.0,1.0; private double[] psquare=0.0,0.0,1.0; private Polynom P0; private Polynom P1; private Polynom PX; public JunitTest(String name) super(name); public static void main(string args[]) junit.textui.testrunner.run(junittest.class); protected void setup() double[] Xar=0.0,1.0; P0=new Polynom(); P1=new Polynom(1.0); PX=new Polynom(Xar); public void testequals() assert(p0.equals(new Polynom(p0))); assert(px.equals(new Polynom(pX))); public void testconstructors() assert((new Polynom(p0)).equals(new Polynom())); assert((new Polynom(p2)).equals(new Polynom(2.0))); Als nächstes soll eine Addition und Subtraktion hinzugefügt werden, bei solchen einfachen Methoden ist die entsprechende Testmethode aufwendiger zu schreiben als die Methode selbst. Der Nutzen erschließt sich hier erst mit zunehmender Komplexität des Systems.
Polynom.java: public void add(polynom p) for(int i=0;i<=max_power;i++) fpoly[i]+=p.get(i); public void neg() for(int i=0;i<=max_power;i++) fpoly[i]=-fpoly[i]; public void sub(polynom p) for(int i=0;i<=max_power;i++) fpoly[i]-=p.get(i); JUnitTest.java public void testneg() Polynom p=new Polynom(-1.0); p.neg(); assert(p.equals(p1)); public void testadd() Polynom p=new Polynom(new double[] 3.0,2.0); // 2x+3 p.add(p1); // +1 p.add(px); // +x assert(p.equals(new Polynom(new double[] 4.0,3.0))); // 3x+4 public void testsub() Polynom p=new Polynom(new double[] 3.0,2.0); // 2x+3 p.sub(p1); // -1 p.sub(px); // -x assert(p.equals(new Polynom(new double[] 2.0,1.0))); // x+2 Alle Tests zeigen erwartungsgemäß OK. Die neuen Klassen können nach dem Compilieren in der IDE im TestRunner wieder neu geladen werden. Bei älteren Versionen von JUnit musste hier der TestRunner für jeden Test neu gestartet werden. Nun soll der Klasse eine echte Funktionalität hinzugefügt werden, die Polynomdivision und Multiplikation, gleichzeitig wird die tostring()-methode überschrieben. public void mul(polynom p) Polynom result=new Polynom(); int i,l; for(i=0;i<=max_power;i++) for(l=0;i<=max_power;l++) // <- Fehler!!!! Endlosschleife // richtig: for(l=0;l<=max_power;l++)
result.set(i+l,result.get(i+l)+get(i)*p.get(l)); for(i=0;i<=max_power;i++) set(i,result.get(i)); public void testmul() Polynom p=new Polynom(new double[] 3.0,4.0,2.0); // 2x^2+4x+3 p.mul(new Polynom(new double[] 4.0,3.0)); // *3x+4 assert(p.equals(new Polynom(new double[] 12.0,25.0,20.0,6.0))); // 6x^3+20x^2+25x+12 Prompt zeigt sich auch hier wieder der Wert des sofortigen Tests. In die Methode mul hat sich ein typischer Copy-and-Paste Fehler eingeschlichen, der zu einer Endlosschleife führt. Dies wird zwar durch JUnit nicht explizit angezeigt, da aber der neue Test der letzte in der Reihe ist und der TestRunner nicht zum Ende kommt, kann man darauf schließen. Leider funktioniert Stop Test nur zwischen zwei Tests. Es folgt nun die Polynomdivision: public Polynom div(polynom p) int i; if(p.getmaxpower()>getmaxpower()) return new Polynom(); Polynom result=new Polynom(); Polynom base=new Polynom(); for(i=0;i<=max_power;i++) base.set(i,get(i)); Polynom modulo=new Polynom(); int maxa=getmaxpower(); int maxb=p.getmaxpower(); for(i=maxa;i>=0;i--) result.set(i-maxb,base.get(i)/p.get(maxb)); maxa=base.getmaxpower(); if(maxa<maxb) break; modulo.clear(); for(int l=0;l<=maxb;l++) modulo.set(maxa-maxb+l,result.get(maxa-maxb)*p.get(l)); // zuerst Fehler: modulo.set(maxa-maxb+l,result.get(maxa-maxb)); base.sub(modulo); for(i=0;i<=max_power;i++) set(i,result.get(i)); return base; In der anfänglichen Version dieser Methode war ein Fehler (s.o.). Allerdings deckte die erste Version der zugehörigen Testklasse nicht diesen Fehler ab, das Ergebnis der getesteten Division war richtig. JUnit ist kein intelligentes Testprogramm. Es ist lediglich ein Test-
Framework und die Schwierigkeit besteht darin, intelligente Tests zu schreiben. Es hilft nur beim Aufdecken eines Fehlers, nicht bei dessen Beseitigung. So konnte ich den Fehler oben erst durch Einfügen von print()-anweisungen beheben, JUnit ersetzt kein Debugging. Es fehlt eine Log-Funktion, die man innerhalb der Methoden der Anwendung aufrufen kann. Erst nach dem debuggen macht dann eine solche Testmethode Sinn: public void testdiv() Polynom p=new Polynom(new double[] 3.0,5.0,6.0); // 6x^2+5x+3 Polynom p2=p.getclone(); p2.div(p1); assert(p2.equals(p)); // Division durch 1 p2.div(p2); p2.mul(p2); // Division durch 2, anschließend *2 assert(p2.equals(p)); Polynom Prest=p.div(new Polynom(new double[] 4.0,3.0)); // *3x+4 assert(p.equals(new Polynom(new double[] -1.0,2.0))); assert(prest.equals(new Polynom(new double[] 7.0))); // Rest 7 ZUSAMMENFASSUNG / BEWERTUNG Zusammenfassend lässt sich sagen, dass JUnit ein durchaus nützliches Tool für das inkrementelle Entwickeln von Programmen ist. Es erzieht gewissermaßen den Programmierer zur fortlaufenden Qualitätskontrolle seiner Software. Bei konsequenter Anwendung ist es möglich, Programm und Test voneinander zu trennen und die Tests zu konservieren. Sonst werden die Tests im Quelltext der Anwendung durchgeführt und verschwinden beim Release. Kommt es später noch zu Änderungen, kann auf die alten Tests nicht mehr zurückgegriffen werden, was mit JUnit ohne weiteres möglich ist. JUnit bewährt sich vor allem bei stark strukturierten Objektbäumen, es kann eine analoge Objekthierarchie aufgebaut werden. Schwächen liegen vor allem in der Fehleranfälligkeit der Testmethoden selbst. Die Soll-Ist-Kontrolle erfolgt nicht mehr per Hand (Ausgabe von Strings, etc.) sondern muss für die assert()-anweisungen in boolsche Ausdrücke umgewandelt werden. Dabei kann es vorkommen, dass völlig korrekte Ergebnisse einen Assertion-Fehler verursachen. Es erfordert einige Übung, sich in diese Art des Testens hineinzudenken. Ein weiterer Schwachpunkt ist die Beschränkung auf den Test von ganzen Methoden, ein Tool für die Fehlersuche in Methoden wäre hilfreich.