Donnerstag, 25. November 2010

Keine Message-Befehle in Geschäftslogik!

Vor kurzem erhielt ich per Mail die Anfrage eines Entwicklers, wie er denn mit ABAP Units testen könne, ob eine bestimmte Fehlermeldung, die er in seiner Geschäftslogik durchläuft, gesendet oder nicht gesendet wird. Er hatte das schon auf verschiedene Weisen probiert, kam an dieser Stelle aber nicht weiter.

Wenn etwas sich als kompliziert testbar erweist, liegt es oft nicht am Unit Test oder gar, wie hier indirekt geargwöhnt wurde, am Unit Test Framework (zwischen den Zeilen lese ich "Pah! Mit ABAP Unit kann man nicht mal eine Errormeldung abfangen bzw. abfragen"), sondern meistens liegt das Problem im zu testenden Code selbst. So auch hier.

Nehmen wir an, es sei eine Plausibilitätsprüfung gefordert, dass ein vom Benutzer einzugebender Preis einen bestimmten Maximalbetrag nicht überschreiten darf. In der an die Transaktion gebundenen Klasse, die die Eingabewerte überprüft, finden wir vielleicht den folgenden Code:

if iv_input_price > gv_max_price.
* Der eingegebene Preis &1 ist zu hoch (Maximum: &2)
message e100 with iv_input_price gv_max_price.
endif.


Das funktioniert im GUI alles ganz wunderbar. Aber beim automatischen Testen des Codes ergeben sich Schwierigkeiten. Der Unit Test, der eine Überschreitung des Maximalbetrags simuliert, lässt sich nicht bis zu Ende ausführen. Wenn er auf die Fehlermeldung stösst, übergibt der Unit Test die Kontrolle zwangsläufig an den GUI zur Anzeige des Fehlers. Das bedeutet aber: Die Ausführung des Unit Tests stoppt an dieser Stelle. Beides - die Anzeige des Fehlers und die Unmöglichkeit, den Unit Test korrekt zu Ende zu führen - ist natürlich unerwünscht.

Wo liegt hier das Problem?

Auch wenn der produktive Code anscheinend ja korrekt funktioniert und das Problem nur beim Unit Test auftritt, liegt das Problem nicht beim Unit Test und schon gar nicht beim Unit Test Framework. Das Problem besteht darin, dass obiger Code die Prüflogik an die Existenz eines GUI bindet: Der message-Befehl ist eine Anweisung, die an den GUI zur Ausführung übergeben wird. Es ist nicht möglich, die Prüfung unabhängig vom GUI auszuführen. Das ist in vieler Hinsicht schädlich:

  • Der Code kann nicht im Hintergrund ausgeführt werden, z.B. in einem Job: Eine Errormeldung führt zum sofortigen Abbruch des Jobs. Wenn aber z.B. eine ganze Reihe von Belegen im Hintergrund verbucht werden sollen, ist dies nicht das erwünschte Verhalten: Dann soll normalerweise nur die Bearbeitung des aktuellen Belegs abgebrochen und mit dem nächsten Beleg fortgefahren werden.

  • Es ist nicht möglich, die Klasse mit dieser Prüfung in einer Web-Anwendung oder mit einem ganz anderen User Interface einzusetzen: Da die message-Anweisung nicht ausgeführt werden kann, kommt es zu einem Abbruch des Prozesses mit einem Kurzdump - im Web also zu einem "internen Serverfehler" (HTTP 500).

  • Es ist nicht möglich, die Logik dieser Klasse als API-Funktion anzubieten, da der Fehlerfall zu einer Situation führt, die nicht durch die Schnittstelle kontrolliert ist.



