Отладка это вроде бы очевидное свойство тестирования, но мне часто встречались ситуации когда разработчик видел в написании тестов только необходимость самого тестирования. Но 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-тестов не так сложно. Лучше потерять час времени на добавление проекта, но потом не заниматься подготовкой каждой сессии отладки. Да и все последующие тесты уже могут быть добавлены намного проще. Статья также в будущем позволит не тратить время на объяснение этого подхода для других коллег-разработчиков.

Комментарии (5)


  1. jaha33
    06.01.2024 08:52

    А на сколько это вообще применимо, если для микроконтроллера в реальности может использоваться один компилятор со своей оптимизацией, а для юнит теста другой? И даже если вы компилите одни и те же файлы, есть риск получить разный результат на выходе.

    Может просто сам уже слишком закостенел, но сколько статей не читал, так и не увидел полезности в юнитах для встроки


    1. viordash Автор
      06.01.2024 08:52
      +1

      я всегда использую одно и тоже окружение для рабочего и тестового проекта, как в проектах на Windows(VisualGDB) так и в проектах на Linux(gcc/clang + cpputest), в редких случаях могу понизить опции оптимизации.
      Полезность в юнит-тестах точно есть, попробуйте :), еще пару побочных причин могу привести:

      • тестами можно показать как пользоваться каким-либо методом, вместо документации. (помня о том что и в будущем этот тест будет приносить пользу как юнит-тест)

      • более безопасный и быстрый рефакторинг кода.


    1. megabyte1024
      06.01.2024 08:52
      +1

      А на сколько это вообще применимо, если для микроконтроллера в реальности может использоваться один компилятор со своей оптимизацией, а для юнит теста другой? И даже если вы компилите одни и те же файлы, есть риск получить разный результат на выходе.

      IMHO, результат исполнения одного и того же кода написанного на языке высокого уровня должен быть один и тот же, вне зависимости от уровня оптимизации, версии компилятора и прочее. Иначе или код содержит "неопределённое поведение" (UB) или есть проблемы в компиляторе, что менее вероятно, чем первое. Задача программиста писать код без UB и кроссплатформенные unit test'ы помогают в этом.

      Пример полезности unit test'ов из моей практики. Пишу код для обмена сообщениями с "железкой" через UART. В данный момент времени у меня нет в наличии этой "железки". Код разделён на две части - протокол сообщений и собственно связь через UART. Код протокола написан, обвязан unit test'ами и протестирован на desktop компьютере с бытрой компиляцией и удобным отладчиком. И я могу быть с высокой доли уверенности сказать, что этот код заработает на целевом железе и останется оттестировать только UART код.

      Плюсы:

      1. есть ускорение времени разработки как из за быстрой компиляции на desktop компьютере, отсутствия загрузки скомпилированного кода в целевой микропроцессор, удобного отладчика, так из-за того, что не нужно ждать когда появится "железка";

      2. Нет "боязни" изменения кода и изменения версии компилятора.


      1. viordash Автор
        06.01.2024 08:52
        +1

        наличие такого слоя абстракции "железа" очень облегчает разработку.

        Вы "обернули" всё "железо", весь код может работать на хосте? Или только выборочные модули?


        1. megabyte1024
          06.01.2024 08:52

          Второе ближе к реальности. Код реализующий протокол обмена "знает" только об абстрактном интерфейсе связи, один из методов которого send_buffer. Реализация интерфейса для кода протокола не важна. Код реализующий интерфейс может быть работать с uart, или другим физическим интерфейсом, а так же быть частью unit test'а.