Donnerstag, 11. Februar 2010

Generierten Code automatisch testen

Der automatische Test von Programmteilen, die Code oder formatierte Daten erzeugen, sollte keine Erwartungen auf der Stringebene prüfen: Ein assert_equals( ) mit dem erwarteten Ergebnis ist zwar schnell hingeschrieben, macht den Code aber unflexibel. Kleine, irrelevante Änderungen an der Ausgabe werden vom Stringvergleich als Fehler gemeldet.

Zum Beispiel XML: Oft spielt in einem XML-Dokument die Reihenfolge, in der namensgleiche Elemente aufgeführt sind, keine Rolle. Und fast immer ist es egal, ob oder wieviel Leerraum zwischen den Elementen steht. Der Stringvergleich ist für die Prüfung von XML-Dokumenten eine Ebene zu tief.

Um wirklich das Wesentliche an der Ausgabe zu testen, empfiehlt es sich, einen Parser für das Ausgabeformat einzusetzen. Für Standardformate wie XML und CSV sind die Wege damit vorgegeben. Für andere Formate oder Codestrecken ist kein Parser zur Hand, oder er ist für den einzusetzenden Bereich zu mächtig. In diesen Fällen kann man sich die Mühe machen, einen Mini-Parser in die Infrastruktur der Tests mit aufzunehmen, der gerade ausreichend ist, um die erwarteten Ergebnisse zu prüfen.

Ein Beispiel aus der Praxis: Ich wollte eine Methode namens get_where_adrc( ) durch Modultests absichern, die aus vom Anwender getätigten Adressangaben eine Where-Bedingung für Selektionen der Datenbanktabelle ADRC (Teil der zentralen Adressverwaltung) erzeugt. Diese wird dann später in ABAP Open SQL wie folgt verwendet:
data: lv_where_adrc type string.
...
lv_where_adrc = get_where_adrc( is_selections ).
select * from adrc into table lt_adrc
for all entries in lt_knvk
where addrnumber eq lt_knvk-adrnp_2
and (lv_where_adrc).
...

Dabei enthält die Struktur is_selections die vom Benutzer eingegebenen Einschränkungen der Selektion: Die fünf Komponenten plz, ort, land, strasse und hausnr der Struktur werden in bestimmte, zum Teil indizierte Spalten der Tabelle adrc abgebildet, wobei meistens mit einem Muster gesucht wird. Eine typische generierte Klausel lautet
mc_city1 like '%BEUSON%NENDAZ%' and 
mc_street like '%RUE%GASPARD%' and
house_num1 like '%15%'

Für jede der fünf Komponenten kann ein Wert angegeben sein oder nicht. Das ergibt insgesamt 32 mögliche Kombinationen dieser Selektionen. Man könnte hier Äquivalenzklassen für ähnliche Tests wählen und für jede Klasse nur einen Repräsentanten testen. Mir erschien die Zahl 32 in diesem Fall überschaubar genug, um alle Möglichkeiten performant in einer Modultestklasse durchzuspielen.

Ich will hier nicht diskutieren, ob die Generierung einer dynamischen Where-Klausel für dieses Problem die richtige Designentscheidung gewesen ist. Auch will ich die konkreten Terme, die aus den Selektionen generiert werden, nicht hinterfragen. Eine Alternative wäre immerhin gewiesen, für jede Selektion einen Range mit der Option CP (contains pattern) zu füllen, all diese Ranges im Select-Statement anzugeben und die Arbeit, das korrekte Statement zu generieren, dem Datenbankinterface zu überlassen. Wie auch immer: Es gibt jedenfalls Fälle, in denen die von ABAP angebotene dynamische Whereklausel sinnvoll ist. Mir geht es in diesem Blog nur um die Frage, wie solche dynamisch generierten Codeteile sinnvoll getestet werden können.

