Application Development Blog Posts
Learn and share on deeper, cross technology development topics such as integration and connectivity, automation, cloud extensibility, developing at scale, and security.
cancel
Showing results for 
Search instead for 
Did you mean: 
rainer_winkler
Contributor
I will give an example how to start with ABAP unit test. I start with a simple migration ABAP migration report. The report reads and change data on the database. So the unit test has to cope with external dependency. Code without external dependency is rare in real life. So I show how I write unit tests when external dependencies are an issue. This is in at least 95% of all code I write the case.

I will sometime deviate from common conventions. I do this because I show how I code in my daily work.

All code I show is on Github here: https://github.com/RainerWinkler/ABAP-Unit-Test-Demo. Use AbapGit to install it on your own SAP system.

The report to be tested


Assume prices in SFLIGHT are wrong due to an error. The following report corrects these:
REPORT zunitdemo_ex1_migrator.

TABLES sflight.

" Correct prices in SFLIGHT
" See also program documentation
" Assume that amount of data is so small, that it can be changed in a single commit

PARAMETERS: p_code TYPE n LENGTH 4.

IF p_code <> '4752'.
WRITE: / 'Wrong code, nothing will be done'.
RETURN.
ENDIF.

DATA: modified TYPE sflight,
modifieds TYPE STANDARD TABLE OF sflight WITH DEFAULT KEY.

SELECT * FROM sflight INTO TABLE @DATA(sfs).

LOOP AT sfs INTO DATA(sf).
IF sf-fldate EQ '20200101'.
IF sf-price IS NOT INITIAL.
modified = sf.
IF sf-price < 100.
ADD 1 TO modified-price.
ELSEIF sf-price < 1000.
ADD 10 TO modified-price.
ELSE.
ADD 20 TO modified-price.
ENDIF.
modifieds = VALUE #( BASE modifieds ( modified ) ).
ENDIF.
ENDIF.

ENDLOOP.

IF modifieds IS NOT INITIAL.
MODIFY sflight FROM TABLE modifieds.
ENDIF.

Adapt report to simplify test


Above report has to be adapted.

  • The report migrates always the complete SFLIGHT table. To test it test data shall be written to the database table. The coding has therefore to be able to run only in the data range of the test data.

  • Unit tests can only be done in functions and classes. The logic is therefore extracted to a class.


REPORT zunitdemo_ex1_migrator_2.

TABLES sflight.

" Correct prices in SFLIGHT
" See also program documentation
" Assume that amount of data is so small, that it can be changed in a single commit

SELECT-OPTIONS s_carr FOR sflight-carrid.
PARAMETERS: p_code TYPE n LENGTH 4.

START-OF-SELECTION.

IF p_code <> '4752'.
WRITE: / 'Wrong code, nothing will be done'.
RETURN.
ENDIF.


DATA: migrator TYPE REF TO zunitdemo_ex1_cl_migrator_2.
migrator = NEW #( ).

" Copy select table to range with is transferred to the class

DATA carrid_range TYPE migrator->ty_carrid.
LOOP AT s_carr INTO DATA(sc).
APPEND sc TO carrid_range.
ENDLOOP.

migrator->migrate( EXPORTING carrid_range = carrid_range ).

And class
CLASS zunitdemo_ex1_cl_migrator_2 DEFINITION
PUBLIC
CREATE PUBLIC .

PUBLIC SECTION.
TYPES ty_carrid TYPE RANGE OF sflight-carrid.
METHODS migrate
IMPORTING carrid_range TYPE ty_carrid.
PROTECTED SECTION.
PRIVATE SECTION.
ENDCLASS.


CLASS zunitdemo_ex1_cl_migrator_2 IMPLEMENTATION.
METHOD migrate.

DATA: modified TYPE sflight,
modifieds TYPE STANDARD TABLE OF sflight WITH DEFAULT KEY.

SELECT * FROM sflight INTO TABLE @DATA(sfs) WHERE carrid IN @carrid_range.

LOOP AT sfs INTO DATA(sf).
IF sf-fldate EQ '20200101'.
IF sf-price IS NOT INITIAL.
modified = sf.
IF sf-price < 100.
ADD 1 TO modified-price.
ELSEIF sf-price < 1000.
ADD 10 TO modified-price.
ELSE.
ADD 20 TO modified-price.
ENDIF.
modifieds = VALUE #( BASE modifieds ( modified ) ).
ENDIF.
ENDIF.

ENDLOOP.

IF modifieds IS NOT INITIAL.
MODIFY sflight FROM TABLE modifieds.
ENDIF.

ENDMETHOD.

ENDCLASS.

Add unit test


I go to the tab "Test Classes"


Click on the button


Write test in the first line and press Ctrl Space.


