Proven methods for isolating and testing embedded code
Author: Franco Chiappori, Schindler Elevators AG
Contribution – Embedded Software Engineering Congress 2016
Continuous integration and automated testing are proven methods for improving software quality. Automated unit tests, in particular, are of great importance, as they form the basis of the test pyramid and thus guarantee the foundation of quality. Developers also appreciate the fast feedback cycles of unit tests. In practice, however, problems often begin with isolating the code to be tested. How do I isolate my C++ class or my C function from its dependencies?
The test pyramid in Fig. 1 (see PDFFigure 1 shows how an application is ideally covered by tests. The foundation is unit tests, which verify a small unit of software. If such a test fails, the problem can only lie within the tested unit. Unit tests are usually easy to create, can be executed within seconds, and any errors found can be easily located. Therefore, most of the functionality should be covered by unit tests. However, a unit test requires that the corresponding code be isolated.
Standard approach Dependency Injection
The different approaches to isolation are best illustrated with a concrete example. In this project, binary data is communicated via a UART interface. To distinguish the individual messages, the framing of the point-to-point protocol is used (see Fig. 2, PDF) used [2]. In summary:
- A flag byte (0x7E) is added at the beginning and end.
- An escape byte (0x7D) is defined.
- If a flag or escape byte appears in the frame, it is replaced with the escape byte followed by the original byte XOR 0x20.
Such framing can easily be implemented in code. Fig. 3 (see PDF) shows a possible implementation in C. This is simple and direct, but difficult to isolate for unit tests because it directly accesses the UART driver (uart_put).
A proven method to circumvent this problem is dependency injection. The framing code requires a character-oriented device to which the encoded bytes can be passed. When this dependency is imposed externally (figuratively speaking, injected), it is called dependency injection. Fig. 4 (see PDF) shows a possible implementation in C++.
This extension makes it relatively easy to isolate the code. Instead of the UART, a so-called mock object is passed to the unit test. This object implements the device interface but stores the written characters so they can be inspected by the unit test. Fig. 5 (see PDFFigure 1 shows the corresponding class diagram; a possible unit test is shown in Figure 6 (see Figure 6). PDF) listed.
Dependency injection and mock objects allow dependencies to be broken, and virtually any code can be isolated for unit testing. However, the significance of dependency injection extends far beyond unit testing. Essentially, it's about separating individual problem areas (separation of concerns). Framing itself has nothing to do with the UART. Thanks to dependency injection, these aspects can also be cleanly separated in the design.
These advantages come at a price, however. The additional abstraction leads to a loss of contextual information. It's no longer immediately obvious what the framing is used for. Furthermore, more code needs to be written, documented, and maintained. In this example, it's not much, but in a real project, there are hundreds of dependencies, and a correspondingly large number of interfaces need to be abstracted. The unit test itself also requires some effort, as the mock objects need to be implemented and set up.
Avoid mock objects by separating core logic and networking.
As in the unit test code of Fig. 6 (see PDFAs is evident, using mock objects always involves a certain amount of effort and makes testing more complex. Conversely, one can ask: Which code can be tested most directly and effortlessly? The answer is simple: Pure functions without dependencies or side effects are the easiest to test. The output depends only on the input, and there are no dependencies to complicate matters.
When code is viewed from this perspective, it's often apparent that classes and functions have two aspects. Firstly, a core logic that defines the processing of data and events. Secondly, a network that connects the code to its environment. In the framing example, the core logic is the creation of the frame, while the network is the routing to the UART. These two aspects can be separated, as shown in Figure 7 (see...). PDF) shown.
This separation eliminates the need for a mock object and simplifies the unit test (Fig. 8, see PDFThe code for the networking is often so simple that no separate unit test is needed. This approach is also known as the Humble Object Pattern [3].
Challenges in testing runtime-critical driver code
In the present project, the core logic and networking were first implemented as shown in Fig. 7 (see PDF) separated. However, performance measurements on the target system revealed that this code was far too slow. Among other factors, the multiple copying of the data and the function call for each sent byte consumed too much time. It was necessary to move the logic to the driver layer. After several optimization steps, the driver code looked like Figure 9 (see PDF) out of.
This optimized code presents three obstacles to unit testing. First, the driver code is not compiled on the PC. Second, definitions in the referenced code conflict. registers.h with other header files. Thirdly, data is written directly to UART registers: the address of UartaRegs.txFifo This corresponds to the register address of the transmit FIFO on the target.
Solution approach: Patching driver code
The first approach was to patch the driver code for the unit test. With a targeted patch, the #include The code was modified, and direct access to the UART registers was replaced with function calls. The resulting file was compiled and tested on the PC. The advantage of this approach is that the driver code can be manipulated as needed to make it testable. On the other hand, there are also many disadvantages. If the driver code is changed, the patch must be adjusted. Since code sections are replaced, errors can be masked. In practice, it turned out that the tests were unreliable and required repeated patching.
Solution approach: Include source file
A second approach is to include the driver's source file in the unit test. This overcomes the first hurdle, compiling the driver code along with the code. To overcome the second hurdle, the... #include The driver is linked to a condition (see Fig. 10, PDF). In regular code, the symbol UNIT_TEST never defined, and registers.h is included. This symbol can be defined in the unit test to... #include to suppress.
The third hurdle is the most technically interesting. How can access to a variable be intercepted? The code under test writes to the register multiple times during a single call, and the test must know not only the last byte, but all bytes written. This is where C++ and its powerful language features come to the rescue. A class is created. FakeFifo Defines a property that overrides the assignment operator for uint8_t. Then, create an object of this type under UartaRegs.txFifo, so that the driver code is applied to the FakeFifo writes. Fig. 11 (see PDF) shows the entire code, including a unit test.
This approach allows the original driver code to be extensively tested with minimal modifications. This also works in the other direction, when reading data from a FIFO register. For this, the class must be... FakeFifo Override the conversion operator for uint8_t.
Conclusion
With a few clever tricks from the C/C++ toolbox, even hardware-related and runtime-critical C code can be tested efficiently. At the unit test level, the logic can be thoroughly examined. For less runtime-critical code, the Humble Object Pattern is recommended to separate core logic from networking. This results in understandable and easily testable code. All these techniques make it possible to increase test coverage. While 100% coverage is not achievable, to quote Martin Fowler [4]: "It is better to write and run incomplete tests than to leave complete tests unused.".
Bibliography and list of sources
[1] Mike Cohn: Succeeding with Agile. Addison-Wesley, 2009
[2] W. Simpson, Editor: RFC1662, PPP in HDLC-like framing. IETF, July 1994
[3] Gerard Meszaros: xUnit Test Patterns. Addison-Wesley, 2007
[4] Martin Fowler: Refactoring. Addison-Wesley, 1999
Testing, Quality & Debugging – Our Training & Coaching
Do you want to bring yourself up to date with the latest technology?
Then find out more here MircoConsult offers training courses/seminars/workshops and individual coaching on the topics of testing, quality & debugging.
Training & coaching on the other topics in our portfolio can be found here. here.
Testing, Quality & Debug Expertise
Valuable expertise on the topics of testing, quality & debugging is available. here Available for you to download free of charge.
You can find expertise on other topics in our portfolio here. here.