Die Modultestklasse für die Methode get_where_adrc() soll also keinen Stringvergleich machen, da dies zu unflexibel wäre. Eigentlich soll in jedem Testfall nur sichergestellt werden, dass

  • alle angegebenen Selektionen im Ergebnis auftreten,
  • keine der nicht angegebenen Selektionen im Ergebnis auftritt,
  • die Komponentennamen auf die richtigen Spaltennamen der Datenbanktabelle abgebildet werden,
  • dass die Werte auf die erwartete Weise in ein LIKE-Muster oder in einen Vergleichswert für EQ abgebildet werden
  • dass die einzelnen Bedingungen syntaktisch korrekt mit AND aneinandergereiht werden.

Nicht geprüft werden soll dagegen, in welcher Reihenfolge die einzelnen Bedingungen in der Where-Klausel auftreten. Auch soll Leerraum zwar als Trennzeichen zwischen den einzelnen Wörtern wirken – es soll jedoch egal sein, welcher oder wieviel Leerraum dasteht.

Um im Fehlerfall einen schönen Überblick zu haben, für welche Kombinationen es nicht funktioniert hat, verwende ich 32 Testmethoden, deren Namen eine Verkettung der Komponentennamen darstellt, für die Selektionen angegeben wurden:
  method hausnr_ort_plz.
gs_sel-hausnr = '15'.
gs_sel-ort = 'Beuson- Nendaz'.
gs_sel-plz = '1996'.
gv_where = go_ref->get_where_for_adrc( gs_sel ).
check_where_clause_for( 'HAUSNR_ORT_PLZ' ).
endmethod. "hausnr_ort_plz


Natürlich habe ich diese 32 Methoden nicht von Hand hingeschrieben. Nachdem ich mir über die Implementierung einer beispielhaften Testmethode klargeworden war, schrieb ich ein Perl-Programm, das mir den Code dieser Methoden zum Einfügen in die ABAP-Testklasse generierte. Hier ist es:
my %values = (plz=>'1996',
ort=>'Beuson- Nendaz',
land=>'CH',
strasse=>'Rue Gaspard',
hausnr=>'15');
my @names = sort keys %values;
my ($fieldset, $generate, $pos, @fields);

for $generate ( \&generate_method_definition,
\&generate_method_implementation ) {
$fieldset = 0;
for (1..31) {
vec($fieldset,0,8) = $_;
$pos = 0;
# Die Felder mit in $fieldset gesetztem Bit übernehmen
@fields = grep { vec($fieldset,$pos++,1) } @names;
# Implementierungs- oder Definitionscode generieren
print &$generate( @fields );
}
}

sub generate_method_implementation {
my $assignments = join "\n",
map { " gs_sel-$_ = '$values{$_}'." } @_ ;
my $methname = join "_", @_;
return sprintf <<METHOD_IMPL, uc($methname);
method $methname.
$assignments
check_where_clause_for( '%s' ).
endmethod.
METHOD_IMPL
}

sub generate_method_definition {
my $methname = join "_", @_ ;
return " $methname for testing,\n";
}

Dieses Script generiert nacheinander den Definitions- und Implementierungsteil der Testmethoden zur Übernahme in die lokale Unittestklasse. Er sollte im wesentlichen selbsterklärend sein. Eine Besonderheit stellt wohl die Verwendung der Variablen $fieldset als Bitfeld dar. Das Bitfeld ist die richtige Datenstruktur für die hier vorliegenden Bildung aller Teilmengen einer gegebenen Grundmenge: In jedem Test ist ja für eine bestimmte Auswahl von Komponenten eine Selektion angegeben.

Bitfelder verwendet man in Perl mit der eingebauten Funktion vec(). Diese hat die angenehme Eigenschaft, sowohl auf der linken als auch auf der rechten Seite einer Zuweisung auftreten zu können. Steht sie links, so wird das Bitfeld als String von Bits ab dem angegebenen Offset mit dem rechts stehenden Wert gefüllt. Steht sie rechts, so wird das Bitfeld am angegebenen Offset gelesen.