Basic statements are automatically created. I add two more lines to be able to test also private methods. This may be helpful sometimes.
CLASS ltcl_test DEFINITION DEFERRED.
CLASS zunitdemo_ex1_cl_migrator_2 DEFINITION LOCAL FRIENDS ltcl_test.
CLASS ltcl_test DEFINITION FINAL FOR TESTING
DURATION SHORT
RISK LEVEL HARMLESS.

PRIVATE SECTION.
METHODS:
first_test FOR TESTING RAISING cx_static_check.
ENDCLASS.


CLASS ltcl_test IMPLEMENTATION.

METHOD first_test.
cl_abap_unit_assert=>fail( 'Implement your first test here' ).
ENDMETHOD.

ENDCLASS.

With a right mouse click you can run it. It will fail due to the fail method which is called:




Adapt unit test to test the report


I assume the table SFLIGHT to be filled in the development system. These data shall not be migrated. But a certain range can be used for the tests.

The test will:

  • Add test data to the database

  • Run the migration

  • Check that the test data is correctly migrated


Do not forget: A new test should always fail first. This is done to test the test. So before I finalized the test below I checked that it failed with a wrong value of the expected price:


This is really important! The success of your work depends on tests which find errors. Believe me, when you omit this step you learn yourself how often it may happen that a test always returns a green result, whatever you write in the coding.
CLASS ltcl_test DEFINITION DEFERRED.
CLASS zunitdemo_ex1_cl_migrator_2 DEFINITION LOCAL FRIENDS ltcl_test.
CLASS ltcl_test DEFINITION FINAL FOR TESTING
DURATION SHORT
RISK LEVEL HARMLESS.

PRIVATE SECTION.
DATA f_cut TYPE REF TO zunitdemo_ex1_cl_migrator_2.
METHODS:
setup,
simple FOR TESTING RAISING cx_static_check.
ENDCLASS.


CLASS ltcl_test IMPLEMENTATION.

METHOD setup.
f_cut = NEW #( ).
ENDMETHOD.

METHOD simple.

" Prepare test data for migration
DELETE FROM sflight WHERE carrid = 'TST'.
COMMIT WORK AND WAIT.

DATA: test_data TYPE STANDARD TABLE OF sflight WITH DEFAULT KEY.

test_data = VALUE #( (
carrid = |TST|
connid = 1
fldate = |20200101|
price = 1
) ).

INSERT sflight FROM TABLE test_data.
COMMIT WORK AND WAIT.

" Migrate
f_cut->migrate( carrid_range = VALUE #( ( sign = |I| option = |EQ| low = 'TST' ) ) ).
COMMIT WORK AND WAIT.

" Check correct migration
DATA: expecteds TYPE STANDARD TABLE OF sflight WITH DEFAULT KEY,
actuals TYPE STANDARD TABLE OF sflight WITH DEFAULT KEY.
expecteds = VALUE #( (
mandt = sy-mandt
carrid = |TST|
connid = 1
fldate = |20200101|
price = 2
) ).

SELECT * from sflight INTO TABLE @actuals WHERE carrid = 'TST'.

cl_abap_unit_assert=>assert_equals( msg = 'Expect correctly migrated data' exp = expecteds act = actuals ).

ENDMETHOD.

ENDCLASS.

The test data is intentionally not deleted after the test. It is sometimes helpful to inspect it after the test:

Make tests easier to read


The test method is OK when only very few tests exist. With more and more tests it becomes difficult to see what a test is doing.

So I make the test more readable. I use global variables. This is not a problem and has the benefit that the coding is shorter and easier to read.

I add also a second test.
CLASS ltcl_test DEFINITION DEFERRED.
CLASS zunitdemo_ex1_cl_migrator_3 DEFINITION LOCAL FRIENDS ltcl_test.
CLASS ltcl_test DEFINITION FINAL FOR TESTING
DURATION SHORT
RISK LEVEL HARMLESS.

PRIVATE SECTION.
DATA f_cut TYPE REF TO zunitdemo_ex1_cl_migrator_3.

DATA: test_data TYPE STANDARD TABLE OF sflight WITH DEFAULT KEY,
expecteds TYPE STANDARD TABLE OF sflight WITH DEFAULT KEY.
METHODS:
setup,
_prepare_test_data,
_migrate,
_check
IMPORTING
message TYPE string,
simple FOR TESTING RAISING cx_static_check,
simple2 FOR TESTING RAISING cx_static_check.
ENDCLASS.


CLASS ltcl_test IMPLEMENTATION.

METHOD setup.
f_cut = NEW #( ).
ENDMETHOD.

METHOD simple.

test_data = VALUE #( (
carrid = |TST|
connid = 1
fldate = |20200101|
price = 1
) ).

_prepare_test_data( ).
_migrate( ).

expecteds = VALUE #( (
mandt = sy-mandt
carrid = |TST|
connid = 1
fldate = |20200101|
price = 2
) ).

_check( 'Expect correctly migrated data' ).

ENDMETHOD.

METHOD simple2.

test_data = VALUE #( (
carrid = |TST|
connid = 1
fldate = |20200101|
price = 100
) ).

_prepare_test_data( ).
_migrate( ).

expecteds = VALUE #( (
mandt = sy-mandt
carrid = |TST|
connid = 1
fldate = |20200101|
price = 110
) ).

_check( 'Expect correctly migrated data' ).

ENDMETHOD.

METHOD _prepare_test_data.

" Prepare test data for migration
DELETE FROM sflight WHERE carrid = 'TST'.
COMMIT WORK AND WAIT.

INSERT sflight FROM TABLE test_data.
COMMIT WORK AND WAIT.

ENDMETHOD.


METHOD _migrate.

" Migrate
f_cut->migrate( carrid_range = VALUE #( ( sign = |I| option = |EQ| low = 'TST' ) ) ).
COMMIT WORK AND WAIT.

ENDMETHOD.


METHOD _check.

" Check

DATA: actuals TYPE STANDARD TABLE OF sflight WITH DEFAULT KEY.

SELECT * FROM sflight INTO TABLE @actuals WHERE carrid = 'TST'.
SORT actuals.
SORT expecteds.
cl_abap_unit_assert=>assert_equals( msg = message exp = expecteds act = actuals ).

ENDMETHOD.

ENDCLASS.

A third test to check that the date is regarded is still needed... I add more tests when I see the need to test a new aspect. I do not try to test all.

The test uses the database so the execution time is 10 to 20 ms per test. This is acceptable for me. There is no mock logic used, this saves time and makes the test more complete.

Review report as it is now


The report has also a documentation:


The transport order is documentated:


There are only very few inline comments. Generally, I try to make inline comments only to explain topics which are not clear from reading the code alone. I am often criticized that I do not comment enough, you may therefore add more comments. But do not forget to always change inline comments when the code is changed!

The naming of local variables follows mostly the proposals of the ABAP Programming Guideline from 2009. Tables are marked with a plural s and the end (therefore acutals for the table of actual data). Different is that I also omitted prefixes for parameters and attributes. I add such parameters when they are helpful. But here they are not needed. The benefit is that the code is easier to read. This is identical to what I do in my work currently.

Make test independent from the database


In some cases I prefer not to read and write to the database anymore. Often I start with tests which use the database and change this during my work. Often the first test depends on data which may change later. I do both things depending on what fits best for me:

  1. Make the test dependent on database entries in the development system. When these entries change the test will fail. To make it easier to fix this I either add comments which explain what is expected on the database or add check logic that checks the precondition.

  2. Break the dependency to the database.


Option 1 is typically not recommended. But test data may be quite stable in a development system. And the effort to implement option 2 can be higher than the additional effort needed to fix tests that break every few years.

Tested is a simple migration report. So I see no need for a very sophisticated test. I will therefore replace the statements to read and write to the database.

The database table will be mocked with the static attribute sflight_mock of class test_container. This is a class for testing, which is defined in the folder "Local Types".



CLASS test_container DEFINITION FOR TESTING.
PUBLIC SECTION.
CLASS-DATA: sflight_mock TYPE STANDARD TABLE OF sflight WITH DEFAULT KEY.
ENDCLASS.

the for testing is needed to be able to reference this class from the tests.

The coding of the class is slightly adapted (Declare sfs before the select and add test seam declarations):
CLASS zunitdemo_ex1_cl_migrator_4 DEFINITION
PUBLIC
CREATE PUBLIC .

PUBLIC SECTION.

TYPES:
ty_carrid TYPE RANGE OF sflight-carrid .

METHODS migrate
IMPORTING
!carrid_range TYPE ty_carrid .
PROTECTED SECTION.
PRIVATE SECTION.
ENDCLASS.

CLASS zunitdemo_ex1_cl_migrator_4 IMPLEMENTATION.


METHOD migrate.

DATA: modified TYPE sflight,
modifieds TYPE STANDARD TABLE OF sflight WITH DEFAULT KEY.

DATA: sfs TYPE STANDARD TABLE OF sflight WITH DEFAULT KEY.

TEST-SEAM sflight_select.
SELECT * FROM sflight INTO TABLE @sfs WHERE carrid IN @carrid_range.
END-TEST-SEAM.

LOOP AT sfs INTO DATA(sf).
IF sf-fldate EQ '20200101'.
IF sf-price IS NOT INITIAL.
modified = sf.
IF sf-price < 100.
ADD 1 TO modified-price.
ELSEIF sf-price < 1000.
ADD 10 TO modified-price.
ELSE.
ADD 20 TO modified-price.
ENDIF.
modifieds = VALUE #( BASE modifieds ( modified ) ).
ENDIF.
ENDIF.

ENDLOOP.

IF modifieds IS NOT INITIAL.
TEST-SEAM sflight_modify.
MODIFY sflight FROM TABLE modifieds.
END-TEST-SEAM.
ENDIF.

ENDMETHOD.
ENDCLASS.

The test is now writing and reading from the static table attribute test_container=>sflight_mock. The select and modify statements are replaced in test injection by a coding which is equivalent.
CLASS ltcl_test DEFINITION DEFERRED.
CLASS zunitdemo_ex1_cl_migrator_4 DEFINITION LOCAL FRIENDS ltcl_test.
CLASS ltcl_test DEFINITION FINAL FOR TESTING
DURATION SHORT
RISK LEVEL HARMLESS.

PRIVATE SECTION.
DATA f_cut TYPE REF TO zunitdemo_ex1_cl_migrator_4.

DATA: test_data TYPE STANDARD TABLE OF sflight WITH DEFAULT KEY,
expecteds TYPE STANDARD TABLE OF sflight WITH DEFAULT KEY.
METHODS:
setup,
_migrate,
_check
IMPORTING
message TYPE string,
simple FOR TESTING RAISING cx_static_check,
simple2 FOR TESTING RAISING cx_static_check.
ENDCLASS.


CLASS ltcl_test IMPLEMENTATION.

METHOD setup.
f_cut = NEW #( ).
TEST-INJECTION sflight_select.
" Do test seams correct. Otherwise tests may not work as expected
" This coding is equivalent to a select statement
DATA: sft TYPE sflight.
LOOP AT test_container=>sflight_mock INTO sft WHERE carrid IN carrid_range.
sfs = VALUE #( BASE sfs ( sft ) ).
ENDLOOP.
END-TEST-INJECTION.
TEST-INJECTION sflight_modify.
" Do test seams correct. Otherwise tests may not work as expected
DATA: m TYPE sflight.
FIELD-SYMBOLS: <f> TYPE sflight.
LOOP AT modifieds INTO m.
" This simulates a modify. All three key fields are checked.
" This coding is equivalent to a modify statement on a database table
READ TABLE test_container=>sflight_mock ASSIGNING <f> WITH KEY carrid = m-carrid connid = m-connid fldate = m-fldate.
IF sy-subrc EQ 0.
<f> = m.
ENDIF.
ENDLOOP.

END-TEST-INJECTION.
ENDMETHOD.

METHOD simple.

test_container=>sflight_mock = VALUE #( (
carrid = |TST|
connid = 1
fldate = |20200101|
price = 1
) ).

