
042: Modern, solid and testable ABAP Code (Part 3)
The digital version of the betterCode presentation on modern and testable ABAP code. We'll look at software architecture and give tips for using ABAP Units.
Table of contents
Welcome to the third episode of "Modern, solid and testable ABAP Code". In this episode, we'll delve into the topic of ABAP Units. In the two previous episodes, we looked at the various patterns that we also need for a testable architecture, and in the last episode, we primarily examined examples from the standard.
ABAP Unit
Let's first take a look at ABAP Units and the question of why we should actually work with unit tests. On the one hand, we naturally have the option of not writing unit tests. However, this means that we have a very high manual effort to test and validate the various components every time. In a manual process, we would never test each individual component in isolation. This means that certain parts of our code would likely never, or only rarely, be tested. This creates the real risk of releasing untested code into production, which could later cause us serious problems. At the same time, code is not always changed by the same developer during the application's lifecycle. Should another colleague ever need to extend or modify our code, there is a certain reluctance, and often a real fear, of even touching this code without automated tests. Since the deeper relationships of complex components are not immediately apparent without test coverage, it is difficult to predict which dependencies might be damaged or affected by a change. The risk is high that problems will arise later in production because certain use cases simply could not be considered or automatically checked beforehand.
On the other hand, we find the use of unit tests. This means we always have reliable validation against the expected result of a method and can thus see directly during development whether our implementation works correctly or not. At the same time, sufficient coverage with unit tests on our code provides us with a robust safety net. We can use this safety net to implement later changes or refactorings. The tests give us immediate feedback on whether the existing logic continues to work as desired or whether we have inadvertently introduced errors through the adjustments. This applies both to in-house development and to work done by third parties. Another crucial advantage is the increased change frequency. Since the time-consuming manual testing effort is eliminated, we can make significantly more adjustments to the source code in the same amount of time and release code in shorter cycles. The automated tests can ultimately be executed with just one click and within seconds. The third point deals primarily with testing specific scenarios and edge cases. For example, if we receive a bug report in production, we can replicate this exact scenario in a unit test. From this point on, the error will be automatically checked with every future change. In the future, we won't need to actively think about this specific case anymore, as our test cases are permanently based on the assumption that this exact problem won't recur. Nothing is more annoying in everyday development than a bug that has already been fixed but reappears in production as a regression due to later code changes. This is precisely where unit tests demonstrate their strengths. The final aspect concerns the documentation of functionality. Unit tests serve as the best form of technical documentation for the use of code, without creating any additional manual effort for document maintenance. A third developer, looking at the test cases, immediately sees: How the code must be called. How the input parameters must be filled. How to handle the result and the return values.
Measurements
So what exactly do the tests look like? How should we structure them, and what number or test coverage is important? We should answer these questions for ourselves beforehand. Regarding the number of unit tests, it's important to ensure that we don't just write the so-called "happy path," i.e., purely positive tests, but also, of course, cover negative scenarios. This means: How does our implementation behave when the interfaces are supplied with incorrect information? Additionally, we can define thresholds to check, for example, whether certain exceptions are thrown in the event of an impending overflow. This means that the majority of the method is structurally tested. As a minimum standard, the positive and negative baseline scenarios should always be covered, since validating all conceivable extreme values naturally increases the effort required to create unit tests. Furthermore, we should critically examine the quality of the actual tests. Code coverage is not the sole criterion for high quality. Much more importantly, our tests must be absolutely repeatable and independent of external influences, so they can be executed quickly time and again and obtain reliable results. The rule here is: individual tests must not run for too long. We want immediate, fast results; after all, a unit test is not a performance analysis. Additionally, we should use automated test suites to validate side effects on other source code and calling programs. Fundamentally, a single unit test is meant to check the actual, isolated object. However, if all your RAP business objects, CDS helper classes, or processing logics have their own unit tests, you can run these tests regularly across all objects using the appropriate tools. Therefore, if we change types, interfaces, or the behavior of methods, the overall result immediately shows whether any utilizing components have unintentionally changed. This is especially helpful for the future and for the period before production deployment, allowing you to identify and fix errors early, before they even reach production.
Scenario
Let's take a closer look at a scenario. In this case, we use the generated factory with the corresponding implementation, as we built it in our previous exercise. We then implement a new component that uses the factory, that is, our actual implementation, and want to write a unit test based on this new component. This simulates exactly the behavior when we provide our colleagues with a corresponding API that they can use in their own applications, while simultaneously ensuring that our components remain testable in isolation.
Let's take a closer look at the classes. We again use the same interface that we defined earlier. We have an implementing class that is instantiated via a factory and creates a timestamp accordingly. Now we create the actual implementation of our class. This class has a public method that checks whether we have reached the "right moment." If this is the case, we return abap_true, otherwise abap_false. In the actual implementation, we use the factory to generate our API. We retrieve the current timestamp via the Timestamp object to compare it. We then return the result of this validation as the method's return value. This is a fairly simple function and something we wouldn't normally write for production. Because the real problem is: the "right moment" is extremely limited in time and almost impossible to hit exactly in reality, unless we were to work with a logical time period instead of a fixed timestamp.
In the test class, we have already created the corresponding ABAP unit tests, including a positive and a negative test case: once we reached the "right moment" and once we did not. Apart from that, we have a relatively simple implementation, which we will now execute in the unit test. To do this, we use code mining in the ABAP Development Tools, click the Run button, and start the actual unit test. The test result currently shows the following: Our test case for the scenario where the moment was not reached works perfectly and is stable for testing. This is simply because we never actually hit the exact, hard-coded moment in a standard test run. But this is precisely where the problem lies: we currently cannot deterministically test the positive test case that is supposed to check when the moment is successfully reached. Since the method always queries the actual, current system timestamp internally, we have no control over the returned value in the unit test. We now want to remedy this lack of testability. To do this, we will extend our framework and the corresponding objects with a mocking capability.
Injector
To solve this problem, we use the so-called Injector pattern. We haven't presented this design pattern before, but we're now implementing it specifically for this use case. In addition to the existing factory, we're extending our architecture with a so-called injector class. This injector class allows us to manipulate the instantiation in the factory at test runtime. The goal is not to receive the actual production implementation back at the end, but rather a test double defined by us. Conversely, this means we don't have to make any messy modifications or branches in the production code to enable the test scenario for the factory. This is perfectly in line with a modern, testable software architecture in the ABAP environment. In the next step, we create a so-called test double, or in this case, a local test double, since we are implementing it locally within our test class. To do this, we create a local class `ltd_timestamp` and define it with the addition `FOR TESTING`. The `FOR TESTING` is essential here because it signals to the runtime system that this class exists and may only be used in the test context. We then implement the interface that we also use in our production object. For this, we use the addition PARTIALLY IMPLEMENTED. In ABAP, this addition ensures that we don't necessarily have to implement all methods of the interface in our local test class. Instead, we can specifically decide which methods we need for our specific test scenario. We see this immediately in the result: We can simply omit an unnecessary method and implement only the relevant `get_timestamp` method. Since we are in a controlled test scenario, we now have this method return precisely the predefined timestamp that we need to validate the successful test case. We have already created a corresponding injector for this use case, which you can also find in the package. This class is defined as `ABSTRACT FINAL` so that no instance of it can be created. It has also been declared with the `FOR TESTING` addition. This means that the injector can only be called in unit tests and is not available in production code. This effectively protects the injector from other developers mistakenly using it in a production context to inject unwanted replacement instances into our factory. The injector has a static method for injecting the test double. This method receives the test double and stores it in the factory. Conversely, this also means that we need to make a small adjustment to the factory itself. In the global factory class, we have defined the injector as a GLOBAL FRIEND. This gives the injector class the right to access the factory's private attributes and methods. In the private section of the factory, we define a static attribute of the type of the corresponding interface. In this attribute, we will later store our actual test double at runtime. Finally, we adjust the CREATE method of the factory. Here, we first check if the test double is set. If so, we return the instance of the double. Otherwise, the normal production code is executed, and the regular instance is created. To make the test double work, we call the injector before starting our unit test to prepare the instance within the factory for the test run. To do this, we call the INJECT method and pass a new instance of our local double, which we previously created as a local class. Of course, it is also possible to use the official Test Double Framework, as it can also dynamically create test doubles. In this case, however, we solved it manually for better understanding by creating our own local implementation. This makes it directly and transparently visible in the code which values are returned. With the Double Framework test, we have to make corresponding pre-configurations, such as setting the expectations and response values, which means more lines of code and requires some explanation. Therefore, the integrated framework is not part of this presentation. Now, if we run our unit tests, we get a green result for both test cases. This means we are now able to test the scenario in which the "right moment" is reached. By replacing the actual implementation via the factory at test runtime, the application accesses our own implementation and returns precisely the value we defined. The crucial point is: this is possible without burdening the production code with messy test branches. Instead, we simply use the Injector pattern as an additional, clean architectural tool for test-driven development in the ABAP environment.
Outlook
In this episode, we looked at testing with the corresponding design patterns and learned how we can selectively establish the testability of our code without much effort using an additional Injector pattern. You should have gathered by now that proper factory preparation provides an excellent foundation for ensuring easy and trouble-free testability. In the next episode, we'll look at various automation options for running unit tests fully automatically and thus receiving results as feedback from the system even faster. So, thanks for watching... and see you next time.
Further References:
YouTube - Part 3
GitHub - Examples