Die Hauptschleife durchläuft also die Zahlen von 1 bis 31 und überträgt sie in ein Bitfeld. Durch Bit-Test der fünf relevanten Positionen wird dann ermittelt, welche Komponenten für den Test verwendet werden sollen und welche nicht. So entstehen 31 Testmethoden vom Typ der obenstehenden. Die 32. Testmethode namens empty() für den Fall, dass überhaupt keine Selektionen übergeben wurden, spielt eine Sonderrolle und wurde von Hand implementiert.

Das angekündigte Parsing der generierten Where-Klausel wird nun in der zentralen Testmethode check_where_clause_for( fields ) durchgeführt:
* --- Ein Mini-SQL-Parser, um Where-Klauseln zu prüfen
method check_where_clause_for.

data: lt_expected_fields type ty_field_exp_tab,
lv_subject type string,
lv_predicate type string,
lv_object type string,
lv_conjunction type string value 'and',
lv_last_conjunction type string,
lv_off type i,
lv_moff type i,
lv_mlen type i,
lv_s type string.

field-symbols: <ls_field_exp> type ty_field_exp.

* Welche Felder werden in der Where-Bedingung erwartet?
get_expected_fields(
exporting iv_fields = iv_fields
importing et_expected_fields = lt_expected_fields ).

do.

lv_last_conjunction = lv_conjunction.

clear: lv_subject, lv_predicate, lv_object, lv_conjunction.

* Mini-Where-Parser
*
* Findet atomare Bedingungen der Where-Klausel wie etwa:
*
* mc_city1 like '%BEUSON%NENDAZ%' and
*
* und weist zu:
*
* Subjekt = mc_city1
* Prädikat = like
* Objekt = %BEUSON%NENDAZ% (ohne Hochkommata)
* Konjunktion = and (oder leer, falls keine vorhanden)
*
find regex `\s*(\w+)\s+(like|eq)\s+'([^']+)'(?:\s+(and\b))?`
in section offset lv_off
of gv_where
submatches lv_subject
lv_predicate
lv_object
lv_conjunction
match offset lv_moff
match length lv_mlen
ignoring case.

if sy-subrc ne 0.

* Statement ist beendet
lv_s = gv_where+lv_off.
condense lv_s.

assert_initial( act = lv_s
msg = 'Unerwarteter Text am Statement-Ende' ).


exit.

else.

* Letzte Einzelbedingung muss mit and fortgesetzt werden
assert_equals( act = lv_last_conjunction
exp = 'and'
msg = 'Terme müssen durch and getrennt sein' ).


* Unerwartete Teile nach der letzten Bedingung?
if lv_moff > lv_off.
lv_s = gv_where+lv_off.
assert_initial( act = lv_s
msg = `Statement enthält unerwartete Zeichen ` ).
endif.

* Offset hochzählen für nächste Iteration
lv_off = lv_off + lv_mlen.


read table lt_expected_fields assigning <ls_field_exp>
with table key dbname = lv_subject.
if sy-subrc eq 0.

* Statement vorbereiten
concatenate
`Erwarte Wert '`
<ls_field_exp>-exp
` für `
<ls_field_exp>-name
into gv_msg respecting blanks.

assert_equals( act = lv_object
exp = <ls_field_exp>-exp
msg = gv_msg ).

* Feld wurde gefunden, aus Tabelle der erwarteten Felder löschen
delete table lt_expected_fields from <ls_field_exp>.

else.

* Unerwartetes Feld
concatenate
'Unerwartetes Feld '
lv_subject
' selektiert, das nicht in den Selektionen vorkommt'
into gv_msg respecting blanks.
fail( gv_msg ).

endif.

endif.
enddo.

assert_initial( act = lv_last_conjunction
msg = 'Nach letztem Term darf kein AND mehr kommen' ).

assert_initial( act = lt_expected_fields
msg = 'Im Select-Statement fehlen Felder,' &
'die in der Selektion angegeben wurden' ).


endmethod. "check_where_clause_for

