Skip to Content
Technical Articles

Start writing a Unit Test

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-guideline/. 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

 

5 Comments
You must be Logged on to comment or reply to a post.
  • Hello!

    I will let it go with the database access/modification in unit tests and the TEST-SEAMS this time! I gather you are going to write a blog saying why TEST-SEAMS are good and refuting the argument is people like me use against them. That (healthy debate) is a Good Thing.

    Moving onward I don’t think “this requires more programming skill” is an argument as to why not to do something. I think if something requires more programming skill than someone currently has then that is a massive incentive to try and do whatever it is in order to improve their programming skill.

    If someones programming skill is not yet up to writing a class method that wraps a SELECT statement inside a FORM routine (and I have met such ABAP programmers, far too many, some 30 years younger than me) then I would say they desperately need to get to such a point. They owe it to their employer and more importantly to themselves.

    Anyway, what I will say is this, in regard to

    Unit tests can only be done in functions and classes

    That’s not actually the case. You can have a test class in an executable program. Methods of local classes (like test methods) can have PERFORM statements. In the GIVEN method you set all the horrible global variables, in the WHEN method you call the PERFORM and in the THEN method you evaluate the changed global variables.

    I created my first ever set of unit tests in a procedural program (after the event) but that was only possible because after the initial database read (which of course I did not test) as it was 100% calculations and conditional logic (which I did test).

    I would also note that many tiny programs we are given to write are really small and are “one off” to solve a problem that is happening just now, once this is fixed the program will never be used again. That is what the specification says, fine, why bother doing anything complicated? Even any sort of unit test seems over the top.

    The problem is that ten years later the one-off twenty line program has mutated into a ten thousand line business critical monster. Earlier this year I spent the best part of a month doing major surgery and enhancing out of all recognition a small “one off” program I had written in 2014 for (you guessed it) a one off data migration, that program would never be needed again I was told. Six years on it is somehow business critical and we cannot do our annual price rise without it (??? That was NEVER what it was for!)

    However in 2014 I did not believe that statement (its a one-off) so I had written that “one-off” program with all OO methods, and test doubles and unit tests so HA HA! HA HA HA! I was laughing.

    Cheersy Cheers

    Paul

  • Great post, I really like your post very much you provided good information about how to do unit test . I’m also interested into to learn coding so now I just collecting some information as much as I can collect. Actually I’m in school and I’m not good in writing, for that problem my friend suggested me this edubirdie and when I read some edubirdie review I got almost good reviews, Still I’m using it whenever I need some help. Same like that I will save your post maybe it will help me in future.