Alle Probleme der Informatik können durch Einführung einer neuen Indirektionsebene gelöst werden, lautet ein berühmtes Zitat von Butler Lampson.[1] Das ist auch in diesem Fall so. Genau um die Behandlung eines Fehlers von dessen Erkennung zu trennen, hat man die Exception erfunden. Statt eine Fehlersituation direkt im GUI zu melden, sollte das Programm eine Ausnahme auslösen, und zwar am besten gleich eine klassenbasierte. Diese Ausnahme wird in der Schnittstelle der Prüfmethode deklariert. Der Konsument dieser Methode kann dann mit einem try... catch... Block selbst entscheiden, was er im Ausnahmefall machen möchte. Nach Einführung einer solchen Ausnahme kann der Code beispielsweise folgendermassen aussehen:

if iv_input_price > gv_max_price.
raise exception type zcx_price_exceeds_bound
exporting
price = iv_input_price
bound = gv_max_price.
endif.


Der Unit Test einer solchen Logik gestaltet sich entsprechend einfach — und einfache Unit Tests sind meist ein Zeichen, dass der produktive Code ganz gut geraten ist. Hier der Test, dass die Prüfung eine Grenzwertüberschreitung erkennt und die erwartete Ausnahme auslöst:

method test_bound_detected.
try.
go_validator->check_price( gc_very_high_price ).
* Nicht OK - Ausnahme wurde nicht ausgelöst:
fail( 'Zu hoher Preis soll Ausnahme auslösen!').
catch zcx_price_exceeds_bound into lo_ex.
* OK - optional noch die Ausnahmeparameter prüfen:
assert_equals(
act = lo_ex->price
exp = gc_very_high_price
msg = 'Ausnahmeobjekt falsch parametrisiert' ).
assert_equals(
act = lo_ex->bound
exp = go_validator->gv_max_price
msg = 'Ausnahmeobjekt falsch parametrisiert' ).
endtry.
endmethod.


Auch der umgekehrte Test ist sinnvoll: Wenn ein hinreichend kleiner Preis (z.B. 1) eingegeben wird, soll keine Ausnahme ausgelöst werden:

method test_no_ex_for_price_in_range.
go_validator->check_price( 1 ).
endmethod.


Fehlt hier nicht noch etwas? Nein! Denn das Abfangen einer Ausnahme können wir getrost dem Unit Test Framework überlassen: Wenn es zu einer Ausnahme kommt, ist das ja ein Fehler im Programm. Also reagiert das Unit Test Framework bereits korrekt, indem es den Test test_no_exc_for_price_in_range in der Übersicht als fehlerhaft markieren wird. Siehe hierzu auch meinen Blog Unbehandelte Ausnahmen als Zusicherung.

Damit könnten wir den Fall als erledigt betrachten: Error Messages in der Prüflogik sollen unbedingt vermieden werden. Ende der Durchsage.

Trotzdem bleibt der unterschwellige Vorwurf bestehen, das Unit Test Framework könnte keine Error Messages abfangen (unabhängig davon, ob dies sinnvoll ist oder nicht). Die Antwort ist: Es kann - wenn wir bereit sind, eine weitere Indirektionsebene einzuführen! Indem ich ausführe, wie das geht, will ich keineswegs zur Verwendung von Error Messages raten. Es könnte aber sein, dass das Wissen um diese Möglichkeit in irgendwelchen Fällen einmal nützlich ist.

Tatsächlich bietet ABAP eine Möglichkeit, Fehlermeldungen abzufangen: Funktionsbausteine haben stets die eingebaute Ausnahme error_message. Wenn diese Ausnahme bei Aufruf eines Funktionsbausteins explizit abgefangen wird, so wird sie beim Auftreten einer Error Message innerhalb des vom Funktionsbaustein durchlaufenen oder aufgerufenen Codes ausgelöst. Man kann also nach Aufruf des Funktionsbausteins wie üblich den sy-subrc abfragen, um zu erkennen, dass eine Error Message ausgelöst wurde.

Nun gibt es für Methoden aus gutem Grund (s.o.) keinen solchen Mechanismus. Wir können jedoch einen Funktionsbaustein einführen, der dynamisch Methoden eines zur Laufzeit gegebenen Objekts mit einer zur Laufzeit gegebenen Parameterliste aufruft:

FUNCTION Z_TEST_E_MESSAGE.
*"----------------------------------------------------------------------
*"*"Lokale Schnittstelle:
*" IMPORTING
*" REFERENCE(IO_OBJECT) TYPE REF TO OBJECT
*" REFERENCE(IV_METHOD) TYPE CSEQUENCE
*" REFERENCE(IT_PARAMETERS) TYPE ABAP_PARMBIND_TAB
*"----------------------------------------------------------------------

call method io_object->(iv_method)
parameter-table it_parameters.

ENDFUNCTION.


Das folgende Beispielprogramm zeigt dann mithilfe eines Miniobjekts lcl_testee,
das auf Wunsch Error Messages erzeugt, wie das Auftreten oder Nichtauftreten einer
Error Message im Unit Test geprüft werden kann:

* ---
report z_test_e_message.

* --- How to test for error messages within unit tests

* ---
class lcl_testee definition.
public section.
methods unwise_check
importing iv_error type flag.
endclass. "lcl_testee DEFINITION

* ---
class lcl_testee implementation.
method unwise_check.
if iv_error eq 'X'.
message e001(bl)
with 'Never issue E-message in a validator'.
endif.
endmethod. "unwise_check
endclass. "lcl_testee IMPLEMENTATION

* ---
class lcl_test definition
for testing " #AU Risk_Level Harmless
inheriting from cl_aunit_assert. " #AU Duration Short

private section.
data go_testee type ref to lcl_testee.
methods:
setup,
prepare_parameters
importing iv_error type flag
exporting et_parameters type abap_parmbind_tab,
test_error for testing,
test_no_error for testing.


endclass. "lcl_test DEFINITION

*
class lcl_test implementation.
*
method setup.
create object go_testee.
endmethod. "setup
*
method test_error.
data: lt_parameters type abap_parmbind_tab,
lv_subrc type i.
call method prepare_parameters
exporting iv_error = 'X'
importing et_parameters = lt_parameters.
call function 'Z_TEST_E_MESSAGE'
exporting
io_object = go_testee
iv_method = 'UNWISE_CHECK'
it_parameters = lt_parameters
exceptions
error_message = 1.
lv_subrc = sy-subrc.
assert_not_initial(
act = lv_subrc
msg = 'Method should issue an error message' ).
endmethod. "test
*
method test_no_error.
data: lt_parameters type abap_parmbind_tab.
call method prepare_parameters
exporting iv_error = space
importing et_parameters = lt_parameters.
call function 'Z_TEST_E_MESSAGE'
exporting
io_object = go_testee
iv_method = 'UNWISE_CHECK'
it_parameters = lt_parameters
exceptions
error_message = 1.
assert_subrc( sy-subrc ).
endmethod. "test_no_error
*
method prepare_parameters.
data: ls_parameter type abap_parmbind,
lv_error type ref to flag.

clear et_parameters.

create data lv_error.
lv_error->* = iv_error.
ls_parameter-name = 'IV_ERROR'.
ls_parameter-kind = cl_abap_objectdescr=>exporting.
ls_parameter-value = lv_error.

insert ls_parameter into table et_parameters.

endmethod.

endclass. "lcl_test IMPLEMENTATION


Dieses Programm beweist, dass es durchaus möglich ist, Error Messages in Unit Tests abzufangen. Der beträchtliche Umfang dieses Testcodes für eine so einfache Sache wie das Auslösen einer Fehlermeldung ist allerdings ein untrüglicher "Code Smell" – in diesem Fall dafür, dass der message-Befehl an einem ungeeigneten Ort verwendet wird.


[1] Zitiert aus Greg Wilson, Andy Oram [Hg.]: Beautiful Code, O'Reilly, Sebastopol (CA), Juni 2007, S.279.

Keine Kommentare :