ABAP in Practice - Test Driven Development
How does TDD actually work in practice and are there simple examples for learning in ABAP? In this exercise we will look at the practical part.
Table of contents
In this article we will look at a practical and playful example of implementing test-driven development and how you could use it in your work.
Introduction
Before the actual development, we first want to implement our unit tests and see that they are running on "red". We will then start implementing the actual logic and bring the unit tests into the "green" state. With the tests, we first want to cover all use cases and requirements in order to then develop the solution. This development methodology is particularly difficult to implement because we as developers have to think about what we need beforehand. This also allows us to achieve three goals:
- The unit tests work (red -> green)
- Our code is written in a testable manner
- Unit tests are available
If you want to learn more about TDD, you can find a good article on Wikipedia.
Preparation
In this task, we want to create a converter for Roman numerals to convert the numbers in both directions. To do this, we need a class that has two methods for the conversion. We can then implement it based on this. In this example, we reuse an Exception class to catch special cases in the methods.
CLASS zcl_bs_demo_roman_numbers DEFINITION
PUBLIC FINAL
CREATE PUBLIC.
PUBLIC SECTION.
METHODS convert_roman_to_arabic
IMPORTING id_roman TYPE string
RETURNING VALUE(rd_result) TYPE i
RAISING cx_abap_not_in_allowlist.
METHODS convert_arabic_to_roman
IMPORTING id_arabic TYPE i
RETURNING VALUE(rd_result) TYPE string
RAISING cx_abap_not_in_allowlist.
PRIVATE SECTION.
ENDCLASS.
CLASS zcl_bs_demo_roman_numbers IMPLEMENTATION.
METHOD convert_arabic_to_roman.
ENDMETHOD.
METHOD convert_roman_to_arabic.
ENDMETHOD.
ENDCLASS.
Task
The task now is to implement the class using Test Driven Development. In the first step, we want to implement the unit tests in order to then develop the logic piece by piece and get to know the advantages of TDD. The following rules apply for the conversion:
- There are the following Roman numerals...
- I - 1
- V - 5
- X - 10
- L - 50
- C - 100
- D - 500
- M - 1000
- A maximum of three identical characters in a row, with V/L/D standing alone.
- Subtraction rule - If a smaller number is in front of a larger number, the smaller is subtracted from the larger ones are subtracted.
- Range - The smallest Roman number is 1 and the largest is 3999 without breaking the rules.
Hint: In the next section we will go into the solution, if you want to do the task on your own, you should pause here.
Solution
In this section we go through the solution piece by piece and implement the various Components.
Unit Tests
So let's start with our unit tests in the first step. To do this, we implement a first test class that deals with converting Roman numbers into Arabic numbers. For the other method, we would create a second test class accordingly.
CLASS ltc_roman_to_arabic DEFINITION FINAL
FOR TESTING RISK LEVEL HARMLESS DURATION SHORT.
PRIVATE SECTION.
DATA mo_cut TYPE REF TO zcl_bs_demo_roman_numbers.
METHODS setup.
METHODS convert_i FOR TESTING.
ENDCLASS.
CLASS ltc_roman_to_arabic IMPLEMENTATION.
METHOD setup.
mo_cut = NEW #( ).
ENDMETHOD.
METHOD convert_i.
DATA(ld_result) = mo_cut->convert_roman_to_arabic( `I` ).
cl_abap_unit_assert=>assert_equals( exp = 1
act = ld_result ).
ENDMETHOD.
ENDCLASS.
Using the setup method, we can always create a new and clean instance of MO_CUT (Class/Code under Test) and do not have to implement the code in every test method. Accordingly, we would now test various combinations and special cases. We should also catch the error cases and pass numbers that are too large, or not pass them at all.
CLASS ltc_roman_to_arabic DEFINITION FINAL
FOR TESTING RISK LEVEL HARMLESS DURATION SHORT.
PRIVATE SECTION.
DATA mo_cut TYPE REF TO zcl_bs_demo_roman_numbers.
METHODS setup.
METHODS convert_i FOR TESTING RAISING cx_static_check.
METHODS convert_iii FOR TESTING RAISING cx_static_check.
METHODS convert_iv FOR TESTING RAISING cx_static_check.
METHODS convert_v FOR TESTING RAISING cx_static_check.
METHODS convert_ix FOR TESTING RAISING cx_static_check.
METHODS convert_lxviii FOR TESTING RAISING cx_static_check.
METHODS convert_cccxlix FOR TESTING RAISING cx_static_check.
METHODS convert_mccxxxiv FOR TESTING RAISING cx_static_check.
METHODS convert_mmmcmxcix FOR TESTING RAISING cx_static_check.
METHODS convert_unknown FOR TESTING RAISING cx_static_check.
METHODS convert_empty FOR TESTING RAISING cx_static_check.
ENDCLASS.
CLASS ltc_roman_to_arabic IMPLEMENTATION.
METHOD setup.
mo_cut = NEW #( ).
ENDMETHOD.
METHOD convert_i.
DATA(ld_result) = mo_cut->convert_roman_to_arabic( `I` ).
cl_abap_unit_assert=>assert_equals( exp = 1
act = ld_result ).
ENDMETHOD.
METHOD convert_cccxlix.
DATA(ld_result) = mo_cut->convert_roman_to_arabic( `CCCXLIX` ).
cl_abap_unit_assert=>assert_equals( exp = 349
act = ld_result ).
ENDMETHOD.
METHOD convert_empty.
TRY.
mo_cut->convert_roman_to_arabic( `` ).
cl_abap_unit_assert=>fail( 'Error should occure' ).
CATCH cx_abap_not_in_allowlist.
ENDTRY.
ENDMETHOD.
METHOD convert_iii.
DATA(ld_result) = mo_cut->convert_roman_to_arabic( `III` ).
cl_abap_unit_assert=>assert_equals( exp = 3
act = ld_result ).
ENDMETHOD.
METHOD convert_iv.
DATA(ld_result) = mo_cut->convert_roman_to_arabic( `IV` ).
cl_abap_unit_assert=>assert_equals( exp = 4
act = ld_result ).
ENDMETHOD.
METHOD convert_ix.
DATA(ld_result) = mo_cut->convert_roman_to_arabic( `IX` ).
cl_abap_unit_assert=>assert_equals( exp = 9
act = ld_result ).
ENDMETHOD.
METHOD convert_lxviii.
DATA(ld_result) = mo_cut->convert_roman_to_arabic( `LXVIII` ).
cl_abap_unit_assert=>assert_equals( exp = 68
act = ld_result ).
ENDMETHOD.
METHOD convert_mccxxxiv.
DATA(ld_result) = mo_cut->convert_roman_to_arabic( `MCCXXXIV` ).
cl_abap_unit_assert=>assert_equals( exp = 1234
act = ld_result ).
ENDMETHOD.
METHOD convert_mmmcmxcix.
DATA(ld_result) = mo_cut->convert_roman_to_arabic( `MMMCMXCIX` ).
cl_abap_unit_assert=>assert_equals( exp = 3999
act = ld_result ).
ENDMETHOD.
METHOD convert_unknown.
TRY.
mo_cut->convert_roman_to_arabic( `IIP` ).
cl_abap_unit_assert=>fail( 'Error should occure' ).
CATCH cx_abap_not_in_allowlist.
ENDTRY.
ENDMETHOD.
METHOD convert_v.
DATA(ld_result) = mo_cut->convert_roman_to_arabic( `V` ).
cl_abap_unit_assert=>assert_equals( exp = 5
act = ld_result ).
ENDMETHOD.
ENDCLASS.
CLASS ltc_arabic_to_roman DEFINITION FINAL
FOR TESTING RISK LEVEL HARMLESS DURATION SHORT.
PRIVATE SECTION.
DATA mo_cut TYPE REF TO zcl_bs_demo_roman_numbers.
METHODS setup.
METHODS convert_0 FOR TESTING RAISING cx_static_check.
METHODS convert_4000 FOR TESTING RAISING cx_static_check.
METHODS convert_1 FOR TESTING RAISING cx_static_check.
METHODS convert_3 FOR TESTING RAISING cx_static_check.
METHODS convert_4 FOR TESTING RAISING cx_static_check.
METHODS convert_9 FOR TESTING RAISING cx_static_check.
METHODS convert_15 FOR TESTING RAISING cx_static_check.
METHODS convert_587 FOR TESTING RAISING cx_static_check.
METHODS convert_611 FOR TESTING RAISING cx_static_check.
METHODS convert_789 FOR TESTING RAISING cx_static_check.
METHODS convert_999 FOR TESTING RAISING cx_static_check.
METHODS convert_1245 FOR TESTING RAISING cx_static_check.
METHODS convert_3299 FOR TESTING RAISING cx_static_check.
METHODS convert_3999 FOR TESTING RAISING cx_static_check.
ENDCLASS.
CLASS ltc_arabic_to_roman IMPLEMENTATION.
METHOD setup.
mo_cut = NEW #( ).
ENDMETHOD.
METHOD convert_0.
TRY.
mo_cut->convert_arabic_to_roman( 0 ).
cl_abap_unit_assert=>fail( 'Error should occure' ).
CATCH cx_abap_not_in_allowlist.
ENDTRY.
ENDMETHOD.
METHOD convert_1.
DATA(ld_result) = mo_cut->convert_arabic_to_roman( 1 ).
cl_abap_unit_assert=>assert_equals( exp = `I`
act = ld_result ).
ENDMETHOD.
METHOD convert_1245.
DATA(ld_result) = mo_cut->convert_arabic_to_roman( 1245 ).
cl_abap_unit_assert=>assert_equals( exp = `MCCXLV`
act = ld_result ).
ENDMETHOD.
METHOD convert_15.
DATA(ld_result) = mo_cut->convert_arabic_to_roman( 15 ).
cl_abap_unit_assert=>assert_equals( exp = `XV`
act = ld_result ).
ENDMETHOD.
METHOD convert_3.
DATA(ld_result) = mo_cut->convert_arabic_to_roman( 3 ).
cl_abap_unit_assert=>assert_equals( exp = `III`
act = ld_result ).
ENDMETHOD.
METHOD convert_3299.
DATA(ld_result) = mo_cut->convert_arabic_to_roman( 3299 ).
cl_abap_unit_assert=>assert_equals( exp = `MMMCCXCIX`
act = ld_result ).
ENDMETHOD.
METHOD convert_3999.
DATA(ld_result) = mo_cut->convert_arabic_to_roman( 3999 ).
cl_abap_unit_assert=>assert_equals( exp = `MMMCMXCIX`
act = ld_result ).
ENDMETHOD.
METHOD convert_4.
DATA(ld_result) = mo_cut->convert_arabic_to_roman( 4 ).
cl_abap_unit_assert=>assert_equals( exp = `IV`
act = ld_result ).
ENDMETHOD.
METHOD convert_4000.
TRY.
mo_cut->convert_arabic_to_roman( 4000 ).
cl_abap_unit_assert=>fail( 'Error should occure' ).
CATCH cx_abap_not_in_allowlist.
ENDTRY.
ENDMETHOD.
METHOD convert_587.
DATA(ld_result) = mo_cut->convert_arabic_to_roman( 587 ).
cl_abap_unit_assert=>assert_equals( exp = `DLXXXVII`
act = ld_result ).
ENDMETHOD.
METHOD convert_611.
DATA(ld_result) = mo_cut->convert_arabic_to_roman( 611 ).
cl_abap_unit_assert=>assert_equals( exp = `DCXI`
act = ld_result ).
ENDMETHOD.
METHOD convert_789.
DATA(ld_result) = mo_cut->convert_arabic_to_roman( 789 ).
cl_abap_unit_assert=>assert_equals( exp = `DCCLXXXIX`
act = ld_result ).
ENDMETHOD.
METHOD convert_9.
DATA(ld_result) = mo_cut->convert_arabic_to_roman( 9 ).
cl_abap_unit_assert=>assert_equals( exp = `IX`
act = ld_result ).
ENDMETHOD.
METHOD convert_999.
DATA(ld_result) = mo_cut->convert_arabic_to_roman( 999 ).
cl_abap_unit_assert=>assert_equals( exp = `CMXCIX`
act = ld_result ).
ENDMETHOD.
ENDCLASS.
Let's now run the test class. All test cases should run on red, as we have not yet stored any logic. As expected, we now have a lot of unit tests that are initially running on red.
Mapping
In the first step, we need to map the numbers to the characters and back. To do this, we first create an internal table, which we then fill. For direct access, we create secondary keys, so that we have fast single record access, and leave the table as the default so that we can later do simpler processing using a LOOP.
TYPES: BEGIN OF ts_mapping,
roman TYPE string,
arabic TYPE i,
END OF ts_mapping.
TYPES tt_mapping TYPE STANDARD TABLE OF ts_mapping WITH EMPTY KEY
WITH UNIQUE SORTED KEY by_roman COMPONENTS roman
WITH UNIQUE SORTED KEY by_arabic COMPONENTS arabic.
To ensure that the data is filled at the start, we implement the constructor in the class and fill it with the data that we store as a member attribute in the class.
mt_mapping = VALUE #( ( roman = `I` arabic = 1 )
( roman = `V` arabic = 5 )
( roman = `X` arabic = 10 )
( roman = `L` arabic = 50 )
( roman = `C` arabic = 100 )
( roman = `D` arabic = 500 )
( roman = `M` arabic = 1000 ) ).
You can also easily create the constructor using CTRL + 1 on the class name. Here you will find suggestions for common implementations.
Roman to Arabic
Let's start with the easiest use case, converting Roman numerals to Arabic. Let's start by processing the numbers. To do this, we go through the string character by character and read the current value, which we add to our result. If we cannot find the character because it does not exist, we trigger an exception.
DO strlen( id_roman ) TIMES.
DATA(ld_actual_roman) = substring( val = id_roman
off = sy-index - 1
len = 1 ).
TRY.
DATA(ld_actual_value) = mt_mapping[ KEY by_roman COMPONENTS roman = ld_actual_roman ]-arabic.
CATCH cx_sy_itab_line_not_found.
RAISE EXCEPTION NEW cx_abap_not_in_allowlist( ).
ENDTRY.
rd_result += ld_actual_value.
ENDDO.
Now we can run our unit tests to check whether the logic already works. As you can see, we are already covering a special case and the direct characters also work.
There seems to be a problem with the subtraction rule here, as we are not taking this into account. To do this, we save the last value of the determination and check whether the last value was greater than or equal to the current value. Otherwise, we have to subtract the last value again and add the current value minus the last value. The logic for this would look like this.
IF ld_last_value >= ld_actual_value.
rd_result += ld_actual_value.
ELSE.
rd_result = rd_result - ld_last_value + ld_actual_value - ld_last_value.
ENDIF.
ld_last_value = ld_actual_value.
If we now run our unit tests again, we are almost there and only have to catch a special case when an empty string is passed.
To do this, we can implement a simple check at the beginning of the method and then exit the method again via an exception.
IF id_roman IS INITIAL.
RAISE EXCEPTION NEW cx_abap_not_in_allowlist( ).
ENDIF.
Finally, we run the unit test again and our method works so far.
Arabic to Roman
Now it's time to implement the second method to convert an Arabic number to a Roman number. In this case, let's start with a special case and compare the numbers that are outside the rule range.
IF id_arabic < 1 OR id_arabic > 3999.
RAISE EXCEPTION NEW cx_abap_not_in_allowlist( ).
ENDIF.
If we run the unit tests, we get the first feedback on these cases. This means that the special cases are already covered.
In the next step, we generate the Roman numerals, starting with the largest number. To do this, we use step -1 to go backwards through the internal table and start with the largest number. In the next step, we calculate how often the current number fits into the open value and generate part of the number. At the end of the loop we subtract the total from the outstanding amount and append the result.
DATA(ld_open) = id_arabic.
LOOP AT mt_mapping INTO DATA(ls_mapping) STEP -1.
DATA(ld_actual_value) = 0.
DATA(ld_part) = ``.
WHILE ( ld_open - ld_actual_value ) >= ls_mapping-arabic.
ld_actual_value += ls_mapping-arabic.
ld_part &&= ls_mapping-roman.
ENDWHILE.
ld_open -= ld_actual_value.
rd_result &&= ld_part.
ENDLOOP.
If we run the unit tests, we get more positive results, but the logic does not seem to be complete yet. On closer inspection, you will notice that we generate the same character more than three times (see rule) and also violate the special rule with more than one intermediate character.
To do this, we now have to expand the customizing to get more information about the characters. Which is the higher character and which is needed for the subtraction rule. Accordingly, the customizing now looks like this:
TYPES: BEGIN OF ts_mapping,
roman TYPE string,
arabic TYPE i,
low TYPE string,
high TYPE string,
single TYPE abap_boolean,
END OF ts_mapping.
mt_mapping = VALUE #( ( roman = `I` arabic = 1 low = `I` high = `V` )
( roman = `V` arabic = 5 low = `I` high = `X` single = abap_true )
( roman = `X` arabic = 10 low = `X` high = `L` )
( roman = `L` arabic = 50 low = `X` high = `C` single = abap_true )
( roman = `C` arabic = 100 low = `C` high = `D` )
( roman = `D` arabic = 500 low = `C` high = `M` single = abap_true )
( roman = `M` arabic = 1000 low = `C` high = `M` ) ).
Now we add the logic with the two special rules. To do this, we read the corresponding high and low values for the current mapping. We then compare the two rules:
- This character may only appear once
- Are 4 or more identical characters used
DATA(ls_low) = mt_mapping[ KEY by_roman COMPONENTS roman = ls_mapping-low ].
DATA(ls_high) = mt_mapping[ KEY by_roman COMPONENTS roman = ls_mapping-high ].
IF ( ls_mapping-single = abap_true AND ( ls_high-arabic - ls_low-arabic ) <= ld_open )
OR strlen( ld_part ) > 3.
ld_part = ls_low-roman && ls_high-roman.
ld_actual_value = ls_high-arabic - ls_low-arabic.
ENDIF.
If we now carry out our unit test at the end, all cases are green, the logic has been fully implemented.
Complete example
The complete implementation of the converter now looks like this, where you can find the complete test class in the upper area.
CLASS zcl_bs_demo_roman_numbers DEFINITION
PUBLIC FINAL
CREATE PUBLIC.
PUBLIC SECTION.
METHODS constructor.
METHODS convert_roman_to_arabic
IMPORTING id_roman TYPE string
RETURNING VALUE(rd_result) TYPE i
RAISING cx_abap_not_in_allowlist.
METHODS convert_arabic_to_roman
IMPORTING id_arabic TYPE i
RETURNING VALUE(rd_result) TYPE string
RAISING cx_abap_not_in_allowlist.
PRIVATE SECTION.
TYPES: BEGIN OF ts_mapping,
roman TYPE string,
arabic TYPE i,
low TYPE string,
high TYPE string,
single TYPE abap_boolean,
END OF ts_mapping.
TYPES tt_mapping TYPE STANDARD TABLE OF ts_mapping WITH EMPTY KEY
WITH UNIQUE SORTED KEY by_roman COMPONENTS roman
WITH UNIQUE SORTED KEY by_arabic COMPONENTS arabic.
DATA mt_mapping TYPE tt_mapping.
ENDCLASS.
CLASS zcl_bs_demo_roman_numbers IMPLEMENTATION.
METHOD constructor.
mt_mapping = VALUE #( ( roman = `I` arabic = 1 low = `I` high = `V` )
( roman = `V` arabic = 5 low = `I` high = `X` single = abap_true )
( roman = `X` arabic = 10 low = `X` high = `L` )
( roman = `L` arabic = 50 low = `X` high = `C` single = abap_true )
( roman = `C` arabic = 100 low = `C` high = `D` )
( roman = `D` arabic = 500 low = `C` high = `M` single = abap_true )
( roman = `M` arabic = 1000 low = `C` high = `M` ) ).
ENDMETHOD.
METHOD convert_arabic_to_roman.
IF id_arabic < 1 OR id_arabic > 3999.
RAISE EXCEPTION NEW cx_abap_not_in_allowlist( ).
ENDIF.
DATA(ld_open) = id_arabic.
LOOP AT mt_mapping INTO DATA(ls_mapping) STEP -1.
DATA(ld_actual_value) = 0.
DATA(ld_part) = ``.
WHILE ( ld_open - ld_actual_value ) >= ls_mapping-arabic.
ld_actual_value += ls_mapping-arabic.
ld_part &&= ls_mapping-roman.
ENDWHILE.
DATA(ls_low) = mt_mapping[ KEY by_roman COMPONENTS roman = ls_mapping-low ].
DATA(ls_high) = mt_mapping[ KEY by_roman COMPONENTS roman = ls_mapping-high ].
IF ( ls_mapping-single = abap_true AND ( ls_high-arabic - ls_low-arabic ) <= ld_open ) OR strlen( ld_part ) > 3.
ld_part = ls_low-roman && ls_high-roman.
ld_actual_value = ls_high-arabic - ls_low-arabic.
ENDIF.
ld_open -= ld_actual_value.
rd_result &&= ld_part.
ENDLOOP.
ENDMETHOD.
METHOD convert_roman_to_arabic.
IF id_roman IS INITIAL.
RAISE EXCEPTION NEW cx_abap_not_in_allowlist( ).
ENDIF.
DATA(ld_last_value) = 0.
DO strlen( id_roman ) TIMES.
DATA(ld_actual_roman) = substring( val = id_roman
off = sy-index - 1
len = 1 ).
TRY.
DATA(ld_actual_value) = mt_mapping[ KEY by_roman COMPONENTS roman = ld_actual_roman ]-arabic.
CATCH cx_sy_itab_line_not_found.
RAISE EXCEPTION NEW cx_abap_not_in_allowlist( ).
ENDTRY.
IF ld_last_value >= ld_actual_value.
rd_result += ld_actual_value.
ELSE.
rd_result = rd_result - ld_last_value + ld_actual_value - ld_last_value.
ENDIF.
ld_last_value = ld_actual_value.
ENDDO.
ENDMETHOD.
ENDCLASS.
Summary
The exercise is only a small practical insight into the topic of Test Driven Development (TDD) and not the complete theory.
Advantages
However, we can take a few advantages from the application straight away:
- We thought about the test cases and even the exceptions.
- We only created as much source code as was necessary for the application and the test.
- During development, the unit tests were a safety net that gave us quick feedback on the current state of development.
- Our Application is equipped with test cases immediately after completion.
- We have achieved a high level of source code coverage.
Disadvantages
In addition to the many advantages, you should also look at the disadvantages:
- A lot of discipline and time in the conceptual design before the actual development of the solution is carried out.
Conclusion
Test Driven Development requires a high degree of a certain amount of restraint on your part as a developer, so that you don't start directly with the implementation of the logic, but first make the actual body available. You think about testability and are rewarded with implemented unit tests. Do you have another solution? Then post it in the comments.