Donnerstag, 26. Juni 2008

Unit Tests im Vererbungsbaum

Unit Tests sichern, wie der Name sagt, eine Programmeinheit ab, sie beschränken sich auf den Code einer einzelnen Klasse (eines einzelnen Programms, einer einzelnen Funktionsgruppe). Damit eine Unit testbar wird, müssen ihre Aufrufe in fremde Programme und ihre Datenbankaufrufe abgeklemmt werden können. Für dieses Abklemmen gibt es ein bewährtes Vorgehen: Stubs - abgeleitete Dummyklassen, die in den Redefinitionen gerade so viel Code enthalten wie nötig, um die Methoden der zu testenden Unit durchlaufen zu können.

Das funktioniert wunderbar für Delegationsobjekte wie auch für API- und Datenbankaufrufe, die über lokale Klassen umgeleitet wurden. Was man als ABAP-Entwickler über dieses Vorgehen wissen muss, habe ich in seinen Grundzügen bereits vor einem Jahr in meinem Blog ABAP Modultests - aber wie? beschrieben.

Wie steht es um Modultests im Vererbungsbaum, wenn der zu testende Code auf Ober- und Unterklassen verteilt ist? Auch hier geht es in vielen Fällen ganz geradeaus nach Rezept. Aber es gibt auch eine Hürde im Zusammenhang mit lokalen Klassen, die ich hier diskutieren möchte.

Der Einfachheit halber nehme ich eine aus zwei Ebenen bestehende Hierarchie an, um "Oben" und "Unten" unterscheiden zu können und allgemeinen Code nach oben, speziellen Code nach unten zu verschieben. Dann stellt sich die Frage: Wie organisiere ich meine Modultests, um dieser Hierarchie gerecht zu werden?

Grundsätzlich soll ja genau der Code durch Modultests abgedeckt werden, der in der betreffenden Klasse implementiert ist. Daraus wäre abzuleiten, dass es in der Oberklasse Modultests für den allgemeinen, in der Unterklasse weitere Modultests für den speziellen Code geben muss. Für die Tests der Oberklasse haben wir dabei kein Problem. Sie enthält ja keine Abhängigkeiten von der Unterklasse. Ein Problem gibt es aber mit den Unterklassen. Denn der Code der Unter- lässt sich nicht immer von dem der Oberklasse trennen. Stellen wir uns eine in der Unterklasse redefinierte Methode m vor:

method m. "redefinition
call method super->m.
... " spezieller Code der Unterklasse
endmethod.


Dann habe ich in Modultests der Unterklasse keine Möglichkeit, den Aufruf von super->m() zu unterbinden. Ich bin also gezwungen, die in der Oberklasse enthaltenen Methodenimplementierungen in Modultests in der Unterklasse mitzutesten. Das Pseudoobjekt super lässt sich nämlich im Gegensatz zu gewöhnlichen Delegationsobjekten nicht stubben.

Eine einfache, aber meist leider unbrauchbare Lösung wäre, den speziellen Code in eine eigene private Methode _m zu verschieben. In ABAP wäre es mir möglich (anders als in Java), die private Methode _m zu testen: dazu müsste ich nur die Testklasse zum local friend der sie beherbergenden globalen Klasse erklären.

method m. "redefinition
call method super->m.
call method _m. " spezieller Code nun in neuer Methode _m
endmethod.


Das wird jedoch in der Praxis fehlschlagen (abgesehen davon, dass das Testen von privaten Methoden von manchen als anstössig angesehen wird - nicht von mir, aber zum Beispiel von Frank Westphal in [1], S.136): Denn der Code der Oberklasse ist meist eine notwendige Voraussetzung dafür, dass die redefinierte Methode überhaupt korrekt durchlaufen werden kann. Dies ist also eine Stelle, an der Ober- und Unterklasse untrennbar miteinander verflochten sind. Das erfordert, dass auch der Unit Test Ober- und Unterklasse gemeinsam testet. Wie lässt sich das bewerkstelligen?

