In Ruby gibt es mit dem Paket test/unit eine schöne Möglichkeit zu Unittest.
Der Modultest (auch Komponententest oder engl. unit test) ist Teil des Softwareentwicklungsprozesses. Er dient zur Verifikation der Korrektheit von Modulen einer Software, z. B. von einzelnen Klassen. Nach jeder Änderung sollte durch Ablauf aller Testfälle nach Programmfehlern gesucht werden. Bei der testgetriebenen Entwicklung, auch TestFirst-Programmieren genannt, werden die Modultests parallel zum eigentlichen Quelltext erstellt und gepflegt. Dies ermöglicht bei automatisierten, reproduzierbaren Modultests, die Auswirkungen von Änderungen sofort nachzuvollziehen. Der Programmierer entdeckt dadurch leichter ungewollte Nebeneffekte oder Fehler, die durch seine Änderung verursacht wurden.
Quelle: http://de.wikipedia.org/wiki/Modultest
Das Prinzip
Das grundsätzliche Verfahren ist denkbar einfach.
- Man definiert ein Ergebnis
- Man definiert einen Funktionsaufruf, der das Ergebnis liefern soll
- Man definiert einen Testfall, der das erwartete Ergebnis (Assertion) mit dem echten Ergebnis vergleicht.
Ein erstes Beispiel
Im Programm sieht es dann so aus:
require 'test/unit' class TestCalculation < Test::Unit::TestCase def test_addition #Annahme: 2 und 1+1 sind dasselbe assert_equal( 2, 1+1 ) end def test_division assert_raise( ZeroDivisionError ){ 1 / 0 } end end
Als Ergebnis gibt es:
Loaded suite unit_test_1 Started .. Finished in 0.0 seconds. 2 tests, 2 assertions, 0 failures, 0 errors
Wie man an dem Beispiel sieht, kann es durchaus sein, das man als Ergebnis eine Exception erwartet (im Beispiel ein Division by Zero-Fehler).
Failure/Abweichung
Wird ein anderes Ergebnis geliefert als erwartet, wird es als Failure protokolliert.
Beipiel: Erwartet wird 3, das Ergebnis ist aber 2.
require 'test/unit' class TestCalculation < Test::Unit::TestCase def test_addition #Annahme: 1+1 = 3 assert_equal( 3, 1+1 ) end end
Als Ergebnis gibt es:
Loaded suite unit_test_2 Started F Finished in 0.047 seconds. 1) Failure: test_addition(TestCalculation) [unit_test_2.rb:5]: <3> expected but was <2>. 1 tests, 1 assertions, 1 failures, 0 errors
Etwas realistischer
In der Realität sieht es eher so aus:
- Man definiert eine Methode
- Man prüft das Verhalten dieser Methode
require 'test/unit' def add( p1, p2) return p1 + p2 end class TestCalculation < Test::Unit::TestCase def test_addition assert_equal( 2, add(1,1) ) assert_equal( 'ab', add('a','b') ) assert_raise( TypeError ){ add(1,'b') } end end
Als Ergebnis gibt es:
Loaded suite unit_test_3 Started . Finished in 0.0 seconds. 1 tests, 3 assertions, 0 failures, 0 errors
Fakten auf die Schnelle
- Die Testklasse leitet sich von »Test::Unit ::TestCase« ab.
- Alle Methoden, die mit »test« beginnen, arbeitet das Test-Framework automatisch ab, sobald der Ruby-Interpreter die Datei ausführt.
Assertions
- Innerhalb einer Testmethode sind Assertions (Annahmen) gesammelt
- Jede Assertion gibt an, was erwartet wird
- Die Testmethode hat 2 Paramter: Das erwartete Ergebnis und den Aufruf der zu testenden Methode
Einen Überblick über die definierten Assertions gibt es in der rdoc-Dokumentation zu test/unit:
- http://www.ruby-doc.org/stdlib/libdoc/test/unit/rdoc/index.html
- http://www.ruby-doc.org/stdlib/libdoc/test/unit/rdoc/classes/Test/Unit/Assertions.html
Ergebnis/Ausgabe
- Jeder erfolgreiche Test wird durch einen Punkt dargestellt.
- Ein »F« steht für Failure (Versagen) und signalisiert, dass der Test nicht die erwarteten Rückgabewerte liefert.
- Das »E« ist die Kurzform für Error (Fehler) und bedeutet, dass innerhalb des Tests ein Programmfehler aufgetreten ist, beispielsweise eine Ausnahme.
- Die letzte Zeile informiert über die Anzahl der Tests, der Assertions und der aufgetretenen Fehler.
Vorteile von Unit tests
Die Test können automatisiert und wiederholt ablaufen. Es muß nicht jedesmal mühsam ein neuer Testablauf geplant werden.
Verifizierung: Es kann geprüft werden, ob das Programm wirklich das macht, was man erwartet. Es kann durchaus sinnvoll sein, wenn man erst Tests definiert und dann die Entwicklung startet (Test-Driven Development)
Dokumentation: Es wird klargestellt, was eigentlich erwartet wird.
Strukturiertes Entwickeln:
- Durch das konsequente Verwenden von Tests macht man sich automatisch mehr Gedanken über Sonderfälle.
- Die Definition von Tests erfordern möglichst einfache Funktionen und Methoden, Objektstrukturen müssen sauber entworfen sein.
Erkennen von Seiteneffekten bei Weiterentwicklungen. Macht man eine kleine Änderung kann man die Tests durchlaufen lassen und sehen, ob es unerwartete Nebeneffekte zeigt.
Weitere Informationen
- http://www.ruby-doc.org/stdlib/libdoc/test/unit/rdoc/index.html
- http://www.linuxmagazin.de/heft_abo/ausgaben/2005/04/alles_unter_kontrolle
- http://de.wikipedia.org/wiki/Modultest
- http://www.evocomp.de/softwareentwicklung/unit-tests/unittests.html
- http://www.dmoz.org/Computers/Programming/Software_Testing/Unit_Testing/
Erweiterungen
Für meine eigenen Unittests reichten mir die Standard-Assertions nicht aus. Deshalb baute ich mir eigene assertions, die ich hier als more_uni_test-gem bereitstelle.
Das Gem enthält zwei Erweiterungen:
- assert_equal_filecontent.rb
- assert_stdout.rb
assert_equal_filecontent.rb
Diese Erweiterung entstand zum Prüfen eines Dokumentgenerierers ( DocGenerator ).
Wollte ich prüfen, ob z.B. der Tabellenerzeuger korrekt arbeitet, wurden meine erwarteten Ergebnisse schnell größer. Und wenn es Abweichungen gab, hatte ich Probleme die Unterschiede aus dem Protokoll der Unit Tests zu erkennen (Wenn das Ergebnis mal mehrere zeilen umfasst und größer wie 255 Zeichen wird wird es mühsam).
Ich wählte deshal einen anderen Weg mit assert_equal_filecontent:
assert_equal_filecontent( 'expected/test.txt', meine_routine() )
Die Methode prüft, ob das Ergebnis von meine_routine() dem Inhalt der Datei 'expected/test.txt' entspricht. Wenn nicht, dann wird ein Fehler ausgegeben.
Die Funktion entspricht
assert_equal( File.readlines('expected/test.txt').to_s, meine_routine() )
Zusätzlich wird allerdings in einem Ordner das ermittelte Ergebnis abgespeichert. Der Ordner kann als dritter Parameter mitgegegen werden:
assert_equal_filecontent( 'expected/test.txt', meine_routine(), 'failure' )
Default für den Pfad ist failureYYYY-MM-DD wobei YYYY-MM-DD dem aktuellen Datum entspricht.
Mein typischer Testaufbau sieht so aus:
- assert_equal_filecontent wird definiert, expected/test.txt existiert noch nicht.
- Der Test wird gestartet, es gibt die Fehlermeldung "Referenzdatei fehlt". Zugleich wird failureYYYY-MM-DD/test.txt erzeugt
- Ich prüfe failureYYYY-MM-DD/test.txt ob es meinen Erwartungen entspricht.
- Ich kopiere failureYYYY-MM-DD/test.txt in den Ordner expected
- Der Test wird wiederholt, es gibt keine Abweichung.
Bei späteren Tests liegt jetzt ein erwartetes Ergebnis vor. Gibt es Abweichungen, so kann ich ein diff-Tool nehmen und mein erwartetes Ergebnis mit dem realen Ergebnis verweichen. Danach passe ich entweder meine erwarteten Ergebnisse oder mein Programm an.
Zum Nachlesen:
- http://forum.ruby-portal.de/viewtopic.php?t=6890
- http://rubyforge.org/tracker/?func=detail&aid=19509&group_id=5650&atid=21859
assert_stdout.rb
assert_stdout.rb definiert testmethoden zum prüfen der Ausgabe auf stdout und stderr.
Eine grundsätzliche Anmerkung vorweg: Die Sinnhaftigkeit dieser Tests kann berechtigterweise in Frage gestellt werden. Normalerweise ist es besser, die Rückgabe-Ergebnisse von Methoden zu prüfen, nicht deren Bildschirmausgaben.
Beipiel: Es wird eine eigene Division definiert, die bei Division durch 0 als Ergebnis 0 liefert, gleichzeitig aber eine Warnung ausgibt.
Der Testcode kann so aussehen:
require 'assert_stdout.rb' def division( p1, p2 ) p1 / p2 end def division_with_zero( p1, p2 ) if p2 == 0 puts "Invalid division by zero, set 0" 0 else p1 / p2 end end class Test_Division < Test::Unit::TestCase def test_division() assert_equal(2, division(4,2)) assert_raise(ZeroDivisionError){division(4,0)} end def test_division_with_0() assert_equal(2, division_with_zero(4,2)) assert_equal(0, division_with_zero(4,0)) assert_nothing_raised{division_with_zero(4,0)} end end
Als Ergebnis gibt es:
Loaded suite unit_test_stdout_1 Started .Invalid division by zero, set 0 Invalid division by zero, set 0 . Finished in 0.0 seconds. 2 tests, 5 assertions, 0 failures, 0 errorsEs zeigen sich zwei Nachteile:
- Im Testprotokoll hat es störende Ausgaben
- Es kann nicht geprüft werden, ob eine korrekte Warnung erfolgte.
Mit meiner Erweiterung ist zumindest die Prüfung auf eine korrekte Warnung möglich:
require 'assert_stdout.rb' def division( p1, p2 ) p1 / p2 end def division_with_zero( p1, p2 ) if p2 == 0 puts "Invalid division by zero, set 0" 0 else p1 / p2 end end class Test_Division < Test::Unit::TestCase def test_division() assert_equal(2, division(4,2)) assert_raise(ZeroDivisionError){division(4,0)} end def test_division_with_0() assert_equal(2, division_with_zero(4,2)) #~ assert_equal(0, division_with_zero(4,0)) #~ assert_nothing_raised{division_with_zero(4,0)} assert_stdout("Invalid division by zero, set 0\n", proc{ division_with_zero(4,0) } ) assert_equal("Invalid division by zero, set 0\n", catch_stdout(proc{ division_with_zero(4,0) }) ) assert_stdout_block("Invalid division by zero, set 0\n"){division_with_zero(4,0) } end end
Als Ergebnis gibt es:
Loaded suite unit_test_stdout_2 Started .. Finished in 0.0 seconds. 2 tests, 6 assertions, 0 failures, 0 errors
Zum Nachlesen: