Отладка это вроде бы очевидное свойство тестирования, но мне часто встречались ситуации когда разработчик видел в написании тестов только необходимость самого тестирования. Но unit-тесты могут упростить процесс отладки сложных кейсов, для которых приходиться выполнять много предварительных действий, чтобы достичь отлаживаемого места в коде. Можно конечно модификацией рабочего кода ускорить доступ к проблемному участку, но у этого подхода есть существенный минус - это изменение рабочего кода, из-за чего есть шанс забыть удалить дебажные изменения. Я сторонник применения unit-тестов вместо модификации рабочего кода, и к тому же этот тест “отладки” останется на будущее непосредственно как unit-тест для средств тестирования.

В статье рассмотрены три варианта отладки (в формате Microsoft Visual Studio 2022 solution). Два варианта для embedded проектов, под отладочную плату STM32F4-Discovery, так как во встраиваемом ПО часто сложнее соблюсти все условия для срабатывания отлаживаемого кода. И третий вариант для .NET приложения. Все три решения включают в себя рабочий проект и проект для тестирования:

  • С/С++. UTestsForDebug_CAN. Имитация некоторого девайса на STM32F4 с коммуникацией по CAN шине. По легенде пытаемся отладить очень редко исполняемый кусок кода обработки принимаемых команд по CAN. Для отладки будет использована возможность перевести CAN модуль процессора в режим LOOPBACK, в котором передаваемые данные будут поступать в приемную часть.

  • С/С++. UTestsForDebug_UART. Имитация некоторого девайса на STM32F4 с коммуникацией по UART интерфейсу. По легенде пытаемся отладить очень редко исполняемый кусок кода обработки принимаемых команд по UART. Ввиду отсутствия локального “эха” у модуля UART, для отладки воспользуемся “мокингом (Mock)” функций приема/передачи последовательного порта.

  • C#. UTestsForDebug_dotNET. Некоторый сервис по расчету значений по сложной формуле. Отлаживаем эту формулу.

Исходный код всех решений есть на гитхаб. Embedded проекты основаны на плагине VisualGDB, который необходимо предварительно установит�� (для ознакомления достаточно скачать 30 дневную триал-версию) и на тестовом фреймворке CppUTest (он входит в состав VisualGDB). Выбор VisualGDB обусловлен возможностью быстрого старта готового embedded проекта под большое количество платформ и с полноценной отладкой на “железе”. CppUTest выбран из-за более простого мокинга и поддержки детектора memleak-ов из “коробки”. Проект .NET основан на .NET 8.0, используется тестовый фреймворк NUnit.

UTestsForDebug_CAN

Открываем UTestsForDebug_CAN/UTestsForDebug_CAN.sln в Visual Studio. 

Краткое описание легенды: в проекте имеется модуль communication.cpp, в методе Comm_ProcessMessages() которого происходит прием и обработка команд с передающей стороны. По команде cmd_VeryDifficult происходит вызов метода Perform_Command_VeryDifficult, который и необходимо отладить. Но команда cmd_VeryDifficult вызывается только при соблюдений множества условий и поэтому отладка этого метода затруднена.

Упрощенную отладку выполним в отдельном unit-test проекте UTestsForDebug_CAN_Tests.

Создание unit-test проекта.

Жмем в Visual Studio меню->FILE->Add->New Project. В открывшемся мастере нового проекта выбираем “Embedded Project Wizard”:

Указываем имя проекта UTestsForDebug_CAN_Tests и путь в папке UTestsForDebug_CAN

Далее выбираем Unit Test, MSBuild, CppUTest:

На следующей странице указываем тулчейн ARM и процессор STM32F407VG:

Далее выбираем Empty Project:

На финальной странице необходимо указать метод отладки, для STM32 это обычно  ST-Link:

После создания проекта рекомендую переоткрыть solution, для обновления фильтров в проекте.

Тестовый проект использует исходные коды, библиотеки и конфигурации из рабочего проекта. Это минимизирует отличия сред исполнения в рабочем проекте и в тестовом.

Подключение исходных кодов, библиотек и конфигураций рабочего проекта

