A program without bugs would be an absurdity, a nonesuch. If there were a program without any bugs then the world would cease to exist.
Geoffrey James, The Zen of Programming
A Small Challenge
Testing is complicated and I’m an engineer. […] So when engineers build something or answer a question about how to build things, we have to be sure we’re right. We have to be sure nothing is left out. It takes a lot of work.
Robert V. Binder to his son, Testing Object-Oriented Systems (TOOS)
How many different test cases can you produce that make you feel you have adequately tested a program that
- Reads three integer values and
- Interprets those as representing the lengths of the sides of a triangle.
- Prints a message that states whether the triangle is scalene, isosceles, or equilateral.
This exercise in Binder’s TOOS was adapted from Myers’ The Art of Software Testing. If you are typical, you have done poorly on this test.
What is Unit Testing?
Unit tests are a methodology for software development. ABAP unit tests are a mature framework in the developer toolbox.
- On one side a matter of focus: user acceptance testing validates the behavior that could be implemented in multiple systems. Integration testing can be seen as validation of the interaction of separate systems, e.g. a server (master data or SQL database) and a client system. When focusing on a single system, we validate the behavior seen by the end user.
- This usually requires process knowledge and should ideally not be performed by the original developer to reduce bias. The tester might understand system architecture, but details of the software implementation are typically seen as a black box.
- Integration or user acceptance tests could also be created using a Unit Testing framework, but other tools are better suited (e.g. eCATT).
- Unit testing on the other hand is white or gray box testing, where a good understanding of the system design is used to test modules in isolation. This is typically the developer using a unit testing framework.
- We concentrate on the Unit Tests that can only be created by developers and so must not be visible outside of the development phase in the Software Life Cycle management.
The Continuous Integration approach has moved testing to central prominence, but this is not the scope of this discussion.
So unit testing is something developers do. Or do not.
Adoption in ABAP
The ABAP Unit framework can be seen as alien in the ABAP workbench. It came relatively late attached to the late adoption of Object Oriented technologies with the marketing explanation of helping how to make more robust code via the testing of program routines in isolation. This did not yield a positive resonance in an environment where understanding the integration with the database is required even for smoke tests.
Smoke test = start the application and check if it break down in flames.
A special requirement in ABAP development is to understanding business rules and database tables well enough to create meaningful tests. SAP selling points are business processes, not development language.
On the other hand, diversification of the tool chain is forcing an understanding and sometimes adoption of agile processes. The pragmatic ABAP developer will allow to be positively surprised by any new technology that pays for itself. I assume that most developer have given it a try and found it was not yet worth the trouble in this context.
In the rest of this blog, I will try to manage expectation by presenting my opinion of the thought process required to invest in a test suite. I will discuss values and limits of unit testing in the ABAP context and present some refactoring use cases that are helped by ABAP unit.
All tests can be unit tests: no. Not all. Unit Tests should be simple. Complex processes lead to complex tests. If you spend more time of fixing the test that on the fixing code, then test are not useful
Writing unit tests is hard: no. Design is hard. Chance is, if your test is hard to write, then you are trying to test too much at a time because your design is not clean. Trying to write simpler unit tests force you to separate dependencies before writing the test. This is already a big change in your software.
You should create test firsts then create the production code. While it is possible, this is hard. I have not seen it done properly in the ABAP context. This approach is called Growing Software guided by tests (cf. reference).
You should have 100% code coverage. Why? Having tests in place do not guarantee there are no errors. They help identify areas of code with missing test. That is about the only quality measure.
So Why Are You Unit Testing?
I am testing to verify that my code perform exactly as I expect it. I do not expect to be surprised by the code or test behavior. I write test as a specification: to confirm that this module does what is expected when called with the defined parameters. In this optic, unit tests are an insurance policy for later, when I will be changing the code at refactoring time. The plan is not to find errors now. If I find an error, I will add a unit test to confirm that the behavior is as expected after the fix.
This means that my strategy is to test after the code is implemented.
There is a problem with test first, i.e. creating tests before the code is implemented: it requires a lot of discipline. It is a lot harder than you might think to just implement the minimum needed to make the test pass before doing the next step. And it might be impossible in an integrated environment with a large existing code base that has to be amended to satisfy a new required behavior.
I do not expect every one to do it, but I surely think everyone can test last.
The problem with test last is that beginner will spend a lot of effort trying to write integration tests, no unit test. Integration test are large and brittle, they break easily and must be corrected with small changes in the code base. They are difficult to support and are often abandoned because there is no benefit in constantly working on a test suite instead of fixing errors and refactoring the production code.
No Nonsense Unit Testing
Tests are validation to enhance our confidence in the behavior of the system. After running the tests and all test pass, we feel better. Actually, the benefit of the tests is a better feeling. We get back the Joy of Coding. Complex tests do nothing to enhance our confidence.
It is better not to write tests than to write bad unit tests, so Unit Tests should be
- simple to write
- Complex unit tests slow you down, they cost time to fix and at the end of the day, you won’t trust them because they fail often.
- simple to read
- The next developer will probably delete failing tests she won’t understand. If you stop trying to fix a failing test because you don’t understand it, you can delete it.
- easy to change
- Test will often fail because they are wrong. They can be wrong because of an error in the fixture (test data) or in the assumption you had while writing the test.
- If you want the test to survive the first failure, it should be easy to change.
- That is one reason to advocate test with a single assert.
- fast to execute.
- Testing will be repeated often, practically after each change. This only makes sense if the tests are automated and fast.
So the main benefit of unit test I am painting here is in the automation of a lot of small even tiny tests that confirm the system behavior.
The only reason to write bad unit tests is to learn how to write good unit test. If you know you are going to be working on this logic next year, save yourself some effort and invest in the code now by writing some unit test.
So Do They Help At All?
After a while of system stability, the unit test do not help in daily work, but the routine of always running them help. Why, they are cheap to run… And they help identify code parts where more testing is needed. Check the coverage indicator and decide…
Those would typically be code parts where automatic testing is difficult. So we remember to test those parts manually after each change. If not, we then create new automated tests for a better coverage/understanding.
I tried to down port my code and after an all-night refactoring session the unit tests failed, catching two issues:
- RETURNING parameters cannot be used together with CHANGING or EXPORTING parameters on lower Netweaver releases. When I changed the RETURNING parameter (always passed by value) to CHANGING in one case, the system behavior changed: the variable passed as IMPORTING parameter with reference was initialized too early. To avoid this side-effect I changed the parameter to IMPORTING with VALUE( ) and the error was fixed.
- In the second down porting case, I was trying to replace the CONV i( ) statement with a helper variable, but I moved the assignment of the helper variable outside of a guard clause (TRY CATCH) and I did not notice it until the unit test failed.
What is this worth? The code quality is not higher, but I have considerably reduced the testing time. My confidence is high. And once I trust the tests, I venture in deeper refactorings to make the code simpler without slowing down too much.
That is actually the real risk: without automatic testing, the need for manual test makes testing for large software very expensive. But you know this already?
My parser had failed to check for the closing parenthesis in an expression and I could not detect this via unit test because I did not write a test for this case. It was later as I was reusing the parser code that I found the error while trying to test for malformed expression.
Tests helps to validate our code against our specification, they do nothing to ensure the specification is valid.
When is it easy to do?
It is easy when you have a good specification and/or a good separation of concern. Conversely it is hard to do unit testing when you design is changing too much, e.g. at the beginning of your project. You need more discipline to create tests in this phase.
On the other hand, creating tests is a very useful introduction to a new code base. You verify your assumptions without risk for the production code.
- Plant UML logic – more than 100 unit tests
- ABAP LISP Interpreter – 350+ unit tests
- The tests are small, easy to write. If a test is difficult to write, it is abandoned.
Step by Step Refactoring
Working with Tests changed my Workflow
Once enough unit tests are implemented, you find it easy to move to a test first approach. I found I wanted to stop doing manual testing. I started writing tests before development of the production code to remember which features are on my log.
But even with test last ABAP unit testing requires discipline:
- One issue at a time. one tiny problem
- If you find any other problem while checking, create another tiny test for it
- Write down the big changes for later, they are easier/done with more confidence when the other parts of the system are covered with tests, so
- first create a coverage with your actual understanding of the system,
- then refactor mercilessly
It is all about ROI, I do not try to create automated test for SQL (that would be integration test) because they are no good tools support for it (maybe in upcoming releases) and after I have done a deep manual testing, I assume the code won’t change often. If it does, and it is valuable, then I should create some kind of test…
Simpler Guard Clauses
METHOD restore_saved_default_task. IF ms_save_default_task IS INITIAL. RETURN. ENDIF.
METHOD restore_saved_default_task. CHECK ms_save_default_task IS NOT INITIAL.
Create Guard Clauses
ls_default_task = get_default_task( ). IF ls_default_task IS NOT INITIAL. clear_default_task( ls_default_task ). ENDIF.
clear_default_task( get_default_task( ) ).
in Method clear_default_task( ) add a new guard clause:
* Importing Paramter IS_DEFAULT_TASK METHOD clear_default_task. CHECK is_default_task IS NOT INITIAL.
I have to do a Where-Used check on method clear_default_task( ) to confirm the design decision does not conflict with existing code.
Use CHECK in LOOP
* build edges LOOP AT lt_nodes ASSIGNING <ls_node>. lt_findstrings = VALUE #( ( <ls_node>-obj_name ) ). lv_find_obj_cls = <ls_node>-obj_type. CALL FUNCTION 'RS_EU_CROSSREF' EXPORTING i_find_obj_cls = lv_find_obj_cls TABLES i_findstrings = lt_findstrings o_founds = lt_founds i_scope_object_cls = lt_scope EXCEPTIONS OTHERS = 9. IF sy-subrc <> 0. CONTINUE. ENDIF.
LOOP AT lt_nodes ASSIGNING <ls_node>. lt_findstrings = VALUE #( ( <ls_node>-obj_name ) ). lv_find_obj_cls = <ls_node>-obj_type. CALL FUNCTION 'RS_EU_CROSSREF' EXPORTING i_find_obj_cls = lv_find_obj_cls TABLES i_findstrings = lt_findstrings o_founds = lt_founds i_scope_object_cls = lt_scope EXCEPTIONS OTHERS = 9. CHECK sy-subrc EQ 0.
EXPORTING Parameters for Inline Declaration
DATA: lv_order TYPE trkorr, lv_task TYPE trkorr. call_transport_order_popup( IMPORTING ev_order = lv_order ev_task = lv_task ).
call_transport_order_popup( IMPORTING ev_order = DATA(lv_order) ev_task = DATA(lv_task) ).
Replace INITIAL LINE by VALUE Operator
APPEND INITIAL LINE TO lt_nodes ASSIGNING <ls_node>. <ls_node>-obj_name = <tadir_ddls>-obj_name. <ls_node>-obj_type = <tadir_ddls>-object.
APPEND VALUE #( ) TO lt_nodes ASSIGNING <ls_node>. <ls_node>-obj_name = <tadir_ddls>-obj_name. <ls_node>-obj_type = <tadir_ddls>-object.
APPEND VALUE #( obj_name = <tadir_ddls>-obj_name obj_type = <tadir_ddls>-object ) TO lt_nodes ASSIGNING <ls_node>.
Last step: if field-symbol <ls_node> is not used anymore:
APPEND VALUE #( obj_name = <tadir_ddls>-obj_name obj_type = <tadir_ddls>-object ) TO lt_nodes.
Expression Oriented Code
CLEAR: lt_dependency. lt_dependency = get_ddls_dependencies( <tadir_ddls>-obj_name ). LOOP AT lt_dependency ASSIGNING <dependency> WHERE deptyp = 'DDLS' AND refname = <tadir_ddls>-obj_name.
LOOP AT get_ddls_dependencies( <tadir_ddls>-obj_name ) ASSIGNING FIELD-SYMBOL(<dependency>) WHERE deptyp = 'DDLS' AND refname = <tadir_ddls>-obj_name.
READ TABLE lt_edges WITH KEY from-obj_name = <ls_node>-obj_name from-obj_type = <ls_node>-obj_type TRANSPORTING NO FIELDS. IF sy-subrc <> 0.
IF NOT line_exists( lt_edges[ from-obj_name = <ls_node>-obj_name from-obj_type = <ls_node>-obj_type ] ).
UNASSIGN <ls_approver>. ASSIGN COMPONENT lv_comp OF STRUCTURE ls_appr TO <ls_approver>. CHECK <ls_approver> IS ASSIGNED.
Is this a dynamic or table expression ASSIGN? if yes, I can re-write it
ASSIGN COMPONENT lv_comp OF STRUCTURE ls_appr TO <ls_approver>. CHECK sy-subrc EQ 0.
- Growing Object-Oriented Software, Guided by Tests, by Steve Freeman and Nat Pryce
- The Art of Unit Testing by Roy Osherove