Kernstück dieses Mini-Parsers für Where-Bedingungen ist ein regulärer Ausdruck, der auf elementare Selektionsbedingungen wie
mc_city1 like '%BEUSON%NENDAZ%' and

passt. Er zerlegt einen solchen Term in Subjekt, Prädikat, Objekt und die optionale Konjunktion and, die diesen Term ggf. mit dem nächsten verbindet. Aus der übergebenen Liste iv_fields der verwendeten Selektionsfelder wird eine interne Tabelle lt_expected_fields mit den Testerwartungen aufgebaut. Diese wird wie eine Streichliste nach jedem gefundenen Term verkleinert und muss am Schluss leer sein (sonst war das Statement grösser als erwartet).

Auf diese Weise werden genau die oben aufgeführten Erwartungen getestet, jedoch ohne Leerraum und Reihenfolge zu prüfen. Wenn beispielsweise ein neues Feld in die Selektion dazukommt, laufen alle bestehenden Tests fehlerfrei durch. Man kann weitere Tests für das neue Feld hinschreiben oder generieren - erst dann muss man an einer Stelle die Tabelle der Erwartungen um das neue Feld erweitern.

Natürlich werden die Tests bei mehr als fünf Freiheitsgraden zu unhandlich, man wird sich auf eine aussagekräftige Teilmenge einschränken müssen. Der Mini-Parser aber bleibt auch dann noch nützlich.

Entscheidend ist, einen solchen Parser mit Augenmass zu implementieren: Er sollte gerade soviel von SQL verstehen, wie für die Tests benötigt wird. Schliesslich ist er ja nur ein Helfer, um Erwartungen flexibel zu prüfen. Das Verhältnis stimmt nicht mehr, wenn wir mehr Aufwand auf einen solchen Helfer verwenden als auf die eigentlichen Tests.

Dies ist eine andere Lage als bei XML – wo Parser und sogar eigene, wieder in XML notierte Validierungssprachen wie Schematron zur Verfügung stehen, die wir einfach benützen können, um uns ganz auf die Tests und Testerwartungen zu fokussieren.

2 Kommentare :

Timo John hat gesagt…

Hallo,
hätte man das mit den Dynamischen Select nicht auch über den ABAP Query Dienst lösen können? der Prüft doch auch die "Ausfürhbarkeit" eines SQL Queries, der sich dynamisch bauen lässt?

Schöne Grüße

Rüdiger Plantiko hat gesagt…

Hallo Timo,

möglicherweise gibt es einen Baustein aus der ABAP Query-Welt, mit dem sich eine Where-Bedingung auf syntaktische Korrektheit prüfen lässt, also z.B. dass kein "and" am Schluss des Statements steht, dass die Feldnamen wirklich Spalten der DB-Tabelle sind usw. (Ich kenne allerdings keinen solchen Baustein.)

Die wesentliche Aufgabe des Modultest, von dem ich schrieb, war aber die Prüfung, dass immer die den jeweils angegebenen Selektionen entsprechende Where-Bedingung erzeugt wird. Hier kann ein generisches Tool wahrscheinlich nicht helfen.

Allenfalls könnte man, sich einen Parser für Where-Bedingungen in ABAP (wenn es so etwas gibt) zunutze machen, um die im Testfall generierte Where-Bedingung in ihre Bestandteile zu zerlegen und diese dann separat zu prüfen.

Der Gewinn wäre aber gering, denn der Blog zeigt ja, dass man mit wenig Aufwand einen regex hinschreiben kann, der gerade soviel Parsing ausführt, wie es nötig ist, um das konkrete Statement zu analysieren. Also: Bei Verwendung eines Standard-Parsers hat man nicht weniger Aufwand. Auch könnte der Standardparser mehr Rechenzeit kosten, da er ja auf alle möglichen Fälle vorbereitet sein muss. Modultests müssen aber in der Ausführung möglichst schnell sein.

Gruss,
Rüdiger