Montag, 25. Juli 2011

ABAP Unit Tests für BSP-Elemente

Unit Tests sollten möglichst wenig Abhängigkeiten von der Aussenwelt aufweisen. Sie sollten sich darauf beschränken, eine isolierte Softwarekomponente aufzurufen und die erwarteten Ergebnisse zu prüfen.

Soweit die Theorie. Wie ist diese auf ein BSP-Element anzuwenden? Wir werden sehen, dass in diesem Fall Abstriche beim Prinzip der Isoliertheit von Modultests gemacht werden müssen – nach dem Motto "Keine Regel ohne Ausnahme". Ausnahmen erfordern allerdings eine Begründung.

Ein BSP-Element wird in einem View aufgerufen und produziert HTML-Code (in der Regel). Um diesen Aufruf in einem automatischen Test durchzuführen, muss die nötige Infrastruktur bereitgestellt werden: Der View kann sich nur in einer BSP-Applikation befinden. Diese muss durch die BSP-Runtime aufgerufen werden. Der Test muss also in irgendeiner Form in der Lage sein, einen HTTP-Request auszuführen und das Ergebnis beispielsweise in Form eines Strings entgegenzunehmen, um es gegen das erwartete Ergebnis zu prüfen.

Das ist die einzige Möglichkeit, BSP-Elemente über ihre "öffentliche Schnittstelle" zu testen, denn die öffentliche Schnittstelle besteht in diesem Fall nicht aus einem Interface im technischen Sinne: Obwohl alle BSP-Elementbehandlerklassen das Interface IF_BSP_ELEMENT implementieren, kann es für den Testaufruf ebensowenig dienen wie irgendeine andere öffentliche Methode. Damit ein Element korrekt arbeitet, muss nämlich ein aktueller Seitenkontext bereitstehen - mindestens in Form einer Referenz auf einen View und einer HTTP-Response-Instanz.

Die vollständige Liste aller benötigten Zutaten verbirgt sich aber in den Details der BSP-Runtime. Es wäre also eine schlechte Strategie, für alle Hilfs- und Kontextobjekte, von denen wir feststellen, dass sie benötigt werden, Stub-Objekte bereitzustellen. Mit dieser Strategie würden wir uns von den Implementierungsdetails der BSP-Runtime abhängig machen.

Die natürliche öffentliche Schnittstelle eines BSP-Elements ist ihr Aufruf in einem BSP-View. Der natürliche "Rückgabewert" eines solchen Aufrufs ist HTML-Code.

Der Unit Test eines BSP-Elements muss also einen HTTP-Request ausführen, um eine Testseite aufzurufen, die ausser dem BSP-Element selbst gar keinen oder nur wenig Code enthält. Der Test ist somit nicht mehr wirklich isoliert. Es werden Hilfsklassen, sogar eine Hilfs-BSP benötigt, und die Basisklassen für die Durchführung von HTTP-Requests müssen aufgerufen werden, so dass sehr viel mehr Code mitläuft als nur der Code der zu testenden Elementbehandlerklasse.

Aber: Die Verletzung des "Isoliertheits"-Prinzips für Modultests ist im Fall der BSP-Elemente wirklich notwendig, um das Element über seine öffentliche Schnittstelle, also in seinem normalen Laufzeit-Einsatz zu beobachten.

Wie macht man das nun konkret?

Es empfiehlt sich, alle Modultestklassen von einer gemeinsamen (lokalen) Oberklasse erben zu lassen. In dieser Oberklasse implementiert man die Ausführung des HTTP-Requests, denn dieser Code wird in allen Modultestklassen benötigt werden, gehört also "nach oben".

In der Modultestklasse kann man dann den Request ausführen, indem man die dafür zuständige Methode der Oberklasse aufruft: frühestens in der Methode CLASS_SETUP und spätestens in der einzelnen Testmethode selbst (wenn der Request nur dort gebraucht wird). Die Ausführung des Requests erfolgt mit der Klasse CL_HTTP_CLIENT.

Die folgende Abbildung zeigt einen typischen Callstack, der dabei entsteht:

Callstack eines HTTP-Requests im Unittest eines BSP-Elements


  • Der Callstack eines beliebigen Modultests beginnt immer mit dem Basisreport RS_AUNIT_CLASSTEST_SESSION. Dieser wird vom Modultestframework per SUBMIT aufgerufen: So ist sichergestellt, dass jeder Test in einem eigenen internen Modus läuft, so dass er gegenüber der Laufzeit und gegenüber anderen Tests besser gekapselt ist.
  • In diesem Beispiel wird der HTTP-Request bereits beim CLASS_SETUP ausgeführt, der auf Ebene 11 aufgerufen wird.
  • Der CLASS_SETUP ruft daher in der allen Modultestklassen gemeinsamen Oberklasse LCL_AU_ALL die Methode RENDER_VIEW auf (Ebene 12).
  • Ebene 13 ist die Utility-Methode zcl_http_util->do_request, die einen vereinfachten Zugriff auf das HTTP-Client-Objekt cl_http_client durchführt.
  • Jeder HTTP-Request an den ABAP-Stack eines SAP-Systems durchläuft den Funktionsbaustein HTTP_DISPATCH_REQUEST (hier auf Ebene 16).
  • Das ICF stellt fest, dass ein BSP-Controller aufzurufen ist, was über den Behandler CL_HTTP_EXT_BSP gemacht wird: Ebene 18.
  • Das BSP-Framework ruft den zuständigen Controller auf, in dessen DO_REQUEST-Implementierung (Ebene 21)...
  • ... dann die Testseite aufgerufen wird (Ebene 26).
  • In dieser Seite befindet sich das hier zu testende <z:table>-Tag. Dessen DO_AT_END()-Methode enthält schliesslich den zu testenden Code (Ebene 29)!

Interessant an diesem Callstack ist noch, dass Aufruf des HTTP-Requests und seine Behandlung im selben Callstack stattfinden. Das ist nicht normal: Normalerweise würde ein eingehender HTTP-Request über einen ganz eigenen Prozess vom System abgearbeitet. In diesem Fall aber wird der Funktionsbaustein HTTP_DISPATCH_REQUEST direkt aufgerufen. Um dies zu erreichen, haben wir das HTTP-Clientobjekt mit der dafür vorgesehenen Methode cl_http_client=>create_internal erzeugt. Der Vorteil ist, dass man bei Bedarf vor oder nach Ausführung des Requests im Test auf öffentliche statische Komponenten des zu testenden Objekts zugreifen kann. Das kann manchmal nützlich sein.

Der Preis für den internen HTTP-Request ist, dass pro Testklasse nur ein Request auf einen BSP-Controller ausgeführt werden kann: Die klasse CL_BSP_RUNTIME merkt sich nämlich in einer statischen Variablen einige Objekte des aktuellen Kontexts, z.B. die Controllerinstanz, und baut diese nach Abarbeitung des Requests nicht ab. Erfolgt im selben Modus ein zweiter Request, wird die Controllerinstanz des letzten Aufrufs verwendet, was zu unerwarteten Ergebnissen führt.

Die Lösung dafür ist: Man organisiere seine Tests so, dass pro Modultestklasse genau ein HTTP-Request ausgeführt wird! Das Ergebnis, beispielsweise in Form eines Instanzattributs gv_html, kann dann von den einzelnen Testmethoden nach verschiedenen Gesichtspunkten geprüft werden.

Trotz des beeindruckend grossen Callstacks bei der Ausführung kann der eigentliche Testcode des Unittests durchaus kompakt und lesbar bleiben. Um dies zu demonstrieren, sei hier eine Testmethode gezeigt, die verifiziert, dass das vom Element <z:table> gerenderte HTML-Fragment eine HTML-Table <table> mit der übergebenen ID ist (in weiteren Methoden derselben Testklasse wird dann der Inhalt dieser <table> geprüft:
  method check_table_id.
data: lo_root type ref to if_ixml_element,
lv_name type string,
lv_id type string.
* <table id="test">
lo_root = go_document->get_root_element( ).
lv_name = lo_root->get_name( ).
assert_equals( exp = 'table'
act = lv_name
msg = 'Root-Element ist nicht <table>' ).
lv_id = lo_root->get_attribute( 'id' ).
assert_equals( exp = 'test'
act = lv_id
msg = 'Tabellen-ID ist nicht ''test''' ).
endmethod. "check_table_id
Da das vom Element <z:table> erzeugte HTML-Codefragment nach XML-Syntax wohlgeformt ist (nähere Infos zum erzeugten HTML-Code des <z:table>-Tags enthält mein Artikel Präsentation von Tabellen mit CSS), parse ich es mit Vorteil in ein Objekt go_document vom Typ ref to if_ixml_document und kann dieses dann in den Testmethoden mit den XML-DOM-Zugriffsfunktionen untersuchen.

Ergänzend möchte ich hier noch den immer gleichen Code zeigen, der zur Ausführung eines HTTP-Requests benötigt wird. Ich habe ihn in die Methode do_request einer Utility-Klasse zcl_http_util gepackt, deren vollständiger Code auch unter http://bsp.mits.ch/code/clas/zcl_http_util betrachtet werden kann. Diese Klasse wird natürlich nicht nur für den hier diskutierten Fall verwendet, sondern überall im ganzen System, wenn es darum geht, vom SAP-System aus HTTP-Requests auszuführen (an welches System auch immer diese gerichtet sind).

Wie man sieht, lasse ich dem Aufrufer neben der Ausführung des Requests im eigenen Callstack auch die Option eines "normalen" Requests, der für URLs ohne Host und Port (deren erstes Zeichen also ein '/' ist) über die eingebaute HTTP-Destination 'NONE' an das eigene System gerichtet wird. Alle anderen Requests richten sich wirklich an einen anderen Server.

method do_request .

data: lv_text type string,
lv_url type string.

* Einen HTTP-Request ausführen
lv_url = iv_url.
if iv_internal eq 'X'.
* Request geht auf diesen Server - dann wird gleich der FB
* HTTP_DISPATCH_REQUEST aufgerufen (im selben Callstack!)
* Achtung: Das Attribut server->request wird überschrieben,
* da die aktuelle server-Instanz wiederverwendet wird!
* Daher iv_internal = 'X' nur verwenden,
* wenn man nicht bereits in einer Requestbearbeitung ist.
cl_http_client=>create_internal(
importing client = go_client ).
cl_http_utility=>set_request_uri( request = go_client->request
uri = lv_url ).
else.
if iv_reuse_client_object eq space or
go_client is initial.
if lv_url(1) = '/'.
* URL = Pfad bedeutet, Request geht auf diesen Server
* iv_internal = space: Request mit eigenem Callstack absetzen
call method cl_http_client=>create_by_destination
exporting
destination = 'NONE'
importing
client = go_client
exceptions
argument_not_found = 1
destination_not_found = 2
destination_no_authority = 3
plugin_not_active = 4
internal_error = 5
others = 6.
if sy-subrc <> 0.
message id sy-msgid type sy-msgty number sy-msgno
with sy-msgv1 sy-msgv2 sy-msgv3 sy-msgv4
raising http_error.
endif.
cl_http_utility=>set_request_uri( request = go_client->request
uri = lv_url ).
else.
* Normalfall: Externer HTTP-Request
call method cl_http_client=>create_by_url
exporting
url = lv_url
importing
client = go_client
exceptions
argument_not_found = 1
plugin_not_active = 2
internal_error = 3
others = 4.
if sy-subrc ne 0.
message id sy-msgid type sy-msgty number sy-msgno
with sy-msgv1 sy-msgv2 sy-msgv3 sy-msgv4
raising http_error.
endif.
endif.
else.
cl_http_utility=>set_request_uri( request = go_client->request
uri = lv_url ).
endif.
endif.

* CDATA angegeben? Dann mit senden
if iv_cdata is not initial.
go_client->request->set_cdata( iv_cdata ).
endif.

* Weitere spezifische Anreichungen des Request-Objekts?
raise event prepare_request
exporting
eo_request = go_client->request.

* Request absenden
call method go_client->send
exceptions
http_communication_failure = 1
http_invalid_state = 2
http_processing_failed = 3
others = 4.
if sy-subrc ne 0.
go_client->get_last_error( importing message = lv_text ).
message lv_text type 'I'
raising http_error.
endif.

go_client->receive( exceptions others = 1 ).
if sy-subrc ne 0.
go_client->get_last_error( importing message = lv_text ).
message lv_text type 'I'
raising http_error.
endif.

ev_html = go_client->response->get_cdata( ).

* Aus Kompatibilitätsgründen muss et_html angeboten werden
if et_html is requested.
call function 'Z_SPLIT_STRING_TO_TABLE'
exporting
iv_string = ev_html
importing
et_table = et_html.
endif.

* Ggf. Verbindung schliessen
if iv_reuse_client_object eq space.
go_client->close( ).
clear go_client.
endif.

endmethod.

Keine Kommentare :