ruby-mine

exploring the mine

ZenTest

von cypher am 04.03.2008 (20 Uhr)

Was?

ZenTest ist eine Suite aus 4 Tools und einer Library: zentest, unit_diff, autotest, multiruby und Test::Rails. Alle helfen dem geneigten Programmierer Code zu schreiben der komplett durch Unit-Tests abgedeckt ist.

Installieren & Einrichten

Wie üblich über Rubygems:

gem install ZenTest

Wichtig ist dabei die richtige Gross/Kleinschreibung des Namens, da "zentest" eine veraltete Version des Gems installiert.

Windows-User brauchen ausserdem für unit_diff eine diff.exe im PATH, die README empfiehlt dazu das Diff-Tool aus der GNU Win32 Toolsammlung.

Verwenden

ZenTest besteht aus 4 Programmen: multiruby, zentest, unit_diff und autotest. Auf Test::Rails gehe ich hier nicht weiter ein und verweise einfach auf die dazugehörige Dokumentation.

multiruby

multiruby führt Unit-Tests mit verschiedenen Versionen von Ruby aus. Dies ist vor allem interessant wenn man ein Programm entwickeln will das sowohl unter Ruby 1.8 als auch unter 1.9 laufen soll. Diese beide Ruby-Versionen verwendet multiruby auch von Haus aus (beim ersten mal werden beide heruntergeladen und in ein extra Verzeichnis kompiliert), wenn man andere Ruby-Versionen ebenfalls testen will, so muss man einfach deren tarball (im Format "ruby*.tar.gz") in das Verzeichnis ~/.multiruby/versions legen, woraufhin sie automatisch kompiliert und verwendet werden.

Leider funktioniert das noch nicht mit jRuby, da multiruby von dem Unix-üblichen configure-make-make install-Zyklus ausgeht, ich bin mir sicher das Ryan Davis Patches diesbezüglich akzeptiert.

zentest

zentest sieht sich ein oder mehrere Rubyfiles an, und erzeugt Test::Unit-Stubs für Methoden ohne Testfälle. Das geht sowohl bei der Entwicklung von neuem Code, aber auch wenn man für bereits bestehenden Code Testfälle machen will.

Nehmen wir mal folgende einfache Klasse her:

class SomeClass

  attr_accessor :foo, :bar

  def initialize
    @foo = "foo"
    @bar = 42
  end

  def a_method
    nil
  end

  def another_method(a, b, c = 3)
    [a, b, c]
  end

  def yet_another_method
    "foo"
  end
end

zentest erzeugt uns automatisch unsere Testfälle:

$ zentest sample_program.rb > test/test_sample_program.rb
$ cat test/test_sample_program.rb 
# Code Generated by ZenTest v. 3.9.1
#                 classname: asrt / meth =  ratio%
#                 SomeClass:    0 /    3 =   0.00%

require 'test/unit' unless defined? $ZENTEST and $ZENTEST

class TestSomeClass < Test::Unit::TestCase
  def test_a_method
    raise NotImplementedError, 'Need to write test_a_method'
  end

  def test_another_method
    raise NotImplementedError, 'Need to write test_another_method'
  end

  def test_bar
    raise NotImplementedError, 'Need to write test_bar'
  end

  def test_bar_equals
    raise NotImplementedError, 'Need to write test_bar_equals'
  end

  def test_foo
    raise NotImplementedError, 'Need to write test_foo'
  end

  def test_foo_equals
    raise NotImplementedError, 'Need to write test_foo_equals'
  end
end

# Number of errors detected: 7

Der Witz dabei ist aber das zentest nur Testfälle für Methoden erstellt, für die wir noch keine Test haben. Dazu müssen wir zentest aber auch sagen wo unsere Testfälle liegen. Wenn wir also eine neue Methode yet_another_method zu unserer Klasse hinzufügen, müssen wir zentest so aufrufen:

$ zentest sample_program.rb test/test_sample_program.rb 
# Code Generated by ZenTest v. 3.9.1
#                 classname: asrt / meth =  ratio%
#                 SomeClass:    0 /    4 =   0.00%

require 'test/unit' unless defined? $ZENTEST and $ZENTEST

class TestSomeClass < Test::Unit::TestCase
  def test_yet_another_method
    raise NotImplementedError, 'Need to write test_yet_another_method'
  end
end

# Number of errors detected: 1

Statt nochmals Stubs für alle Methoden erzeugen, kriegen wir nur für unsere neue Methode den Stub. Diesen können wir bequem in unsere bereits bestehende Test-Datei kopieren, und munter weiter testen.

zentest ist dabei aber nicht hellseherisch, und sieht logischerweise Methoden, die durch Meta-Programmierung erzeugt werden, nicht.

unit_diff

unit_diff erleichtert das lesen des Outputs von Test::Unit. Statt dem normalen Output:

1) Failure:
test_to_gpoints(RouteTest) [test/unit/route_test.rb:29]:
<"new GPolyline([\n  new GPoint(  47.00000, -122.00000),\n  new GPoint(  46.5000
0, -122.50000),\n  new GPoint(  46.75000, -122.75000),\n  new GPoint(  46.00000,
 -123.00000)])"  expected but was
<"new Gpolyline([\n  new GPoint(  47.00000, -122.00000),\n  new GPoint(  46.5000
0, -122.50000),\n  new GPoint(  46.75000, -122.75000),\n  new GPoint(  46.00000,
 -123.00000)])">.

erzeugt unit_diff folgenden Output:

1) Failure:
test_to_gpoints(RouteTest) [test/unit/route_test.rb:29]:
1c1
< new GPolyline([
---
> new Gpolyline([

(Beispiele wurden aus der unit_diff-Dokumentation entnommen) Der Unterschied ist winzig, und wäre uns im normalen Output nur sehr schwer aufgefallen - unit_diff zeigt uns dagegen sofort wo das Problem liegt, und lässt den Rest weg.

Das verwenden von unit_diff ist dabei denkbar einfach: Wenn test.rb die Tests ausführt, muss man nur dessen Ausgabe an unit_diff weiterleiten:

$ test.rb | unit_diff

So einfach ist das.

autotest

autotest ist das interessanteste Programm aus der ZenTest-Suite: Es wartet bis wir Änderungen an unserem Unit-Tests abspeichern, und lässt dann automatisch unsere Unit-Tests (sowohl Test::Unit als auch RSpec) laufen. Damit erhalten wir sofort Feedback über die Auswirkung unserer Änderungen.

Nehmen wir unsere Test-Klasse mitsamt den von zentest generierten Unit-Tests von vorhin noch mal her (und nehmen an dass wir für alle Methoden bereits Tests implementiert haben). autotest geht davon aus dass die Unittests im Verzeichnis test (bzw. spec für RSpec) liegen, und das unser Code in 'lib' liegt. Wir starten einfach autotest:

$ ls -l1R
lib
test

./lib:
sample_program.rb

./test:
test_sample_program.rb
$ autotest 

# Waiting since 2008-03-04 18:35:10

/usr/bin/ruby -I.:lib:test -rtest/unit -e "%w[test/test_sample_program.rb].each { |f| require f }" | unit_diff -u
Loaded suite -e
Started
......F
Finished in 0.008107 seconds.

1) Failure:
test_yet_another_method(TestSomeClass) [./test/test_sample_program.rb:43]:
--- /var/folders/8Z/8ZNHjeOOHTOEQ15lS-+vH++++TI/-Tmp-/expect.46957.0    2008-03-05 00:35:45.000000000 +0100
+++ /var/folders/8Z/8ZNHjeOOHTOEQ15lS-+vH++++TI/-Tmp-/butwas.46957.0    2008-03-05 00:35:45.000000000 +0100
@@ -1 +1 @@
-something
+<nil>

7 tests, 7 assertions, 1 failures, 0 errors
================================================================================

# Waiting since 2008-03-04 18:36:15

Dieser Output sagt uns: Unsere neue Methode ist noch nicht korrekt implementiert. Sobald das geschehen ist und wir im Editor auf "Speichern" drücken, kriegen wir sofort folgenden Output:

/usr/bin/ruby -I.:lib:test -rtest/unit -e "%w[test/test_sample_program.rb].each { |f| require f }" | unit_diff -u
Loaded suite -e
Started
.......
Finished in 0.000579 seconds.

7 tests, 7 assertions, 0 failures, 0 errors
================================================================================

# Waiting since 2008-03-04 18:41:12

Jetzt sind alle Tests grün, und wir können neue Tests für neue Methoden implementieren, immer in dem Wissen das wir sofort sehen ob unsere Methoden auch wirklich das tun was wir wollen.

autotest kann auch feststellen welche Datei genau geändert wurde und nur die dazugehörigen Unittests laufen lassen, sofern man sich an das normale Namen-Schema hält (z.b. test_sample_program.rb enthält die Unit Tests für sample_program.rb)

Update

Zur Klarstellung: autotest erwartet das gewisse Regeln eingehalten werden:

Mac-User finden hier eine Möglichkeit autotest mit Growl zu verknüpfen - und dabei zeigt uns der Doom-Marine an wie es um unsere Tests steht.

Fin

Nächstes Mal: HTML-Parsen mit hpricot.


Kommentar schreiben

Name (notwendig)

Mail (wird nicht veröffentlicht)

Webseite


Kommentare

  1. Johannes schrieb am 05.03.2008 (08 Uhr)

    Ui, das liest sich sehr praktisch. Könntest du bitte mal einen generischen Aufbau des Testverzeichnisses geben? Ich bin mir da noch nicht ganz klar darüber. Was steht z.B. in test/unit und wo wird unsere someclass.rb includiert?

  2. cypher schrieb am 05.03.2008 (11 Uhr)

    Ich hab den Post erweitert. Falls es noch immer nicht ganz klar ist, einfach weiter fragen.

  3. Johannes schrieb am 05.03.2008 (12 Uhr)

    Hm, das meinte ich nicht ganz. In test_someclass.rb müsst doch noch ein "require 'lib/somclass.rb' hinein. Oder kennt der Test automagisch die Implementation der zu testenden Methoden? Wie sieht eine "korrekter" rake-task aus, der die Test laufen lässt? So desc "Runs the tests" task :test do chdir('test') do ruby 'test_someclass.rb' end end Geht's ja auch nicht, da es kein Verzeichnis "lib" im Verzeichnis "test" gibt.

  4. Johannes schrieb am 05.03.2008 (12 Uhr)

    Hmpf.. und jetzt verrate mir doch mal bitte, wie ich meine Kommentare besser formatieren kann!

  5. Johannes schrieb am 05.03.2008 (13 Uhr)

    Ah ok - hab gerade gesehen, dass es bei Rake dieses TestTask gibt der die Tests startet.

  6. cypher schrieb am 05.03.2008 (13 Uhr)

    Ahso.

    @Kommentare: Some HTML is allowed. (Ja, mir wäre auch Markdown oder sowas lieber, aber so ist es halt im Moment...)

    In der "test_someclass.rb" musst du nur ein require 'someclass' machen. autotest gibt das lib-Verzeichnis ja per -I-Parameter mit, wodurch Ruby automatisch auch in diesem Verzeichnis nach .rb-Dateien sucht.

    @Raketask: Dem Ruby-Aufruf musst du irgendwie den -I-Parameter mitgeben, so wie es autotest macht.

  7. Johannes schrieb am 05.03.2008 (13 Uhr)

    Ok danke.