Wenn die Oberklasse nur mit ihren eigenen Daten und ihrem eigenen Code beschäftigt ist, gibt es auch für die Unit Tests der Unterklasse kein Problem. Ein Problem gibt es erst dann, wenn sowohl in der Ober- als auch in der Unterklasse Aufrufe in fremde Objekte oder Datenbankselektionen "abzuklemmen" sind. Denn der Modultest der Unterklasse kann keine Stubs der Oberklasse herstellen: Die Testklasse nimmt - wie jede lokale Klasse - nicht an der Vererbungshierarchie teil, sondern ist lokal ihrer jeweiligen produktiven Klasse beigesellt. Unterklassen kennen nicht die lokalen Klassen ihrer Oberklassen. Die Testklasse der Unterklasse weiss insbesondere nichts von der Testklasse der Oberklasse und auch nichts davon, welche Stubs in der Oberklasse bereitzustellen sind. Irgendeine Kommunikation mit diesen Stubs muss jedoch möglich sein, denn sonst kann der Test der Unterklasse den Stub nicht mit Daten versorgen.

Die Lösung ist eine Methode setup_test() in der Oberklasse, die in der Unterklasse redefiniert wird und alle benötigten Stubs bereitstellt. Unmittelbar nach Instanzerzeugung wird im Test diese Methode aufgerufen. Die Methode hat darüberhinaus Exportparameter, die mit Referenzen auf die zur Datenbanksimulation verwendeten internen Tabellen gefüllt werden. So kann der Testfall der Unterklasse den Stub der Oberklasse mit Daten versorgen - und/oder nach Durchlaufen der Methode die Testerwartungen anhand der Stub-Daten verifizieren.

Hier ein Beispiel aus einer produktiven Klassen zum Lesen und Verbuchen von Paletten in einem Handelsunternehmen. Die Oberklasse heisst hier zcl_sscc_we und hat mehrere Unterklassen, je nach Typ des Vorlagebelegs. Eine dieser Unterklassen ist zcl_sscc_we_zu_lieferung für den lieferbezogenen Wareneingang.

Die in der Oberklasse zcl_sscc_we definierte geschützte Methode setup_test() hat hier folgende Definition:

methods setup_test
exporting
* Palettencodes
et_zsscc type ref to zsscc_tab
* Packpositionen je Palette
et_vepo type ref to tab_vepo
* Ein- und Ausgangsinterface des BAPI
es_goodsmvt type ref to zbapi_goodsmvt_data.


Die Zeiger auf zsscc_tab, tab_vepo und zbapi_goodsmvt_data erlauben es der Unterklasse, die von den Stubs der Oberklasse verwendeten Testdaten zu bestimmen oder zu verifizieren. In der Implementierung von setup_test() in der Oberklasse werden die Stub-Objekte gebildet, die Referenzen an die Schnittstelle weitergereicht und schliesslich der gewöhnliche produktive setup() gerufen (der dann mit den Stubs go_api und go_db an Stelle der produktiven Objekte arbeitet).

method setup_test.

data: lo_db type ref to lcl_test_db,
lo_api type ref to lcl_test_api.

* Stub-Instanzen von LCL_DB und LCL_API für Unittestzwecke beschaffen
create object lo_db.
go_db ?= lo_db.

et_zsscc = lo_db->gt_zsscc.
et_vepo = lo_db->gt_vepo.

create object lo_api.
go_api ?= lo_api.
get reference of lo_api->gs_goodsmvt into es_goodsmvt.

setup( ).

endmethod.


In der Unterklasse wird die Methode setup_test() redefiniert, um weitere Stubs der Unterklasse zu bilden.

method setup_test.

data: lo_db type ref to lcl_test_db.

create object lo_db.
go_db ?= lo_db.

call method super->setup_test
importing
et_zsscc = et_zsscc
et_vepo = et_vepo
es_goodsmvt = es_goodsmvt.


* Datenreferenzen auf ZSSCC- und VEPO-Simulation an Stub der Unterklasse übergeben
lo_db->gt_zsscc = et_zsscc.
lo_db->gt_vepo = et_vepo.


endmethod.


Der Aufruf von setup_test() erfolgt in der Methode setup() der Testklassen:

method setup.
data: ls_zsscc type zsscc,
lt_wueb type standard table of wueb.

* WE-Instanz für diesen Prozess
go_we ?= zcl_sscc_we_zu_lieferung=>_get_instance( '1' ).
go_we->setup_test( importing es_goodsmvt = gs_goodsmvt ).

* Casting
go_db ?= go_we->go_db.


endmethod. "setup


Die Testmethoden selbst sind nach diesen Vorarbeiten von der üblichen Einfachheit: Testdaten vorbereiten, Testmethode aufrufen, Erwartungen prüfen.
Hier ein Beispiel, in dem das korrekte Lesen einer Palette überprüft wird. Zunächst werden die internen Tabellen mit den Daten der Referenzbelege vorbereitet. Hierzu habe ich ein Macro _insert_row_n_fields, das eine Auswahl von Feldern einer internen Tabelle auffüllt. In diesem Fall werden die dereferenzierten, in der Oberklasse beheimateten Datenobjekte go_db->gt_vepo->* und go_db->gt_zsscc->* versorgt, aber auch eine interne Tabelle go_db->gt_wueb der Unterklasse. Dann wird die Methode palette_lesen aufgerufen, und schliesslich wird der Rückgabewert et_item daraufhin geprüft, ob er die erwarteten Werte enthält. Auch zum Inspizieren einer internen Tabelle habe ich ein Macro _assert_n_fields_in_row, das zur angegebenen Zeilennummer prüft, ob die angegebenen Felder die erwarteten Werte haben. Der Code des Tests ist nach diesen Vorbereitungen sequentiell, enthält keine besondere Logik mehr. Wie es ja auch sein soll.
method lesen_1_pal_1_lief.

data: ls_head type zwe_head_display,
lt_item type zwe_item_display_tab.

* --------------------------------------------------------------------
* 1 Palette mit einer Lieferung, die genau eine Position enthält
* --------------------------------------------------------------------

* Daten setzen für Paletten
* --- ZSSCC
_insert_row_n_fields go_db->gt_zsscc->*
'exidv;venum;vbeln;ebeln;zproc':
'E1;V1;L1;B1;1'.

* --- VEPO
_insert_row_n_fields go_db->gt_vepo->*
'venum;vepos;velin;vbeln;posnr;matnr;vemng;vemeh;zzwemng':
'V1;1;1;L1;10;M1;5;CU;1'.

* --- WUEB
_insert_row_n_fields go_db->gt_wueb
'matnr;erfmg;erfme;vbeln;vbelp':
'M1;10;CU;L1;10'.

* --- Testaufruf
call method go_we->palette_lesen
exporting
iv_exidv = 'E1'
importing
et_item = lt_item.

* --- Ergebnis: Eine Position mit erwarteter Menge und Referenzdaten
_assert_rows 'lt_item' lt_item 1.
_assert_n_fields_in_row 'lt_item' lt_item
'exidv;vbeln;refblnr;refposnr;matnr;wemenge;weme'
1 'E1;L1;L1;10;M1;4;CU'.

endmethod. "lesen_1_pal_1_lief


Eine optimale Lösung für diese Art von Tandem-Tests gibt es wohl nicht. Die hier vorgestellte Lösung hat den Preis, dass eine Methode in den produktiven Code der Klasse hereingenommen werden muss, die nur für die Ausführung von Unit Tests bestimmt ist. Aber wenn sich der Code der Ober- und Unterklasse nicht trennen lassen, müssen auf irgendeine Art auch die Testklassen der Ober- und Unterklasse miteinander kommunizieren können - mit der Methode setup_test arbeitet man dabei entlang der Vererbungslinie. Wenigstens sollten wir diese Methode als protected erklären und somit aus der öffentlichen Schnittstelle herausnehmen - in ABAP können wir die geschützte Methode trotzdem in den lokalen Testklassen aufrufen, indem wir diese zu local friends machen.

[1] Frank Westphal: Testgetriebene Entwicklung mit JUnit & Fit, dpunkt-Verlag, Heidelberg 2006.

Keine Kommentare :