Упрощение отладки с помощью unit-тестов

Отладка это вроде бы очевидное свойство тестирования, но мне часто встречались ситуации когда разработчик видел в написании тестов только необходимость самого тестирования. Но 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»:

0cf66e0d24e180f07afd03d0967ceaea.png

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

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

6b61e2a0564999df6bbd40cd16d1dd6a.png

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

8b8ec4cf876c94a615dee52d04856036.png

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

ca61fd98f06b3ce85b1e416c1b30598d.png

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

1b40489b96a5292e371e9f03182e1e05.png

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

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

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

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

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

    •  startup_stm32f407xx.c

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

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

    • 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:

    #include 
    #include 
    
    int main(void) {
      HAL_Init();
    
      const char *p = "";
      CommandLineTestRunner::RunAllTests(0, &p);
      return 0;
    }
    #include "main.h"
    #include 
    #include 
    
    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.

0dd915d07061cd7b056ccf3e9c96fcd4.png

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

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

d31689a3d2c901f247d0762c6b40e416.png

Как видно из 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 

#include "main.h"
#include 

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», который должен успешно выполняться по нажатию на Run.

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

Debug Tests

899ed938141ed267a9f3f329a3e2f0e5.png

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

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

39c88d95789a6bbc1ff25c14008d4018.png

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

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

b8e25345dba1a0db5c554f5bf7715742.png

Заключение

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

© Habrahabr.ru