Для подключения файлов из рабочего проекта, необходимо проделать шаги:

  • Удаляем из проекта UTestsForDebug_CAN_Tests файл:

    •  startup_stm32f407xx.c

  • Создаем раздел-фильтр для файлов рабочего проекта, выделяем проект UTestsForDebug_CAN_Tests, жмем меню->PROJECT->New Filter, вводим название ProjectSources. Правым кликом на вновь созданном фильтре в контекстном меню выбираем Add->Existing Item…

  • Добавляем файлы из рабочего проекта: 

    • UTestsForDebug_CAN/UTestsForDebug_CAN/can_module.cpp

    • UTestsForDebug_CAN/UTestsForDebug_CAN/communication.cpp

    • UTestsForDebug_CAN/UTestsForDebug_CAN/Src/stm32f4xx_hal_msp.c

    • UTestsForDebug_CAN/UTestsForDebug_CAN/Src/stm32f4xx_it.c

    • UTestsForDebug_CAN/UTestsForDebug_CAN/Src/system_stm32f4xx.c

    • UTestsForDebug_CAN/UTestsForDebug_CAN/Inc/main.h

    • UTestsForDebug_CAN/UTestsForDebug_CAN/Inc/stm32f4xx_hal_conf.h

    • UTestsForDebug_CAN/UTestsForDebug_CAN/Inc/stm32f4xx_it.h

    • все файлы из папки UTestsForDebug_CAN/UTestsForDebug_CAN/BSP/STM32F4xxxx/STM32F4xx_HAL_Driver/Src

    • UTestsForDebug_CAN/UTestsForDebug_CAN/BSP/STM32F4xxxx/StartupFiles/startup_stm32f407xx.c

  • Правым кликом на тестовом проекте открываем настройки “VisualGDB Project Properties”, на закладке слева “MSBuild settings”, в поле Preprocessor Macros прописываем “DEBUG=1;STM32F407VG;STM32F407xx” 

  • Там же в настройках проекта, в поле Include Directories вписываем относительные пути к файлам рабочего проекта “../UTestsForDebug_CAN/BSP/STM32F4xxxx/BSP/STM32F4-Discovery;../UTestsForDebug_CAN/Inc;../UTestsForDebug_CAN/Src;../UTestsForDebug_CAN/BSP/STM32F4xxxx/CMSIS_HAL/Device/ST/STM32F4xx/Include;../UTestsForDebug_CAN/BSP/STM32F4xxxx/CMSIS_HAL/Include;../UTestsForDebug_CAN/BSP/STM32F4xxxx/STM32F4xx_HAL_Driver/Inc”.

  • В поле Linker Script указываем на файл скрипта линкера  “../UTestsForDebug_CAN/BSP/STM32F4xxxx/LinkerScripts/STM32F407VG_flash.lds”.

  • В фильтре Source files создаем два файла, правый клик на фильтре Add->New Item:

    • “UTestsForDebug_CAN_Tests.cpp”

    #include <CppUTest/CommandLineTestRunner.h>
    #include <stm32f4xx_hal.h>
    
    int main(void) {
      HAL_Init();
    
      const char *p = "";
      CommandLineTestRunner::RunAllTests(0, &p);
      return 0;
    }

    • “communication_tests.cpp”

    #include "main.h"
    #include <CppUTest/CommandLineTestRunner.h>
    #include <stdio.h>
    
    TEST_GROUP(CommunicationTestGroup){TEST_SETUP(){
            SystemClock_Config();
            CAN_Init(true);
    }
    
    TEST_TEARDOWN() {}
    }
    ;
    
    static Led_TypeDef led_On;
    void BSP_LED_On(Led_TypeDef Led) { led_On = Led; }
    
    static Led_TypeDef led_Off;
    void BSP_LED_Off(Led_TypeDef Led) { led_Off = Led; }
    
    static Led_TypeDef led_Toggle;
    void BSP_LED_Toggle(Led_TypeDef Led) { led_Toggle = Led; }
    
    TEST(CommunicationTestGroup, Debug_VeryDifficult_Case) {
      uint8_t payload[6] = {0, 1, 2, 3, 4, 5};
    
      CAN_SendCommand(TCommandId::cmd_VeryDifficult, 2, payload);
      Comm_ProcessMessages();
    
      CHECK_EQUAL_TEXT(LED6, led_On,
                       "Perform_Command_VeryDifficult was not called");
      CHECK_EQUAL_TEXT(LED5, led_Off,
                       "Perform_Command_VeryDifficult was not called");
    }

Признаком удачно созданного проекта, после пересборки проекта, служит появление нового теста в Test Explorer Visual Studio.

который выполниться без ошибок нажатием на кнопку Run.

Для отладки метода Perform_Command_VeryDifficult необходимо установить breakpoint в этом методе и запустить проект UTestsForDebug_CAN_Tests в Debug. 

