Increase the Quality of Your Embedded Projects Using PlatformIO Unit Tests
2022-09-07 | By Maker.io Staff
Unit-testing is a method commonly used when developing more complex applications, and it is one of three primary testing techniques that help engineers boost the reliability of their programs. This article introduces you to the basics of unit tests and how to perform them using PlatformIO, an advanced IDE for developing embedded applications.
What are Unit Tests?
When conducting unit tests, developers identify and isolate modules of their program, intending to test each of them independently from the other modules in an application. The goal is to determine whether each module returns the correct results for a given set of input values. Doing so can help engineers narrow down the source of errors in their program, thereby decreasing the number of bugs and increasing the code quality of the final product.
The testing can happen manually; however, automated tests are the more common approach, yielding more consistent results and eliminating user error. Further, there are two approaches to unit-testing, black-box testing and white-box testing, which describe how to write the test cases for a program.
When performing black-box testing, developers supply a module with predetermined, fixed input parameters and compare the values returned by the program with the ones they expect. This technique doesn't require knowledge of the program's internal structure, as it exclusively examines the returned output values. On the other hand, white-box tests can be more specifically tailored for specific applications, as they require the test engineer to know the internals of a program. With this method, engineers can test for various more finely granulated potential problems, for example, whether a for-loop leads to an invalid index error given specific inputs.
Prerequisites for Running Automated Unit Tests in PlatformIO
Start by installing Visual Studio Code and the PlatformIO extension. Next, install support for running tests on your native development platform. While PlatformIO allows running unit tests directly on the target platform (for example, an Arduino UNO board), this tutorial explains how to execute test cases on the native platform for the sake of simplicity.
To get started, navigate to the PlatformIO home page within VS Code. Then, click the platforms button on the left-hand side of the window. Next, switch to the desktop tab and use the search bar to find the native module:
Follow the highlighted steps to find the native platform support module in PlatformIO.
You can then use the install button on the native platform support module’s page to install the necessary files:
Use the install button to download and install the necessary platform support files.
Next, navigate back to the PlatformIO home page and use the project examples button from the quick access menu to search for the hello world example project:
Use the quick access menu to import the hello-world example project in PlatformIO.
This article uses the example project as a base for creating a simple program for demonstrating how unit tests work.
Creating a Simple Module for Testing
The remainder of this article focuses on testing the following simple program for checking whether a user is at least 19 years old. For that purpose, the program contains a single module with a function that returns a positive integer value whenever the supplied age is strictly greater than 18. If the user is not old enough, the function returns zero. Otherwise, for example, when providing an invalid number, the function returns a negative integer value.
To start, create a new folder in the lib folder and name it age_check. Then, add two new files within the newly created folder and call them age_check.h and age_check.c, respectively. You must not place the source and header file directly in the lib folder. Instead, always create a new subfolder for every module to prevent linker issues later on:
Create the highlighted folder and files using PlatformIO’s file explorer.
Then, copy the following code snippet and paste it into the header file you just created:
#ifndef AGE_CHECK_H #define AGE_CHECK_H // Returns 1 if age is strictly greater than 18. // Returns 0 if age is between 0 and 18 (including 0 and 18) // Returns -1 if age is negative or greater than 99 int isOldEnough(int age); #endif
Then, paste the following code in the source file:
#include "age_check.h" int isOldEnough(int age) { if(age > 18) return 1; else if(age >= 0 && age < 18) return 0; else return -1; }
You might already have spotted a problem with the code. The comment next to the function in the header file states that the method should return zero if the user is precisely eighteen years old. However, the “if” statements in the code don’t reflect this requirement. But, as this is a black-box test, we would not have access to the code (or at least not look at the implementation when writing the test cases).
Thinking About a Test Strategy
Once you create the simple module from above, you can start by thinking about how to test it. Ideally, you’ll have a specification document that describes what each function does and the expected outputs. This information is present in the header file’s source code from above. From this, we can derive a few possible input combinations for testing the add function, for example:
Again, remember to only focus on the formal specification of what the program is supposed to do for given inputs when creating your black-box unit test plan. Therefore, you must also include the fifth test case, even though you already know it will fail. Once your test cases are ready, you can implement them in PlatformIO.
Creating a Black-Box Unit Test in PlatformIO
Like library files, you must create test classes within the project’s test folder. For that purpose, create a new subfolder for every module you want to test. In this example, there’s only the age_check module. Therefore, the test folder should then look similar to this:
Make sure to create a subfolder for every unit you want to test. Then, prefix the folder and test class’ name with test_ and follow that prefix with the name of the module you want to test.
The code for the test class is relatively simple in this case, and it only contains our five test functions determined above:
#include <unity.h> #include "../../lib/age_check/age_check.h" void setUp(void) { /* set stuff up here */ } void tearDown(void) { /* clean stuff up here */ } void test_when_age_21_then_return_positive_int() { TEST_ASSERT(isOldEnough(21) > 0); } void test_when_age_10_then_return_0() { TEST_ASSERT_EQUAL_INT(isOldEnough(10), 0); } void test_when_age_0_then_return_0() { TEST_ASSERT_EQUAL_INT(isOldEnough(0), 0); } void test_when_age_negative_then_return_negative_int() { TEST_ASSERT_TRUE(isOldEnough(-10) < 0); } void test_when_age_18_then_return_0() { TEST_ASSERT_EQUAL_INT(isOldEnough(18), 0); } int main( int argc, char **argv) { UNITY_BEGIN(); RUN_TEST(test_when_age_21_then_return_positive_int); RUN_TEST(test_when_age_10_then_return_0); RUN_TEST(test_when_age_0_then_return_0); RUN_TEST(test_when_age_negative_then_return_negative_int); RUN_TEST(test_when_age_18_then_return_0); UNITY_END(); }
You can use the Unity framework’s TEST functions to test whether the returned values meet certain expectations. Take as an example the test_when_age_21_then_return_positive_int function. Here, I used the TEST_ASSERT function to check whether the supplied condition is true. In this case, the returned value must be a positive integer, as the user is old enough. Remember that we know that the function returns one in this case. However, the specification only states that the result must be a positive integer. Black-box tests don’t make assumptions based on the source code.
Next, you can use the setUp function to make the test framework perform some tasks before it executes your tests. This can, for example, include establishing a database connection or loading data from an external file. Similarly, you can use the tearDown method to free resources after running the tests, for example.
Finally, the test class’ main method is what instructs the test framework to execute the test cases defined above. You can then use PlatformIO’s project tasks menu to build your project and run the test cases:
Follow the outlined steps to run the test cases in your project.
As expected, one of the test cases fails because the function should return zero if the entered age is equal to 18. However, they should succeed once you return to the library code and fix the error. The result should then look as follows:
All tests passed.
Summary
Unit testing is an essential tool in every software engineer’s repertoire, as it helps reduce the number of errors in your programs, thereby increasing the overall quality of the finished product. In addition, the technique helps narrow down the source of bugs and helps developers think in terms of isolated modules, which typically leads to more loosely coupled programs.
PlatformIO can execute test cases locally and directly on the target hardware. However, this tutorial focused on running tests locally on the native platform to focus on the most critical aspects of the overall testing procedure. Start by installing the support files for your native platform in PlatformIO. Then, create a test project and write a small module you can test. Ensure to place each module’s files in a separate subfolder within the project’s lib folder to prevent linking issues. Similarly, place every module’s test files in a separate subfolder within the project’s test folder. Make sure to exactly match the names and prefixes of the files and folders, as PlatformIO might not recognize the files otherwise.
Finally, implement your test cases in a test class and use PlatformIO’s project tasks panel to execute the tests. You cannot run the test cases using VS Code’s run or debug buttons, as the program will fail to find the necessary library files.
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.
Visit TechForum