_migrate( ).

expecteds = VALUE #( (
carrid = |TST|
connid = 1
fldate = |20200101|
price = 2
) ).

_check( 'Expect correctly migrated data' ).

ENDMETHOD.

METHOD simple2.

test_container=>sflight_mock = VALUE #( (
carrid = |TST|
connid = 1
fldate = |20200101|
price = 100
) ).

_migrate( ).

expecteds = VALUE #( (
carrid = |TST|
connid = 1
fldate = |20200101|
price = 110
) ).

_check( 'Expect correctly migrated data' ).

ENDMETHOD.

METHOD _migrate.

" Migrate
f_cut->migrate( carrid_range = VALUE #( ( sign = |I| option = |EQ| low = 'TST' ) ) ).
COMMIT WORK AND WAIT.

ENDMETHOD.


METHOD _check.

SORT test_container=>sflight_mock.
SORT expecteds.
cl_abap_unit_assert=>assert_equals( msg = message exp = expecteds act = test_container=>sflight_mock ).

ENDMETHOD.

ENDCLASS.

This test has a smaller scope than before as the SELECT and MODIFY statements are not tested.

Please follow my blog with a personal guideline for test seams https://blogs.sap.com/2018/06/08/abap-test-seam-for-unit-test-with-external-dependencies-personal-gu.... Test seams are a powerful way to break dependencies in an easy way when they are done correctly. The main benefit is that no big changes are required to the tested code.

It is also possible to implement a separate class to read and write to table SFLIGHT. And to replace accesses to this class with a mock in the unit test. This requires more effort and programming skill. For a simple report like in this example I see no benefit in doing this.

Visualize test coding


I extract the test coding I made with SAP2Moose and visualize with Moose2Model. I do this to help me remembering and exploring what I programmed.


I will be happy to read your comments below!

Thanks, Rainer

 
4 Comments