Как видно из Call Stack, попадание в целевой метод произошло по упрощенной схеме, в TEST(CommunicationTestGroup, Debug_VeryDifficult_Case) данные, подготовленные для cmd_VeryDifficult, передаются в CAN приемник. В методе Comm_ProcessMessages данные принимаются, парсятся и затем вызывается Perform_Command_VeryDifficult. Проверка состояния индикаторов в финале теста добавлена для определения успешности работы отлаживаемого метода, чтобы оставить этот тест уже непосредственно как unit-тест.

UTestsForDebug_UART

Открываем UTestsForDebug_UART/UTestsForDebug_UART.sln в Visual Studio. 

Легенда похожа на прошлый пример с шиной CAN, необходимо отладить Perform_Command_VeryDifficult.

Упрощенную отладку выполним в отдельном unit-test проекте UTestsForDebug_UART_Tests.

Создание unit-test проекта

Создание тестового проекта аналогично созданию проекта UTestsForDebug_CAN_Tests. За исключением пунктов:

  • Замена CAN на UART в названиях.

  • Файл UTestsForDebug_UART/UTestsForDebug_UART/uart_module.cpp не добавлять в проект.

  • В фильтр “Source files/Device-specific files/Test Framework” добавить файлы для поддержки mocking, макрос $(TESTFW_BASE_LOCAL) по умолчанию указывает на папку с тестовыми фреймворками VisualGDB, т.е. C:\Users\User\AppData\Local\VisualGDB\TestFrameworks\:

    • "$(TESTFW_BASE_LOCAL)\com.sysprogs.unittest.CppUTest\src\CppUTestExt\MockActualCall.cpp"

    • "$(TESTFW_BASE_LOCAL)\com.sysprogs.unittest.CppUTest\src\CppUTestExt\MockExpectedCall.cpp"

    • "$(TESTFW_BASE_LOCAL)\com.sysprogs.unittest.CppUTest\src\CppUTestExt\MockExpectedCallsList.cpp"

    • "$(TESTFW_BASE_LOCAL)\com.sysprogs.unittest.CppUTest\src\CppUTestExt\MockFailure.cpp"

    • "$(TESTFW_BASE_LOCAL)\com.sysprogs.unittest.CppUTest\src\CppUTestExt\MockNamedValue.cpp"

    • "$(TESTFW_BASE_LOCAL)\com.sysprogs.unittest.CppUTest\src\CppUTestExt\MockSupport.cpp"

    • "$(TESTFW_BASE_LOCAL)\com.sysprogs.unittest.CppUTest\src\CppUTestExt\MockSupportPlugin.cpp"

    • "$(TESTFW_BASE_LOCAL)\com.sysprogs.unittest.CppUTest\src\CppUTestExt\MockSupport_c.cpp"

Содержимое UTestsForDebug_UART_Tests.cpp идентично UTestsForDebug_CAN_Tests.cpp.

Содержимое communication_tests.cpp:

#include "CppUTest/TestHarness.h"
#include "CppUTestExt/MockSupport.h"
#include <CppUTest/CommandLineTestRunner.h>

#include "main.h"
#include <stdio.h>

TEST_GROUP(CommunicationTestGroup){TEST_SETUP(){SystemClock_Config();
}
TEST_TEARDOWN() { mock().clear(); }
}
;

TEST(CommunicationTestGroup, Debug_VeryDifficult_Case) {
  TCommandId id;

  id = TCommandId::cmd_VeryDifficult;

  mock()
      .expectOneCall("UART_HandleReceivingCommands")
      .withOutputParameterReturning("id", &id, sizeof(id))
      .andReturnValue(true);

  mock()
      .expectOneCall("UART_SendCommand")
      .withParameter("id", TCommandId::cmd_Start)
      .withParameter("status", 0);

  mock().expectOneCall("BSP_LED_On").withParameter("Led", LED6);
  mock().expectOneCall("BSP_LED_Off").withParameter("Led", LED4);
  mock().expectOneCall("BSP_LED_Off").withParameter("Led", LED5);

  Comm_ProcessMessages();

  mock().checkExpectations();
}

/* mocking work module*/

void UART_Init() { mock().actualCall("UART_Init"); }

void UART_SendCommand(TCommandId id, uint8_t status, uint8_t *payload) {
  (void)payload;

  mock()
      .actualCall("UART_SendCommand")
      .withIntParameter("id", id)
      .withUnsignedIntParameter("status", status);
}

bool UART_HandleReceivingCommands(TCommandId *id, uint8_t *status,
                                  uint8_t *payload, size_t payload_size) {
  (void)status;
  (void)payload;
  (void)payload_size;

  return mock()
      .actualCall("UART_HandleReceivingCommands")
      .withOutputParameter("id", id)
      .returnBoolValue();
}

void BSP_LED_On(Led_TypeDef Led) {
  mock().actualCall("BSP_LED_On").withIntParameter("Led", Led);
}

void BSP_LED_Off(Led_TypeDef Led) {
  mock().actualCall("BSP_LED_Off").withIntParameter("Led", Led);
}

void BSP_LED_Toggle(Led_TypeDef Led) {
  mock().actualCall("BSP_LED_Toggle").withIntParameter("Led", Led);
}

После внесения всех изменений и пересборки проекта в Test Explorer Visual Studio появиться новый тест “Debug_VeryDifficult_Case”, который должен успешно выполниться по нажатию на Run.

Отладка метода Perform_Command_VeryDifficult аналогична примеру с CAN шиной. Передаваемые по UART данные симулируется при помощи “mocking” функции UART_HandleReceivingCommands,

id = TCommandId::cmd_VeryDifficult;
 mock()
     .expectOneCall("UART_HandleReceivingCommands")
     .withOutputParameterReturning("id", &id, sizeof(id))
     .andReturnValue(true);

при вызове UART_HandleReceivingCommands в выходные параметры функции будут переданы данные которые были определены в начале теста, “expectOneCall…withOutputParameterReturning…”. Вариации значений в методах withOutputParameterReturning позволяют симулировать различные кейсы. В примере симуляция данных только у аргумента “id”.

Также для проверки успеха тестирования Perform_Command_VeryDifficult замоканы функции:

  • UART_SendCommand, с ожиданием id равным TCommandId::cmd_Start и status равным 0.

  • BSP_LED_On, с ожиданием аргумента Led равным LED6

  • и два срабатывания BSP_LED_Off, со значениями у Led равными LED4 и LED5.

Эти проверки позволят использовать этот код уже непосредственно как unit-тест.

UTestsForDebug_dotNET

Открываем UTestsForDebug_dotNET/UTestsForDebug_dotNET.sln в Visual Studio. 

Для ускорения доступа к отлаживаемому коду в методе SuperCalc.GetVeryDifficultCompute использован отдельный unit-test проект UTestsForDebug_dotNET.Tests.

Создание unit-test проекта
  • Жмем в Visual Studio меню->FILE->Add->New Project. 

  • В открывшемся мастере нового проекта выбираем “NUnit Test Project”. 

  • Указываем имя проекта UTestsForDebug_dotNET.Tests и путь в папке UTestsForDebug_dotNET

  • Далее выбираем Framework .NET 8.0 (LTS) и создаем проект. 

  • В Visual Studio меню->PROJECT жмем на Add Project Reference… и в появившемся окне отмечаем проект UTestsForDebug_dotNET. 

  • Переименовываем файл UnitTest1.cs в SuperCalcTests.cs, заполняем его кодом:

namespace UTestsForDebug_dotNET.Tests {
    public class SuperCalcTests {
        [Test]
        public void GetVeryDifficultCompute_Test() {
            var value = SuperCalc.GetVeryDifficultCompute(0);
            Assert.That(value, Is.EqualTo(0));

            value = SuperCalc.GetVeryDifficultCompute(int.MaxValue);
            Assert.That(value, Is.EqualTo(4.6116860143471688E+18).Within(1).Ulps);

            value = SuperCalc.GetVeryDifficultCompute(int.MinValue);
            Assert.That(value, Is.EqualTo(4.61168601821264E+18).Within(1).Ulps);
        }
    }
}

После внесения всех изменений и пересборки проекта в Test Explorer visual studio должен появиться новый тест “GetVeryDifficultCompute_Test”.

Отладка метода SuperCalc.GetVeryDifficultCompute может производится простым Debug-ом этого теста, правый клик на тесте GetVeryDifficultCompute_Test в редакторе и затем выбор Debug Tests.

Debug Tests

Для изменения входных аргументов, без перезапуска отладки можно воспользоваться “перетягиванием” точки исполнения (желтая стрелка) обратно на точку вызова метода.

Повторить вызов метода

И затем после входа в GetVeryDifficultCompute поменять значение у аргумента number на необходимое.

Inline редактирование аргумента

Заключение

Данной статьей я хотел популяризировать этот подход, ведь добавить дополнительный проект для unit-тестов не так сложно. Лучше потерять час времени на добавление проекта, но потом не заниматься подготовкой каждой сессии отладки. Да и все последующие тесты уже могут быть добавлены намного проще. Статья также в будущем позволит не тратить время на объяснение этого подхода для других коллег-разработчиков.