blob: cc107d23a698b30a5339efc27cbfa9056926f40b [file] [log] [blame]
Implementing tests
==================
Concept of unit testing
-----------------------
First of all unit tests exercise the C code on a function level. The tests
should call functions directly from the code under tests and verify if their
return values are matching the expected ones and the functions are behaving
according to the specification.
Because of the function level testing the dependencies of the tested functions
should be detached. This is done by mocking the underlying layer. This provides
an additional advantage of controlling and verifying all the call to the lower
layer.
Adding new unit test suite
--------------------------
The first step is to define a new unit test suite. If a completely new module is
being test the test suite definition should be created in a separate ``.cmake``
file which is placed in the test files' directory. Otherwise the test
definition can be added to an existing ``.cmake`` file. These files should be
included in the root ``CMakeLists.txt``.
The ``UnitTest`` CMake module defines the ``unit_test_add_suite`` function so
before using this function the module must be included in the ``.cmake`` file.
The function first requires a unique test name which will be test binary's name.
The test sources, include directories and macro definition are passed to the
function in the matching arguments. CMake variables can be used to reference
files relative to common directories:
- ``CMAKE_CURRENT_LIST_DIR`` - Relative to the ``.cmake`` file
- :cmake:variable:`TF_A_PATH` - Relative to the Trusted Firmware-A root directory
- :cmake:variable:`TF_A_UNIT_TESTS_PATH` - Relative to the unit test root directory
.. code-block:: cmake
# tests/new_module/new_test_suite.cmake
include(UnitTest)
unit_test_add_suite(
NAME [unique test name]
SOURCES
[source files]
INCLUDE_DIRECTORIES
[include directories]
COMPILE_DEFINITIONS
[defines]
)
.. code-block:: cmake
# Root CMakeLists.txt
include(tests/new_module/new_test_suite.cmake)
Example test definition
^^^^^^^^^^^^^^^^^^^^^^^
.. code-block:: cmake
unit_test_add_suite(
NAME memcmp
SOURCES
${CMAKE_CURRENT_LIST_DIR}/test_memcmp.cpp
${CMAKE_CURRENT_LIST_DIR}/memcmp.yml
INCLUDE_DIRECTORIES
${TF_A_PATH}/include
${TF_A_PATH}/include/lib/libc/aarch64/
)
Using c-picker
--------------
c-picker is a simple tool used for detaching dependencies of the code under
test. It can copy elements (i.e. functions, variables, etc.) from the original
source code into generated files. This way the developer can pick functions from
compilation units and surround them with a mocked environment.
If a ``.yml`` file listed among source files the build system invokes c-picker
and the generated ``.c`` file is implicitly added to the source file list.
Example .yml file
^^^^^^^^^^^^^^^^^
In this simple example c-picker is instructed to copy the include directives and
the ``memcmp`` function from the ``lib/libc/memcmp.c`` file. The root directory
of the source files referenced by c-picker is the Trusted Firmware-A root
directory.
.. code-block:: yaml
elements:
- file: lib/libc/memcmp.c
type: include
- file: lib/libc/memcmp.c
type: function
name: memcmp
Writing unit tests
------------------
Unit test code should be placed in ``.cpp`` files.
Four-phase test pattern
^^^^^^^^^^^^^^^^^^^^^^^
All tests cases should follow the four-phase test pattern. This consists of four
simple steps that altogether ensure the isolation between test cases. These
steps follows below.
- Setup
- Exercise
- Verify
- Teardown
After the teardown step all global states should be the same as they were at the
beginning of the setup step.
CppUTest
^^^^^^^^
CppUTest is an open source unit testing framework for C/C++. It is written in
C++ so all the useful features of the language is available while testing. It
automatically collects and runs the defined ``TEST_GROUPS`` and provides an
interface for implementing the four-phase test pattern. Furthermore the
framework has assertion macros for many variable types and test scenarios.
Include
'''''''
The unit test source files should include the CppUTest header after all other
headers to avoid conflicts.
.. code-block:: C++
// Other headers
// [...]
#include "CppUTest/TestHarness.h"
Test group
''''''''''
The next step is to define a test group. When multiple tests cases are written
around testing the same function or couple related functions these tests cases
should be part of the same test group. Basically test cases in a test group
share have same setup/teardown sequence. In CppUTest the ``TEST_GROUP`` macro
defines a new class which can contain member variables and functions. Special
setup/teardown function are defined using ``TEST_SETUP`` and ``TEST_TEARDOWN``
macros. These functions are called before/after running each test case of the
group so all the common initilization and cleanup code should go into these
functions.
.. code-block:: C++
TEST_GROUP(List) {
TEST_SETUP() {
list = list_alloc();
}
TEST_TEARDOWN() {
list_cleanup(list);
}
bool has_element(int value) {
for (int i = 0; i < list_count (list); i++) {
if (list_get(i) == value) { return true; }
}
return false;
}
List* list;
};
Test case
'''''''''
Test cases are defined by the ``TEST`` macro. This macro defines a child class
of the test group's class so it can access the member functions and variables of
the test group. The test case's block itself is the body of the function of the
child class.
.. code-block:: C++
TEST(List, add_one) {
// Exercise
const int test_value = 5;
list_add(list, test_value);
// Verify using CHECK_TRUE assertion and TEST_GROUP member function
CHECK_TRUE(has_element(test_value));
}
TEST(List, add_two) {
// Exercise
const int test_value1 = 5;
const int test_value2 = 6;
list_add(list, test_value1);
list_add(list, test_value2);
// Verify
CHECK_TRUE(has_element(test_value1));
CHECK_TRUE(has_element(test_value2));
}
CppUMock
^^^^^^^^
During unit testing the dependencies of the tested functions should be replaced
by stubs or mocks. When using mocks the developer would usually like to check if
the function was called with corrent parameters and would like to return
controlled values from the function. When a mocked function is called multiple
times from the tested function maybe it should check or return different values
on each call. This is where CppUMock comes handy.
All CppUMock interactions start with calling the ``mock()`` function. This
returs a reference to the mocking system. At this point the developer either
wants to define expected or actual calls. This is achiveable by calling
``expectOneCall(functionName)`` or ``expectNCalls(amount, functionName)`` or
``actualCall(functionName)`` member functions of ``mock()`` call's return value.
Registering expected calls are done in the test case before exercising the code
and actual calls happen from the mocked functions.
After this point the following functions can be chained:
- ``onObject(object)`` - In C++ it is usually the ``this`` pointer but it can be
useful in C too.
- ``with[type]Parameter(name, value)`` - Specifying and checking call parameters
- ``andReturnValue(result)`` - Specifying return value when defining expected
call
- ``return[type]Value()`` - Returning value from function
The mocking system has two important functions. ``mock().checkExpectation()``
checks if all the expected calls have been fulfilled and and the
``mock().clear()`` removes all the expected calls from CppUMock's registry.
These two functions are usually called from the ``TEST_TEARDOWN`` function
because there should not be any crosstalk between test cases through the mocking
system.
CppUMock's typical use-case is shown below by a simple example of the
``print_to_eeprom`` function.
.. code-block:: C++
int eeprom_write(const char* str); /* From eeprom.h */
int printf_to_eeprom(const char* format, ...) {
char buffer[256];
int length, written_bytes = 0, eeprom_write_result;
va_list args;
va_start(args, format);
length = vsnprintf(buffer, sizeof(buffer), format, args);
va_end(args);
if (length < 0) {
return length;
}
while(written_bytes < length) {
eeprom_write_result = eeprom_write(&buffer[written_bytes]);
if (eeprom_write_result < 0) {
return eeprom_write_result;
}
written_bytes += eeprom_write_result;
}
return written_bytes;
}
Having the code snipped above a real life usage of the function would look like
something shown in the following sequence diagram.
.. uml:: resources/sequence_print_to_eeprom.puml
It would be really hard to test unit this whole system so all the lower layers
should be separated and mock on the first possible level. In the following
example the ``print_to_eeprom`` function is being tested and the
``eeprom_write`` function is mocked. In test cases where ``eeprom_write``
function is expected to be called the test case should first call the
``expect_write`` function. This registers an expected call to CppUMocks internal
database and when the call actually happens it matches the call parameters with
the entry in the database. It also returns the previously specified value.
.. code-block:: C++
TEST_GROUP(printf_to_eeprom) {
TEST_TEARDOWN() {
mock().checkExpectations();
mock().clear();
}
void expect_write(const char* str, int result) {
mock().expectOneCall("eeprom_write").withStringParameter("str", str).
andReturnValue(result);
}
};
/* Mocked function */
int eeprom_write(const char* str) {
return mock().actualCall("eeprom_write").withStringParameter("str", str).
returnIntValue();
}
TEST(printf_to_eeprom, empty) {
LONGS_EQUAL(0, printf_to_eeprom(""))
}
TEST(printf_to_eeprom, two_writes) {
expect_write("hello1hello2", 6);
expect_write("hello2", 6);
LONGS_EQUAL(12, printf_to_eeprom("hello%dhello%d", 1, 2))
}
TEST(printf_to_eeprom, error) {
expect_write("hello", -1);
LONGS_EQUAL(-1, printf_to_eeprom("hello"))
}
This how the ``printf_to_eeprom/two_writes`` test case's sequence diagram looks
like after mocking ``eeprom_write``. The test case became able to check the
parameters of multiple calls and it could return controlled values.
.. uml:: resources/sequence_print_to_eeprom_mock.puml
Analyzing code coverage
-----------------------
The code coverage reports can be easily used for finding untested parts of the
code. The two main parts of the coverage report are the line coverage and the
branch coverage. Line coverage shows that how many times the tests ran the given
line of the source code. It is beneficial to increase the line coverage however
100% line coverage is still not enough to consider the code fully tested.
Let's have a look on the following example.
.. code-block:: C++
void set_pointer_value(unsigned int id, unsigned int value) {
unsigned int *pointer;
if (id < MAX_ID) {
pointer = get_pointer(id);
}
*pointer = value;
}
The 100% line coverage is achievable by testing the function with an ``id``
value smaller than ``MAX_ID``. However if an ``id`` larger than or equal to
``MAX_ID`` is used as a parameter of this function it will try to write to a
memory address pointed by an uninitialized variable. To catch untested
conditions like this the branch coverage comes handy. It will show that only one
branch of the ``if`` statement has been tested as the condition was always true
in the tests.
--------------
*Copyright (c) 2019-2020, Arm Limited. All rights reserved.*