Unit Test in Ruby


Sie sind hier: RubyUnit Test


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.

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:

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

Assertions

Einen Überblick über die definierten Assertions gibt es in der rdoc-Dokumentation zu test/unit:

Ergebnis/Ausgabe

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:

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

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

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:

  1. assert_equal_filecontent wird definiert, expected/test.txt existiert noch nicht.
  2. Der Test wird gestartet, es gibt die Fehlermeldung "Referenzdatei fehlt". Zugleich wird failureYYYY-MM-DD/test.txt erzeugt
  3. Ich prüfe failureYYYY-MM-DD/test.txt ob es meinen Erwartungen entspricht.
  4. Ich kopiere failureYYYY-MM-DD/test.txt in den Ordner expected
  5. 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:

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 errors
Es zeigen sich zwei Nachteile:

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: