Юнит-тесты в uVision Keil (и не только)

    КПДВ


    Не утихают споры о том, нужны ли юнит-тесты вообще, а если нужны — то как именно их писать. Сначала писать код или сначала писать тесты? Допустимо ли нарушать инкапсуляцию при тестировании или же можно трогать только публичное API? Сколько процентов кода должно быть покрыто тестами?


    Тестирование во встраиваемых системах тоже порождает немало споров. Точки зрения разнятся от "покрытие должно быть 100% + нужны испытательные стенды" до "какие еще тесты, я программу написал — значит все работает".


    Я не хочу начинать холивар и вооще стараюсь придерживаться некоего разумного баланса. Поэтому для начала предлагаю рассмотреть самые "низко висящие" плоды, которые позволяет сорвать юнит-тестирование применительно к embedded-разработке.


    1. Можно писать код для устройства, которого у вас еще нет. Это может быть датчик с алиэкспресса, который почтой идет две недели или какой-нибудь отечественный девайс со сроком поставки в полгода — это не столь важно.
    2. Можно менять код и не бояться, что ваши изменения сломают что-то, что работало раньше (если это что-то покрыто тестами, разумеется).
    3. Следствие предыдущего пункта: вы можете передавать разработку другому человеку и тоже не бояться, что он сломает что-то, что раньше работало.
    4. Если вы пишете какие-нибудь библиотеки (в минимуме — просто какой-то код, который используется не один раз), то с помощью тестов вы можете синтезировать ситуации, которые "руками" можно ловить очень долго.
    5. В тестовой конфигурации можно использовать всякие дополнительные инструменты для оценки качества/надежности кода — санитайзеры, опции компилятора для защиты от переполнения стека и тому подобные вещи, которые в "боевую" прошивку не влезают

    Первый пункт сильно зависит от того, насколько документация на устройство соответствует действительности, но мой опыт показывает, что небольшие несоответствия все же не вызывают необходимости переписывать прям всё к чертям, а приводят к небольшим же изменениям в коде.


    Второй и третий пункт предполагают, что ваши тесты действительно что-то проверяют (но если нет, то зачем их вообще писать?).


    Разумеется, каждый решает сам для себя, нужны ему тесты или нет (если только у вас нет корпоративной политики, что они нужны); в этой статье я никого ни в чем не пытаюсь убедить, а просто рассказываю, как можно эти самые тесты писать и запускать, и как тестировать некоторые специфические вещи.


    Далее пойдет речь о юнит-тестировании кода для встраиваемых систем — в основном, микроконтроллеров (МК). Предполагается что код на С и С++



    Как можно запускать тесты?


    На мой взгляд, глобально можно выделить 3 способа:


    1. На обычном ПК (настольном или на тестовом сервере, не суть)
    2. На физическом МК — т.е. на специальной тестовой плате
    3. В симуляторе МК

    Давайте разберем плюсы и минусы каждого подхода.


    На обычном ПК


    Плюсы:


    • тесты выполняются максимально быстро
    • у ПК много памяти, тестовый бинарник может быть существенно больше, чем релизная прошивка
    • не нужна физическая плата; тестовое окружение можно сделать легко воспроизводимым
    • можно использовать дополнительный инструментарий — valgrind, санитайзеры
    • легко выводить результаты тестов — в stdout, в красивое окно, в текстовый файл — на ваш вкус

    Спорный момент:


    Другой компилятор и другая стандартная библиотека. Это плюс, потому что позволяет отловить платформозависимый код, получить больше предупреждений от нового компилятора. Но не факт, что вы к этому стремитесь. Минус, собственно, в этом же — придется писать платформонезависимый код, скорее всего, оборачиваясь в условную компиляцию; иногда придется подкостыливать код для работы под компилятором, который вам вообще и не нужен для собственно рабочей прошивки.


    Минусы:


    • почти наверняка другая архитектура процессора — т.е. какие-то вещи могут работать не так, как в релизной прошивке на целевом устройстве (например, какие-нибудь тонкие отличия между программной реализацией плавающей точки в МК и аппаратной у х86)
    • нужно держать параллельный проект для другого компилятора/системы сборки. Трудоемкость этого зависит от системы сборки, которую вы выберете.
    • трудно тестировать прерывания
    • трудно тестировать работу с ОСРВ

    На физическом МК


    Плюсы:


    • архитектура та же, что у целевого устройства
    • тесты выполняются достаточно быстро
    • легко тестировать прерывания и работу с ОСРВ
    • можно тестировать работу с внешними устройствами

    Спорный момент все тот же, только с другой стороны — компилятор, стандартная библиотека и рантайм такие же, как и в релизе.


    Минусы:


    • мало памяти — как для кода, так и оперативной
    • нужна физическая тестовая плата, источник питания и т.д.
    • нужен какой-то интерфейс для связи с ПК или с чем-то еще, чтобы выводить результаты тестирования
    • короче, нужен тестовый стенд

    В симуляторе


    Плюсы:


    • архитектура примерно та же, что и на целевом устройстве
    • не нужна плата или тестовый стенд
    • симулированной памяти может быть много
    • если симулятор сможет, то можно тестировать прерывания и работу с ОСРВ

    Спорный момент все тот же.


    Минусы:


    • нужен симулятор
    • самый медленный вариант из трех
    • симулятор не совсем идентичен физическому МК, это может породить тонкие проблемы

    Следующий интересный вопрос — что покрывать тестами, а что нет?


    Что тестировать?


    С одной стороны, некоторые вещи тестить легко, а некоторые — тяжело. С другой, некоторые вещи как будто сильнее нуждаются в тестах, а другие — слабее.


    Если разобраться, то легко тестить код, который слабо завязан на какие-то сложные концепции или аппаратную специфику. Например, общение с каким-нибудь датчиком легко абстрагируется от реального физического интерфейса — такому коду вполне достаточно умения принять пачку байт и отправить пачку байт.


    А, скажем, код для управления BLDC-двигателем тестируется тяжело, потому что для этого нужна, фактически, виртуальная модель двигателя. Или, скажем, попиксельное рисование на каком-нибудь OLED-экране гораздо проще проверять визуально.


    А вот насколько сильно вещи нуждаются в тестах, сильно зависит от задачи.


    Что же выбрать? Тут я, опять-таки, не берусь давать универсальных советов, а вместо этого попытаюсь обосновать наш выбор с исторической точки зрения. Если вам это не очень интересно, то можете следующую главу смело пропустить.


    Историческая перспектива и локальная специфика


    Сперва специфика. Тут я не придумал, как можно эти факты упорядочить или сгруппировать, поэтому просто перечислю.


    Сперва вещи, над которыми программисты не властны:


    • проектов (т.е. изделий) много, их разработка часто происходит одновременно. Длительность проектов от полугода до полутора лет; что-то более долгоиграющее попадается редко
    • каждое изделие содержит одну, но чаще несколько плат с микроконтроллерами, эти платы связываются друг с другом какими-нибудь интерфейсами, поскольку должны взаимодействовать
    • изделия сами по себе разные, но в них есть повторяющиеся или похожие "куски", как-то:
      • общение по интерфейсам связи между платами
      • управление DC и BLDC двигателями
      • общение с разнообразными датчиками и прочими покупными изделиями
      • работа с матричными клавиатурами и матрицами светодиодов
    • некоторые изделия должны быть выполнены на отечественной элементной базе

    А остальное — просто "исторически сложилось", иногда без всякой рациональной причины:


    • почти всегда действует правило "одна прошивка — один разработчик". С одной стороны это снижает трудозатраты на слияния, но с другой стимулирует людей "окукливаться" в своем мирке, не обмениваться кодом и переизобретать велосипеды
    • вся команда сидела под Windows, в качестве IDE использовался uVision Keil. Тут нет рациональных причин, только рационализации :)
    • МК — только ARM Cortex; в основном — STM32, для "отечки" — Миландр. Во многом дело вкуса.
    • изначально разработка велась на чистом С, но очень медленно перешла на С++
    • никто из команды (включая меня) не учился на программиста
    • никаких аджайлов, скрамов и прочих методов выпаса кошачьих не применялось
    • начиналась вся эта история примерно в 2013 году — C++11 появился совсем недавно, Keil был версии 4.20 вроде бы и С++11 не поддерживал

    К чему же все это привело? А вот к чему.


    Разработчики разобщены и стремятся закуклиться, поэтому любые инновации нужно подавать постепенно; они не должны требовать установки 10 программ и полной смены рабочего процесса. Поэтому отпадает вариант "тестирование на ПК" — это потребовало бы ставить какую-то другую IDE, другой компилятор, вести в нем параллельный проект — слишком сложно.


    Поэтому же отпадают все варианты, которые требуют ставить Linux (например, запуск тестов в QEMU).


    Использование тестовой платы отпадает по умеренно-объективным причинам — нужна плата (и желательно не одна), просто слишком много возни по сравнению с чисто программными вариантами.


    Таким образом историческая неизбежность приводит нас к запуску тестов в симуляторе Keil. А отсутствие хороших програмистстких практих подталкивает к велосипедостроению :)


    Запуск тестов в симуляторе Keil'a


    Выбор фреймворка


    Я, честно говоря, уже плохо помню, чем я руководствовался, когда выбирал фреймворк, слишком много лет прошло. Кажется, я успел посмотреть на GoogleTest, но счел его слишком большим и сложным, фреймворки на чистом С были страшненькими и либо нуждались в ручной регистрации каждого теста, либо опирались на сторонние скрипты, с которыми связываться было не очень охота.
    А у CppUTest был пример для IAR'a (а это почти как Keil).


    В целом, это не очень важно. Главное, что CppUTest достаточно легко скомпилировался в Keil'e; для этого пришлось создать всего один файл, наполненный платформозависимыми функциями. Практически все оттуда я передрал из аналогичного файла для IAR, если честно.


    Для полноты картины, привожу здесь этот файл целиком:


    UtestPlatform.cpp
    #include <stdio.h>
    #include <stdlib.h>
    #include <stdarg.h>
    #include <string.h>
    #include <math.h>
    #include <float.h>
    #include <ctype.h>
    #include <setjmp.h>
    
    #include "CppUTest/TestHarness.h"
    #include "CppUTest/PlatformSpecificFunctions.h"
    #include "CppUTest/TestRegistry.h"
    
    // скопировано из ИАРа
    
    static jmp_buf test_exit_jmp_buf[10];
    static int jmp_buf_index = 0;
    
    int PlatformSpecificSetJmp(void (*function) (void* data), void* data)
    {
        if (0 == setjmp(test_exit_jmp_buf[jmp_buf_index])) {
            jmp_buf_index++;
            function(data);
            jmp_buf_index--;
            return 1;
        }
        return 0;
    }
    
    void PlatformSpecificLongJmp()
    {
        jmp_buf_index--;
        longjmp(test_exit_jmp_buf[jmp_buf_index], 1);
    }
    
    void PlatformSpecificRestoreJumpBuffer()
    {
        jmp_buf_index--;
    }
    
    void PlatformSpecificRunTestInASeperateProcess(UtestShell* shell, TestPlugin* plugin, TestResult* result)
    {
       printf("-p doesn't work on this platform as it is not implemented. Running inside the process\b");
       shell->runOneTest(plugin, *result);
    }
    
    TestOutput::WorkingEnvironment PlatformSpecificGetWorkingEnvironment()
    {
        return TestOutput::vistualStudio;
    }
    
    ///////////// Time in millis
    
    static long TimeInMillisImplementation()
    {
        return 12345;
    }
    
    static long (*timeInMillisFp) () = TimeInMillisImplementation;
    
    long GetPlatformSpecificTimeInMillis()
    {
        return timeInMillisFp();
    }
    
    void SetPlatformSpecificTimeInMillisMethod(long (*platformSpecific) ())
    {
        timeInMillisFp = (platformSpecific == 0) ? TimeInMillisImplementation : platformSpecific;
    }
    
    ///////////// Time in String
    
    static const char* TimeStringImplementation()
    {
        return "Keil time needs work";
    }
    
    static const char* (*timeStringFp) () = TimeStringImplementation;
    
    const char* GetPlatformSpecificTimeString()
    {
        return timeStringFp();
    }
    
    void SetPlatformSpecificTimeStringMethod(const char* (*platformMethod) ())
    {
        timeStringFp = (platformMethod == 0) ? TimeStringImplementation : platformMethod;
    }
    
    int PlatformSpecificAtoI(const char*str)
    {
       return atoi(str);
    }
    
    size_t PlatformSpecificStrLen(const char* str)
    {
       return strlen(str);
    }
    
    char* PlatformSpecificStrCat(char* s1, const char* s2)
    {
       return strcat(s1, s2);
    }
    
    char* PlatformSpecificStrCpy(char* s1, const char* s2)
    {
       return strcpy(s1, s2);
    }
    
    char* PlatformSpecificStrNCpy(char* s1, const char* s2, size_t size)
    {
       return strncpy(s1, s2, size);
    }
    
    int PlatformSpecificStrCmp(const char* s1, const char* s2)
    {
       return strcmp(s1, s2);
    }
    
    int PlatformSpecificStrNCmp(const char* s1, const char* s2, size_t size)
    {
       return strncmp(s1, s2, size);
    }
    char* PlatformSpecificStrStr(const char* s1, const char* s2)
    {
       return (char*) strstr(s1, s2);
    }
    
    int PlatformSpecificVSNprintf(char *str, size_t size, const char* format, va_list args)
    {
    
        char* buf = 0;
        int sizeGuess = size;
    
        int result = vsnprintf( str, size, format, args);
        str[size-1] = 0;
        while (result == -1)
        {
            if (buf != 0)
                free(buf);
            sizeGuess += 10;
            buf = (char*)malloc(sizeGuess);
            result = vsnprintf( buf, sizeGuess, format, args);
        }
    
        if (buf != 0)
            free(buf);
        return result;
    
    }
    
    PlatformSpecificFile PlatformSpecificFOpen(const char* filename, const char* flag)
    {
       return fopen(filename, flag);
    }
    
    void PlatformSpecificFPuts(const char* str, PlatformSpecificFile file)
    {
       fputs(str, (FILE*)file);
    }
    
    void PlatformSpecificFClose(PlatformSpecificFile file)
    {
       fclose((FILE*)file);
    }
    
    void PlatformSpecificFlush()
    {
      fflush(stdout);
    }
    
    int PlatformSpecificPutchar(int c)
    {
      return putchar(c);
    }
    
    void* PlatformSpecificMalloc(size_t size)
    {
       return malloc(size);
    }
    
    void* PlatformSpecificRealloc (void* memory, size_t size)
    {
       return realloc(memory, size);
    }
    
    void PlatformSpecificFree(void* memory)
    {
       free(memory);
    }
    
    void* PlatformSpecificMemCpy(void* s1, const void* s2, size_t size)
    {
       return memcpy(s1, s2, size);
    }
    
    void* PlatformSpecificMemset(void* mem, int c, size_t size)
    {
        return memset(mem, c, size);
    }
    
    double PlatformSpecificFabs(double d)
    {
       return fabs(d);
    }
    
    int PlatformSpecificIsNan(double d)
    {
        return isnan(d);
    }
    
    int PlatformSpecificVSNprintf(char *str, unsigned int size, const char* format, void* args)
    {
        while(1);
        return 0;
       //return vsnprintf( str, size, format, (va_list) args);
    }
    
    char PlatformSpecificToLower(char c)
    {
        return tolower(c);
    }

    Помимо этого файла, во всем проекте пришлось разрешить исключения (ключом --exceptions) и выделить достаточно много памяти в стеке и в куче (килобайт по 15 примерно). Чтобы не засорять этим сборку, для тестов я создал отдельную конфигурацию — в Keil это называется "target".


    Ну и, конечно же, пришлось убрать ключ --c99, прописанный в опциях компилятора, потому что с ним Keil даже.срр-файлы пытался компилировать как сишные.


    В последних версиях Keil'a этой проблемы нет, поскольку режим С99 включается галочкой, которая действует только на файлы с расширением.с.


    Вывод результатов


    Вывод CppUTest делает просто printf'ами в терминал, но ведь у МК нет терминала. Как же быть?


    На отладочной плате можно было бы воспользоваться выводом в UART, а результаты на компе смотреть, скажем, в putty — но симулятор Keil'a далеко не для всех МК поддерживает симуляцию UART'a.


    К счастью, у многих МК на ядре Cortex есть специальный отладочный интерфейс ITM. Если пользоваться полноразмерным разъемом JTAG (или если вы пользуетесь отладчиком STLink через разъем SWD, то у этого разъема должна быть подключена нога SWO) и ваш аппаратный отладчик этот самый ITM поддерживает, то в stdout можно в него и перенаправить.


    При этом, поскольку ITM является периферией уровня ядра, симулятор Keil'a поддерживает его всегда! Вывод при этом появляется в отладчике, в окне View->Serial windows->Debug (printf).


    CppUTest printf


    Чтобы сделать это перенаправление, потребуется переопределить несколько функций стандартной библиотеки. Это я делал методом проб и ошибок, на полноту и правоту не претендую, но вроде бы работает без нареканий. Перенаправлял я только stdout, поскольку в stdin и stderr нужды не испытывал.


    retarget
    #if __CC_ARM || ( (__ARMCC_VERSION) && (__ARMCC_VERSION >= 6010050) )
    
        // armclang
        #if ( (__ARMCC_VERSION) && (__ARMCC_VERSION >= 6010050) )
    
            asm(".global __use_no_semihosting_swi\n");
    
        // armcc    
        #elif __CC_ARM
    
            #pragma import(__use_no_semihosting_swi)
    
            namespace std { struct __FILE { int handle;} ; }
    
        #endif
    
        #include <stdio.h>
        #include <rt_sys.h>
        #include <rt_misc.h>
    
        std::FILE std::__stdout;
        std::FILE std::__stdin;
        std::FILE std::__stderr;
    
        extern "C"
        { 
            int fputc(int c, FILE *f)
            {
                return ITM_SendChar(c);
            }
    
            int fgetc(FILE *f)
            {
                char ch = 0;
    
                return((int)ch);
            }
    
            int ferror(FILE *f)
            {
                /* Your implementation of ferror */
                return EOF;
            }
    
            void _ttywrch(int ch)
            {
                ITM_SendChar(ch);            
            }
    
            char *_sys_command_string(char *cmd, int len)
            {
                return NULL;
            }
    
            // вызывается после main
            void _sys_exit(int return_code) 
            {
                while(1) { __BKPT(0xAA); }
            }        
        }
    #endif

    Сами тесты


    Тут, на самом деле, никаких отличий от обычных тестов в CppUTest нету, но я все же приведу пример для наглядности


    TEST(Loader, FirmwareCrcCheck_ErrorOnCheck)
    {
        makeLoaderReadyForBlockAddress(testLoader);
    
        sendSuccessfulWriteRandomDataBlock(testLoader, test_chunk_size);
    
        mockSender.isSent = false;
    
        // теперь флешер скажет, что срс не сходится
        mockFlasher.curMode = MockFlasher::Modes::ALL_FW_ERR;
    
        uint32_t crc = 0x11223344;
        uint32_t fwEnd = mockFlasher.getFreeFlashAddrMin() + Loader_Data_Block_Size;
    
        sendFwCrcCheck(testLoader, crc, fwEnd);
    
        checkIfError();
    
        CHECK( mockFlasher.isAllFwChecked == true);
    
        // мы записывали только один блок
        CHECK( mockFlasher.fwStart == mockFlasher.getFreeFlashAddrMin() );
        CHECK( mockFlasher.fwEnd == fwEnd);
        CHECK( mockFlasher.fwCrc == crc);
    }

    Впечатления от CppUTest


    Плюсы:


    • быстро заработал в симуляторе
    • не требуется ручная регистрация тестов

    Минусы:


    • требует С++, все тесты компилируются как код на С++ (но этот минус нивелируется переходом на С++)
    • относительно много файлов в фреймворке
    • требуется динамическая память и исключения. Справедливости ради скажу, что вместо исключений можно использовать setjmp/longjmp, но уж лучше исключения!
    • поскольку исключения используются постоянно, отладка по шагам превращалась в очень увлекательное путешествие по библиотечному коду с внезапными прыжками туда-сюда
    • поскольку тесты — это методы, названия тестов должны быть валидными идентификаторами. То есть CamelCase или snaking_case, но NeitherOfThemIsVeryReadable when_test_name_is_long_enough, а названия тестов хочется делать подробными.
    • макросов для сравнения как-то уж слишком много:
      • CHECK — окей, все ясно
      • CHECK_TEXT — хорошо, проверка с поясняющим текстом
      • CHECK_FALSE — хмм, но почему бы не написать CHECK( .. == false)?
      • CHECK_EQUAL — э?
      • STRCMP_EQUAL — стоп, серьезно? Но я ведь сам могу strcmp написать...
      • STRNCMP_EQUAL — да ладно
      • LONGS_EQUAL — CppUTest остановись, ты пьян!
      • UNSIGNED_LONGS_EQUAL — аааа!!!
        Нет, я все понимаю, у специализированных макросов обычно более понятный вывод, но это уже явно перебор! Да и Catch2 как-то справляется с одним REQUIRE.
    • к тому же, мне весь этот более понятный вывод был до лампочки, поскольку я почти сразу начал практиковать TDD; в 90% случаев я заранее знал, какая именно проверка должна провалиться
    • когда тест валился, в терминал выводилось сообщение типа такого:

    src\Core\Loader\Bootloading\loader_test.cpp(815): error: Failure in TEST(Loader, SuccessfulWriteBlockWithoutErase)
            CHECK(mockFlasher.isBlockWritten != true) failed
    
    Errors (1 failures, 76 tests, 76 ran, 1942 checks, 0 ignored, 0 filtered out, 0 ms)

    и потом провалившуюся проверку приходилось отыскивать по имени теста и номеру строки


    • несколько раз тесты падали, потому что где-то в глубинах фреймворка не хватало стека или кучи, но понять это было очень сложно; неопытному мне из прошлого код казался очень запутанным

    Разумеется, почти все эти минусы совершенно субъективные. У CppUTest большая и преданная аудитория, которую, вероятно, все устраивает. Но я решил на этом не останавливаться и двинулся дальше. А дальше мне на глаза попался munit.


    Munit


    Как я уже упоминал когда-то давно в своем посте про юнит-тесты на чистом С, munit — это самый маленький фреймворк для юнит-тестирования; настолько маленький, что его можно привести прямо в тексте статьи целиком:


    #define mu_assert(message, test) do { if (!(test)) return message; } while (0)
    #define mu_run_test(test) do { char *message = test(); tests_run++; \
                                  if (message) return message; } while (0)
    extern int tests_run;

    Для меня в тот момент это было просто откровение! Никакого С++, никаких классов для тестов, произвольные строки в качестве названий и просто return 0, если все проверки пройдены! Никакой чехарды с исключениями!


    К сожалению, отсутствие С++ означало сложности с автоматической регистрацией — борьбе с этим посвящена другой пост, в которой, фактически, был рожден маленький велосипедный тестовый фреймворк на С.


    Этим фреймворком мы даже успели попользоваться какое-то время, но после относительно масштабного проекта, так же написанного на С, но в ООП стиле, мы, вдоволь нахлебавшись виртуального наследования вручную, сказали ХВАТИТ ЭТО ТЕРПЕТЬ и решили переходить на С++.


    Ну а раз вся разработка переходит на С++, то можно и тестовый фреймворк обновить и избавиться от этой мути с BOOST_PP_COUNTER.


    UmbaCppTest


    И так был рожден новый велосипедный фреймворк для юнит-тестирования! Ееее! Кратко пробегусь по ключевым фишкам:


    • автоматическая регистрация тестов (легко, когда есть конструкторы)
    • названия тестов — произвольные строки
    • не требуются исключения
    • не требуется динамическое выделение памяти
    • минимум макросов для проверок — фактически, только UMBA_CHECK( cond, text ), но text — опциональный
    • фреймворк из двух файлов (один.срр и один .h)
    • "киллер-фича": если проверка провалилась, то отладка останавливается на проблемной строке! Это тоже легко, симулятор корректно выполняет ассемблерную инструкцию BKPT; очень удобно!
    • киллер-фича опциональная, можно как обычно — просто вывести в терминал сообщение, что тест провалился и выполнять тесты дальше
    • поскольку фреймворк самодельный, его можно было легко и быстро дорабатывать по ходу дела, допиливать фичи, которые нужны только нам

    Вот он на гитхабе.


    Пример теста:


    UMBA_TEST("Check timer with no overflow - one-shot timer shall be handled")
    {
        using namespace time_service;
    
        for( uint32_t i=0; i<1000; i++ )
        {
            time_service::setCurTime_mcs( Microsec{ i*1000 } );
            time_service::setCurTime_ms( i );
    
            testLwipTask.work( i );
    
            if( mockCallbacks.flag == true )
                break;
        }
    
        UMBA_CHECK( mockCallbacks.flag == true );
    
        return 0;
    }

    А запуск всех тестов выглядит как-то так:


    int main()
    {
    
        #ifdef USE_TESTS
    
            umba::runAllTests();
    
            __BKPT(0xAA);
            while(1) {}
    
        #endif

    При этом пользователь должен создать конфигурационный файл для фреймворка, который обязан называться umba_cpp_test_config.h и определить в нем несколько вещей:


    #pragma once
    
    #include <stdint.h>
    
    // включить логирование
    #define UMBA_TEST_LOGGING_ENABLED        1
    
    // выключить подвисание на упавшем тесте
    #define UMBA_TEST_HANG_ON_FAILED_TEST_ENABLED      1
    
    #define UMBA_TEST_DISABLE_IRQ()  __disable_irq()
    
    namespace umba
    {
        // максимальное количество тестов в одной группе
        const uint32_t tests_in_group_max = 200;
    
        // максимальное количество групп тестов
        const uint32_t groups_max = 100;
    
    }

    Конечно, фреймворк очень простой, поэтому минусов у него тоже полно:


    • нет автоматической генерации моков, фаззинга и прочих классных фишек
    • проверочные макросы нельзя просто использовать во вспомогательных функциях, а не прямо внутри самого теста
    • немножечко опирается на порядок инициализации глобальных переменных. В целом, это исправить легко, но никак руки не доходят — а работать вроде и так работает -_-'

    Тесты интерфейсов связи


    Как уже было сказано вначале, одним из самых удобных и полезных для юнит-тестирования вещей являются модули связи — всякие парсеры протоколов, модули общения с датчиками и тому подобное.


    Как же их тестировать?


    Возможны варианты. Для начала, что вообще нужно от интерфейса связи коду, который им пользуется? Например, от UART'a в самом минимуме нужно не так уж много:


    • принять байт
    • отправить байт

    Принятый байт можно "скармливать" пользовательскому коду как параметр у функции, а для отправки пользоваться одним коллбэком.
    Но отправлять один байт не всегда удобно, хочется отправлять массив. Окей, не проблема, для этого тоже хватит одного коллбэка.
    Потом оказывается, что иногда нужно можно отправлять массив блокирующе (т.е. колбэк может не возвращать управление, пока не отправит массив целиком), а иногда нельзя.
    Иногда массив локальный — и тогда в неблокирующем варианте его приходится куда-то копировать — а иногда он слишком большой, чтобы его куда-то копировать, но зато статический.


    Потом вдруг выясняется, что конкретный датчик может при первом включении работать на любом из 10 разных бодрейтов...


    Короче, передавать 10 разных коллбэков по одному как-то глупо. Само собой напрашивается решение — сделать класс "обертка над UART'ом", а в нем методы. И тогда для каждого UART'a мы просто создаем экземпляр такой обертки.


    Допустим. Но как же их тогда тестировать? Нам нужно, чтобы в тестовой сборке методы делали одно, а в релизной — другое.


    По факту, нам нужен полиморфизм. И вот тут начинаются вопросы.


    • С одной стороны, для этого не нужен полиморфизм времени выполнения, т.е. можно обойтись ifdef'ами
    • С другой, это как-то не красиво; у класса фактически поменяется вся реализация от одного ifdef'a. Некрасиво.
    • С третьей, есть относительно идиоматичное решение — CRTP, которое дает полиморфизм на этапе компиляции с помощью шаблонов.
    • А с четвертой есть "обычный" полиморфизм из С++ — через виртуальное наследование.

    Отметая вариант с ifdef'ами по эстетическим соображениям, давайте посмотрим на CRTP.
    Для тех, кто не в курсе, выглядит это как-то так:


    template <typename T>
    class Amount
    {
    public:
        int getValue() const
        {
            return static_cast<T const&>(*this).getValue();
        }
    };
    
    class Constant42 : public Amount<Constant42>
    {
    public:
        int getValue() const { return 42; }
    };

    Соответственно, класс Amount тут является статическим интерфейсом, а класс Constant42 его реализует.


    К сожалению, у такого подхода есть 2 большие проблемы:


    • Amount — это не класс, это шаблон класса. Соответственно, мы не можем передать указатель на него в клиентский код. Мы вынуждены делать клиентский код шаблонным! И вот это реально проблема, потому что Keil не очень хорошо позволяет отлаживать шаблоны и прочий код в заголовочных файлах — на него иногда невозможно поставить точку останова, невозможно прошагать в отладке. И насколько мне известно, у многих IDE такая проблема есть, в той или иной степени.
    • Нельзя забывать, что мои коллеги в тот момент, когда эти вопросы в первый раз поднимались, только-только начали переползать на С++. Обычные шаблоны выглядят страшно, а ЭТО вызывало легкую панику.

    Соответственно, остается вариант с обычным полиморфизмом времени выполнения.
    Но как же так, возможно вскричали сейчас матерые разработчики? Ведь виртуальные вызовы это очень дорого, это таблица виртуальных методов, это дополнительный оверхед при вызове!


    И да, действительно, это несколько дороже, чем хотелось бы. Тем не менее:


    • таблица виртуальных методов — если этих методов не слишком много — весит пару сотен байт.
    • виртуальный вызов дороже на несколько десятков команд. Поскольку у МК обычно нет кэш-памяти, виртуальный вызов не ведет к кэш-промаху, соответственно время вызова увеличивается незначительно.

    Но самое главное, что код выглядит просто и понятно — он не требует выворачивать себе мозг каждый раз. И шаблоны не распространяются на всю кодовую базу, как чума.


    У такого подхода, как ни странно, есть менее очевидный минус. В Keil'e есть простая оптимизация, которая очень существенно сокращает размер бинарника — выбрасывание неиспользуемых функций.


    И вот она-то от наличия таблицы виртуальных методов ломается — ведь на метод есть указатель! Значит, он используется. С этим можно бороться, но как показала практика — особого смысла в этом нет.


    Если бы мы пользовались слабыми МК с небольшим количеством памяти и низкой тактовой частотой, то разговор скорее всего был бы совсем иной. Но даже весьма бюджетные модели STM вполне справляются. Да, средний размер бинарника вырос — ну… и ладно? До тех пор, пока в условия технического задания мы вписываемся — кому какая разница?


    Отмечу, что мы обычно даже в релизе не включаем оптимизацию кода — потому что так отлаживать проще, а прошивка все равно влезает! Т.е. примерно двухкратный запас по размеру кода почти всегда имеется.


    Соответственно, ответ на вопрос раздела простой (но на всякий случай я его проговорю) — для каждого интерфейса связи делаем интерфейс (каламбур не намеренный) — т.е. класс с чисто-виртуальными методами.
    Во всем пользовательском коде используем только этот интерфейс.


    В релизной прошивке в пользовательский код передаются объекты нормального класса, а в тестовом — моки (mock). Скажем, в мок для UART'a можно передать массив, который пользовательский класс потом "примет" через метод для чтения входящих байт.


    Инициализация и деинициализация


    Как известно, юнит-тесты не должны зависеть от порядка, в котором их запускают. Поэтому каждый тест должен работать с "чистым" состоянием тестируемого объекта.


    Собственно, для этого почти во всех тестовых фреймворках используются функции setup и teardown; которые инициализируют и деинициализируют объект соответственно.


    Как можно инициализировать объект?


    • В конструкторе
    • В методе init

    Казалось бы, предпочтительнее инициализация в конструкторе — ее невозможно забыть, т.е. невозможно создать объект в невалидном состоянии.


    К сожалению, в embedded иногда приходится создавать глобальные объекты — в основном, чтобы взаимодействовать с прерываниями. А выполнять какой-то сложный код в конструкторе глобального объекта чревато static initialization order fiasco, т.е. можно нарваться на зависимость от какого-то другого глобального объекта из другой единицы трансляции.


    Но на самом деле не так важно, как объект инициализировать; в функциии setup мы просто создадим объект (и может быть вызываем для него метод init). А вот как его деинициализировать?


    • В деструкторе
    • В методе deInit

    И вот тут возникает неприятность. В embedded очень многие объекты имеют бесконечное время жизни — потому что main никогда не завершается. Прошивка должна работать, пока на устройстве есть питание; а когда питания нет — уже завершаться поздно.


    Поэтому большинству объектов не нужны ни деструкторы, ни методы для деинициализации. А раз они не нужны — писать их специально для тестов как-то не очень хочется.
    При этом вполне вероятно, что объект не владеет ресурсами, которые реально нужно деинициализировать, было бы вполне достаточно просто сконструировать объект заново.


    Как сконструировать объект заново?


    • просто вызвать init еще раз
    • создать новый объект, а старый удалить

    Подход с методом init обладает неприятным моментом — таким образом не получится заново заполнить константные или ссылочные поля. А чтобы создавать и удалять объекты, нужна динамическая память — традиции ембедеров не велят мне использовать ее… Впрочем, в тестовой сборке это вполне допустимо, а иногда даже обязательно — например, CppUTest без кучи работать не хочет.


    Тем не менее, если вам тоже неприятно использовать динамическую память, есть другой способ переконструировать объект — placement new! Это относительно малоизвестная фича С++, которая, по-факту, позволяет вызывать конструктор явным образом для заранее выделенного куска памяти.


    У placement new есть свои подводные камни (скажем, не совсем понятно, как таким образом конструировать массивы), но для наших целей это не существенно — вполне достаточно создать статический объект файле с тестами, а перед каждым тестом (или после каждого теста) переконструировать его in place.


    Это удобно делать с помощью небольшой вспомогательной функции (которую, кажется, невозможно в общем виде написать без С++11):


    template< typename T, typename ... Args >
    void inplace_new( T & object, Args && ... args )
    {
        auto t = &object;
        new (t)T{ std::forward<Args>(args)... };
    }
    
    A a(1,2,3);
    
    inplace_new( a, 3,4,5 );

    У такого варианта тоже есть один неприятный момент — компилятор armcc (он же Arm Compiler 5), который все еще является компилятором по-умолчанию при создании проекта в Keil'e, поддерживает С++11 очень странно.
    Из языковых конструкций поддерживается почти все, но вот стандартная библиотека осталась от С++03. Поэтому std::forward в нем использовать не получится — только если свой писать.


    Соответственно, в Keil'e придется или использовать placement new напрямую, или перелезать на "шестую версию компилятора", которая на самом деле clang, или обходиться без forward'a. Ну или написать свой forward.


    Если у объекта все-таки есть необходимость в какой-то деинициализации, то при таком подходе нужно не забывать явно вызывать деструктор!


    Тестирование assert'ов


    На случай, если кто-то не в курсе: ассерты — это проверки условий, которые должны быть истинными; проваленный ассерт как правило означает ошибку в логике программы.
    Как правило, ассерт программу экстренно завершает с помощью вызова std::terminate, exit(1) или чего-то вроде того.


    Зачем нужны ассерты? В основном для проверок предусловий (т.е. корректности входных параметров у функций) — и постусловий (корректности результатов).


    Для встраиваемых систем возникают обычные вопросы:


    • что делать, если ассерт провалился?
    • допустимо ли оставлять ассерты в "релизной" прошивке?
    • допустимо ли их вообще писать или все ошибки должны обрабатываться?
    • какой ассерт использовать?
    • нужно ли их тестировать и, если да, то как?

    Что делать, если ассерт провалился


    Самое простое, что можно сделать — это пустой бесконечный цикл:


    while(1) {}

    Насколько я знаю, примерно так и поступают почти всегда, потому что сходу непонятно, что еще можно сделать — даже вызов std::terminate в конце концов упрется в пустой цикл, который сгенерировал компилятор.


    Пустой цикл не очень хорош по двум причинам:


    • не сразу понятно, что ассерт сработал, даже под отладкой
    • прерывания могут прерывать этот пустой цикл и продолжать что-то делать

    Обе эти проблемы решаются тривиально — прерывания в этом пустом цикле можно запретить, а отладку остановить с помощью уже известной нам инструкции BKPT.


    #define UMBA_ASSERT( statement )     do { if(! (statement) ) { __disable_irq(); while(1){ __BKPT(0xAC); if(0) break;} }  } while(0)

    Допустимо ли оставлять ассерты в "релизной" прошивке


    Тут каждый решает сам, в зависимости от сроков и серьезности проекта. Я часто оставляю, потому что:


    • лучше пусть сработает ассерт, чем прошивка продолжи выполнение с бредовыми данными
    • от зависания намертво помогает watchdog
    • далеко не всегда есть способ просигнализировать об ошибке хоть как-то

    Допустимо ли вообще писать ассерты или все ошибки должны обрабатываться


    Ответ практически повторяет предыдущий пункт, но к этому можно добавить следующую мысль.


    Если используются велосипедные библиотеки (или просто какой-то общий код), то в них ассерты становятся нужны для гарантии правильного использования.


    Какой ассерт использовать


    Ассерт времени выполнения:


    Чисто теоретически, есть заголовочный файл assert.h, но как-то я особо не видел, чтобы им в embedded пользовались.
    Вероятно, потому что реализация поведения при срабатывании ассерта будет зависимой от компилятора.


    Поэтому — опять велосипеды. Но это ведь С++, тут все привыкли, что у каждой библиотеки свои тайпдефы над стандартными типами, свой ассерт и т.д :)


    Ассерт времени компиляции:


    Тут все просто. Если у вас есть возможность использовать С++11 и выше, то есть стандартный static_assert. Если нет, то используется конструкция аля:


    #define UMBA_STATIC_ASSERT_MSG(condition, msg) typedef char umba_static_assertion_##msg[(condition)?1:-1]
    #define UMBA_STATIC_ASSERT3(X, L) UMBA_STATIC_ASSERT_MSG(X, at_line_##L)
    #define UMBA_STATIC_ASSERT2(X, L) UMBA_STATIC_ASSERT3(X, L)
    
    #define UMBA_STATIC_ASSERT(X) UMBA_STATIC_ASSERT2(X, __LINE__)

    Нужно ли тестировать ассерты (и как)


    На мой взгляд, если пишется библиотека, то нужно, потому что ассерт вполне может срабатывать и будет срабатывать достаточно часто — пока пользователь библиотеки пытается допереть, какие параметры библиотека принимает на входе.


    В пользовательском коде же ассерт срабатывать как бы и не должен и в любом случае является затычкой для проблемы, вместо ее нормальной обработки.


    Соответственно, если мы пишем библиотеку, то желательно удостовериться в корректности проверок для аргументов.
    Окей, решили что ассерты тестить нужно. Как?


    Ассерт времени компиляции


    Вроде бы такая классная штука, проверка на этапе компиляции! Но как его тестировать, если он компиляцию останавливает?


    Медитации на stackoverflow (и вот это видео Roland Bock) показали мне три пути:


    • Тесты с помощью системы сборки, скажем, в CMake можно ожидать, что файл не должен компилироваться

    Имхо способ не очень удобный. Каждый тест нужно будет пихать в отдельный файл или заворачивать в ifdef; ну и к системе сборки привязываться не очень хочется. Не говоря уже о том, что Кейл так не сумеет.


    • Вариант, который предлагает Roland Bock — с опорой на то, что decltype это unevaluated context.

    Имхо, выглядит это не очень красиво, приходится делать вспомогательный тип на каждый ассерт, перемазываться макросами. А еще этот способ не очень работает на clang'e (по крайней мере, на момент видеозаписи).


    К тому же, у Кейла (точнее, у компилятора armcc) не очень все хорошо с decltype.


    • Заменять static_assert на обычный.

    Roland этот способ отбросил сходу без особых пояснений, но я лично не вижу в нем ничего плохого (если вы видите — то расскажите, пожалуйста). Просто не используем static_assert напрямую, а прячем его за макросом. И в тестовой сборке этот макрос может делать обычный, рантаймовый assert, таким образом сводя этот вопрос к следующему.


    Не нужно никаких особых ухищрений или поддержки со стороны системы сборки, не нужно создавать кучу вспомогательных типов. Разве что можно случайно не constant expression в ассерте написать, но это быстро вскроется в релизной сборке.


    Если вы знаете какой-то еще способ, то, пожалуйста, расскажите.


    Ассерт времени выполнения


    Если оставить реализацию с бесконечным циклом, то тоже никак! Выполнение просто уйдет в этот бесконечный цикл и все.


    К счастью, исправить это достаточно легко — для тестовой конфигурации изменить макрос ассерта так, чтобы он не уводил выполнение в бесконечный цикл, а, например, бросал исключение.


    Это ведь тестовая сборка, почему бы и нет? Весь окружающий код при этом не меняется.


    И с помощью небольшого дополнительного макроса в нашем тестовом фреймворке, это исключение можно поймать и проверить:


    #if defined USE_TESTS
    
        #define UMBA_ASSERT( statement )                                               \
            do                                                                         \
            {                                                                          \
                if(! (statement) )                                                     \
                {                                                                      \
                    printf("\nUmba Assertion failed in " __FILE__ ":%d\n", __LINE__ ); \
                    throw ::umba::AssertionFailedException();                          \
                }                                                                      \
            } while(0)
    
        #include <exception>
    
        namespace umba
        {
            class AssertionFailedException : public std::exception
            { };
        }
    #endif

    Для теста нам потребуется еще один макрос, доводя таким образом суммарное количество проверяющих макросов до 4, но это все еще меньше, чем в CppUTest :)


    Ремарка про стек и кучу


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


    При этом в тестовой конфигурации вообще можно себе разрешить существенно большее потребление памяти, в том числе памяти кода.


    В Кейле размеры ОЗУ и ПЗУ задаются в настройках конфигурации проекта (Project->Options->Target), поэтому их легко сделать разными для разных конфигураций. А вот размеры стека и кучи как правило задаются в ассемблерном стартап-файле, как-то так:


    Stack_Size      EQU     0x00000400
    ...
    Heap_Size       EQU     0x00000000

    Можно, конечно, держать по два стартапа — один для релиза, а другой для тестов, но это как-то глупо. Удобнее эти строки в стартапе закомментировать, а константы передавать через опцию к ассемблеру (аналогично опции -D для компилятора) — на вкладке Project->Options->Assembler в поле Define.


    Тесты и ОСРВ


    Еще один интересный вопрос — как тестировать код, написанный под ОСРВ?


    Краткий ликбез: ОСРВ — операционные системы реального времени. В случае микроконтроллеров они как правило поставляются в исходных кодах и дают вам возможность создавать потоки выполнения на основе функций. В результате прошивка начинает выглядеть примерно как многопоточное приложение под десктоп.


    Основной затык тут следующий — в embedded потоки, как правило, никогда не завершаются. Они создаются на основе функций вида:


    void task(void * arg )
    {
        // инициализация
    
        while(1)
        {
            // работа
    
            // какой-нибудь системный вызов
        }
    }

    Опять бесконечный цикл мешает тестированию!


    Обойти это препятствие можно очевидным образом — сделать цикл небесконечным в тестовой конфигурации:


    #ifdef USE_TESTS
        uint32_t umba::decrementTaskCycleCounter();
    
        #define OS_IS_RUNNING (::umba::decrementTaskCycleCounter())
    #else
        #define OS_IS_RUNNING 1
    #endif
    
    void task(void * arg )
    {
        // инициализация
    
        while(OS_IS_RUNNING)
        {
            // работа
    
            // какой-нибудь системный вызов
        }
    }

    А во фреймворке заведем функции:


    
    typedef void (*TaskCycleCallback)(void);
    
    static uint32_t taskCycleCounter = 0;
    static TaskCycleCallback taskCycleCallback;
    
    void setTaskCycleCounter(uint32_t cnt)
    {
        taskCycleCounter = cnt;
    }
    
    uint32_t decrementTaskCycleCounter(void)
    {
        if (taskCycleCallback != nullptr)
        {
            taskCycleCallback();
        }
        uint8_t a = taskCycleCounter;
        taskCycleCounter--;
        return a;
    }
    
    void setTaskCycleCallback( TaskCycleCallback cb )
    {
        taskCycleCallback = cb;
    }

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


    Соответственно, в тесте поток можно просто вызывать, как обычную функцию.


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


    Еще пара моментов:


    • перед входом в цикл потока может быть этап инициализации; в тестах нужно каждый раз делать де-инициализацию
    • запуск тестов приходится тоже выносить в отдельный поток и запускать весь этот ужас под ОСРВ, иначе примитивы синхронизации работать не будут

    У меня не очень большой опыт использования ОСРВ, поэтому что еще тут сказать, я не знаю. Спрашивайте :)


    Тесты на ПК


    Время шло, вселенные вставали и рушились, разработчики вылуплялись из куколок, и Кейл как IDE перестал нас удовлетворять.


    Начались поиски другого решения, которые пока что привели к гибриду: код пишется в Eclipse-CDT с привязанным arm-none-eabi-gcc, но отлаживается и прошивается в Кейле. Тесты по-прежнему можно выполнять в симуляторе Кейла.


    Такой подход обладает своими плюсами и минусами, расписывание которых еще больше раздует и так неприлично большой пространный пост.


    Остановимся на главном — Eclipse, в отличие от Кейла, умеет не только кросс-компилятор вызывать.


    Это дает возможность скомпилировать тестовую сборку не только под симулятор, но и под десктоп. Зачем, спросите вы?


    Ну, тесты под десктопом будут выполняться существенно быстрее. А еще в Линуксе можно использовать Google Sanitizer'ы! Это замечательный набор инструментов, который позволяет отлавливать типичные ошибки в плюсовом коде:


    • выходы за границы массивов
    • переполнения знаковых целых
    • и прочие виды неопределенного поведения разных сортов

    К тому же, тесты не в симуляторе должны выполняться гораздо быстрее. Теоретически.


    Чтобы собрать тесты под Линукс, нужно создать тестовую конфигурацию (на этот раз в Eclipse), которая будет отличаться от обычной только компилятором и каким-нибудь специальным дефайном.


    • эта конфигурация должна быть для обычного gcc (не cross)
    • из нее нужно убрать все файлы, которые приколочены к архитектуре — например, ассемблерные стартапы
    • весь платформозависимый код, разумеется, нужно будет поифдефать
    • если мы таки хотим санитайзеры, то нужно прописать соответствующие ключи к компилятору

    -fsanitize=address -fsanitize=leak -fsanitize=undefined

    и к линкеру:


    -fsanitize=address -fsanitize=leak -fsanitize=undefined

    По желанию, можно добавить и других ключей — скажем, -fdiagnostics-color=always для цветного вывода и -ftrapv -fstack-protector-all -fstack-check для дополнительных проверок переполнения стека.


    Запуск тестов тоже придется немножко доработать — по факту, просто разрешить main'у завершаться с кодом возврата


    main
    int main(void)
    {
    
        #ifdef USE_TESTS
    
            #ifdef USE_DOCKER
                // выключаем буферизацию stdout; без этого сообщения санитайзеров
                // могут смешиваться с выводом тестов
                setvbuf(stdout, NULL, _IONBF, 0);
            #endif
    
            auto res = umba::runAllTests();
            (void)res;
    
            #ifdef USE_DOCKER
                return res;
            #else
                __BKPT(0xAB);
                while(1);
            #endif
    
        #endif

    Велика так же вероятность, что обычный gcc будет выдавать много предупреждений на микроконтроллерные библиотеки — например, на заголовочные файлы CMSIS'a, которые любят кастовать адреса к uint32_t, а не к uintptr_t — даже если вы в тестируемом коде регистры не трогаете.


    Это можно исправить, например, если подключать эти библиотеки не через -I (как это обычно делает эклипс), а через -isystem. К сожалению, для этого придется прописывать пути уже как опции компилятора, а не в диалоге для Include Paths.


    Вообще же, в репозитории есть пример, который собирается в Keil'e и в Eclipse, так что за совсем подробными подробностями — прошу туда.


    Далее, нам потребуется ПК с Линуксом. Можно, конечно, всем разработчикам поставить по виртуалке. Но если мы хотим делать красиво, то нужен отдельный сервер для автоматического запуска тестов! Ну, как у больших мальчиков, после каждого коммита.


    У нас сервер был, но — опять же, по историческим причинам — на нем стоит Windows Server 2012; со связкой Jenkins-Redmine-Gitblit.


    Но тем не менее. Что нам понадобится, чтобы запускать тесты таким образом?


    • Docker
    • Сборка Eclipce CDT, которая согласится в нем заработать
    • VirtualBox
    • Немножко воображения и костылей, чтобы все это запускалось из-под Windows
    • Еще подкрутить наш фреймворк (и код), чтобы докер не подавился

    Начнем с последнего. Из всех тестов и тестового фреймворка придется убрать явные обращения к регистрам и ассемблерные вставки. Какое счастье, что как раз обращения к регистрам-то мы и не тестировали :)


    К сожалению, в коде также нельзя будет использовать прерывания (не очень-то и хотелось) и ОСРВ. Точнее, можно, но надо изобретать еще какой-то дополнительный уровень абстракции, до которого пока что руки не дошли.


    Если писать тесты "правильно", передавать платформозависимые вещи снаружи и оборачивать их в обертки, а не хардкодить, то особых проблем переезд на другую платформу вызывать не должен.


    Фреймворк придется немного подправить, чтобы убрать явные зависимости от инструкции BKPT и запрета прерываний, вынесем это все в конфигурационный файл:


    Конфиг
    #pragma once
    
    #include <stdint.h>
    
    // включить логирование
    #define UMBA_TEST_LOGGING_ENABLED        1
    
    #ifndef USE_DOCKER
        // наименее процессорно-зависимый инклуд
        #include <cmsis_compiler.h>
    
        #define UMBA_TEST_HANG_ON_FAILED_TEST_ENABLED      1
        #define UMBA_TEST_DISABLE_IRQ()                    __disable_irq()
        #define UMBA_TEST_STOP_DEBUGGER()                  __BKPT(0xAA)
    #else
        #define UMBA_TEST_HANG_ON_FAILED_TEST_ENABLED      0
        #define UMBA_TEST_DISABLE_IRQ()
        #define UMBA_TEST_STOP_DEBUGGER()
    #endif
    
    namespace umba
    {
        // максимальное количество тестов в одной группе
        const uint32_t tests_in_group_max = 200;
    
        // максимальное количество групп тестов
        const uint32_t groups_max = 100;
    
        // тип коллбэка для одного оборота цикла в потоке ОСРВ
        typedef void (*TaskCycleCallback)(void);
    
        const char COLOR_RESET[] = "\x1b[0m";
        const char RED_FG[]     = "\x1b[31m";
        const char GREEN_FG[]   = "\x1b[32m";
        const char RED_BG[]     = "\x1b[41m";
    }
    

    Отмечу, что далее будет просто приблизительное описание того, как оно у нас сделано. Скорее всего, оно сделано неправильно, но, к сожалению, devops'ов у нас нет.
    Поэтому тут я лучше все под спойлеры спрячу :)


    В корень проекта кладется такой dockerfile:
    FROM eclipse:latest
    
    # Copy project into the container at /app/project
    COPY . /app/project
    
    # Build project
    # Replace ~~PROJECT~~ with your Eclipse project name and ~~Docker~~ with docker-friendly configuration
    RUN ./eclipse/eclipse -nosplash -application org.eclipse.cdt.managedbuilder.core.headlessbuild -import ./project/ -build ~~PROJECT~~/~~Docker~~
    
    # Run tests
    CMD ["/app/project/~~Docker~~/~~PROJECT~~"]

    А в Jenkins щедро насыпаем костыльный скрипт примерно такого содержания:
    export DOCKER_CERT_PATH="C:\Users\Jenkins\.docker\machine\machines\default"
    IS_ERROR=0
    
    # удаляем кэш докера
    taskkill -F -FI "IMAGENAME eq Vbox*" && rm -rf /c/Users/Jenkins/.docker/ 
    
    # подтягиваем изменения из репозитория с образом эклипса и скриптом настройки докера
    git -C /c/docker/ pull
    
    # настраиваем докер
    printf 'DIR=\"$( cd \"$(dirname \"$0\")\" ; pwd -P )\"\n \"/c/Program Files/Git/bin/bash.exe\" -x /c/docker/Server/start.sh 2> $DIR/__out.txt' > $WORKSPACE/_jenkins.sh &&
    /d/PSTools/psexec -accepteula -h -u Jenkins -p verysecretpassword "C:\Program Files\Git\bin\bash.exe" $WORKSPACE/_jenkins.sh &&
    
    # запускаем сборку проекта и тесты. Ошибки временно игнорируем, чтобы почистить кэш
    cd /c/Program\ Files/Docker\ Toolbox/ &&
    ./docker load < /c/docker/eclipse.tar.gz &&
    ./docker build -t=project $WORKSPACE/ &&
    ./docker run --rm --privileged project ||
    IS_ERROR=1
    
    # удаляем кэш докера
    taskkill -F -FI "IMAGENAME eq Vbox*" &&
    rm -rf /c/Users/Jenkins/.docker/ || echo
    
    exit $IS_ERROR

    К сожалению, кэш докера по невыясненным причинам приходится каждый раз очищать. Поэтому быстрее чем в симуляторе не получилось. Ну, как сказать? Сами тесты выполняются моментально :) А вот разворачивается вся эта штука минут 15. Да.


    Тем не менее, теперь благодаря связке локального гит-сервера с дженкинсом, после каждого пуша можно получить уведомление на почту, о том, что все хорошо:



    Ну или не очень хорошо, если все же санитайзер что-то обнаружил:



    А если он ничего не нашел, то еще и глубокое спокойствие — ведь теперь мы знаем, что в нашей прошивке нет неопределенного поведения!
    Ну, наверное, нет. Скорее всего ._.


    Разумеется, к этому можно далее прикручивать любые вещи — автоматическую сборку hex-файла прошивки с добавлением в архив (чтобы для прошивания платы программиста не дергали), дополнительную проверку с помощью PVS-Studio, что угодно, бесконечность не предел.


    (ремарка: я помню, что представители PVS-Studio рекомендуют встраивать проверку в обычный процесс сборки, а не выполнять ее только по праздникам; над этим мы тоже работаем :)


    Известные проблемы


    Основная проблема использования юнит-тестов вообще, с которой лично я столкнулся, это когда где-то надо немножко сменить API (потому что оно было придумано N лет назад, и накопились проблемы) — но все покрыто тестами… И прям руки опускаются при мысли, что надо 100+ тестов тоже править.


    Но в какой-то момент все же пересиливаешь себя, меняешь API и видишь, что упало-то всего тестов 10.


    Остальное — скорее проблемы "фреймворка", поэтому я их лучше под спойлер спрячу.


    Проблемы
    • BKPT не всегда останавливает выполнение "красиво"; зачастую оно останавливается где-то в дизассемблере, а не на строчке в срр-файле. Это сильно зависит от настроек компилятора, поэтому как-то в общем виде это решить не получается. К счастью, обычно достаточно немножко поскроллить вверх-вниз дизасемблер, чтобы Keil смог показать нужный срр-файл.


    • Т.к. названия функций-тестов генерируются с помощью директивы __LINE__, это название не очень-то человекочитаемое. Поэтому по call-stack'у не очень понятно, что за тест такой — doTest56.


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




    Отчасти, эти проблемы друг друга дополняют :) Не знаешь, что такое doTest56 — находишь его в outline и переходишь к нему.


    Ну и Collapse All (по хоткею Ctrl+Shift+/) тоже сильно облегчает жизнь, хотя после него придется пару раз кликнуть на плюсик. Судя по всему, использовать строковые литералы в качестве названий тестов — не всегда хорошо.



    • Поскольку ошибки в тестах — это просто ненулевые возвращаемые значения, нельзя использовать макрос UMBA_CHECK внутри вспомогательных функций, а не прямо внутри самого теста. Пока что эта проблема подкостылена с помощью дополнительных макросов — UMBA_CHECK_F и UMBA_CHECK_CALL.


    • А если вспомогательная функция — лямбда, то из нее нельзя возвращать 0 или nullptr, иначе слишком суровый вывод типов вынудит вас указывать тип возвращаемого значения явно как const char *. Для небольшого облегчения заведен макрос UMBA_TEST_OK.


    • При ловле ассертов ломается красивый вертикальный столбик из ОК'ов :(



    Личный опыт


    Один из "бета-чтецов" (которым отдельное большое спасибо) порекомендовал мне добавить акцента на полезности тестов, потому что после столь продолжительного описания всяческих проблем у читающих может возникнуть мысль — а может ну его?


    Поэтому я приведу несколько позитивных примеров, в которых, на мой взгляд, тесты очень сильно облегчили жизнь лично мне. Не знаю, честно говоря, насколько это показательно — но пусть будет.


    Примеры
    • Большой проект для военных, только отечественная элементная база. У большинства приборов срок поставки полгода, у некоторых — год. А код надо писать уже сейчас, потом время будет только на отладку. Как это делать без юнит-тестов — я даже не знаю. Просто вслепую писать и надеятся, что сходу заработает?


    • Загрузчик. Да, каждый embedded-разработчик должен написать свой загрузчик, это как обряд взросления :) В моем случае загрузчик должен поддерживать несколько разных микроконтроллеров и интерфейсов связи; но при этом основная логика прошивания остается неизменной. Поскольку эта логика покрыта тестами, багов в ней не находилось уже очень-очень давно, проблемы появляются в основном при необходимости поддержки нового микроконтроллера или когда пользователь неправильно конфигурацию выполняет.


    • Еще один проект для военных, опять "отечка", необходимость общения по протоколу заказчика. Вся "высокоуровневая" логика протокола протестирована, поэтому проблемы с коммуникацией были только из-за Ethernet на Миландре -_-'



    Подведем итоги


    Внезапно даже в embedded можно использовать юнит-тесты и для этого совершенно необязательно наличие дорогущего оборудования, ложа с гвоздями или чего-то подобного.


    При этом, разные стратегии тестирования не обязательно противоречат, а вполне могут дополнять друг друга. Так, тестирование в симуляторе, встроенному в IDE, гораздо лучше подходит для TDD, а тестирование на билд-сервере позволяет проводить более полные и/или сложные проверки.


    Разумеется, такое тестирование ни в коем случае не является всеобъемлющим! Это подход именно к юнит-тестированию отдельным программных модулей, а не всего устройства в целом — они не замена дорогущему оборудованию для изделия в целом, ложу с гвоздями для плат или приемочным испытаниям у заказчика.


    Тем не менее, по своему опыту скажу, что юнит-тесты вынуждают вас писать более модульный код и действительно вселяют уверенность в том, что код работает. Баги в ужасе мигрируют из покрытого тестами кода в отдаленные закоулки :)


    Продублирую ссылку на репозиторий


    P.S. Отдельно прошу прощения у Polaris99, которому я обещал эту статью несколько месяцев назад -_- Надеюсь, длина статьи хотя бы отчасти компенсирует долгое ожидание.

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 32

      +2
      Чудесно, спасибо, изучу самым внимательным образом!
        0
        Прочитал и не понял что конкретно тестировалось.
        Хотя бы привести пример десятка функций подвергавшихся тестированию и что они должны были делать.
        Не ясна совокупная выгода такого подхода и контекст.
          0

          Ну, скажем, есть некий датчик, с которым нужно общаться по некоему протоколу.
          Датчик физически получить на руки можно примерно через полгода, но документация на него есть.


          Значит, можно писать код и тестировать его, подсовывая на вход данные, которые составлены по документации.


          Можно было бы написать полноценный, физический имитатор датчика и подключать к нему нашу плату — а можно написать тест, который проверит конкретный кусок кода.


          Выгода — все делается программно, не нужно писать какие-то дополнительные вспомогательные программы или разрабатывать новые платы. Плюс это хорошо автоматизируется.


          Пример какой-то такой будет:


          Spoiler header
              UMBA_TEST("Task should parse answer and put distance in the register")
              {
                  ::umba::setTaskCycleCounter(1);
          
                  // делаем вид, что передача уже завершена
                  osSemaphoreGive( uartMock.m_transmitCompleteSem );
          
                  // имитируем ответ от датчика с первым адресом
                  // ибо первый запрос должен быть к нему
                  uint16_t x = presendRndAnswer(1);
          
                  run_task();
          
                  UMBA_CHECK( testTable.getReg16Val( REG_RO_LS5_1_DIST_0) == x, "Distance from answer should be in the register");
          
                  UMBA_CHECK
                  (
                      testTable.getRegVal( REG_RO_LS5_STATUS ) == Ls5Task::status_error,
                      "Common state should be error because only one sensor has answered"
                  );
          
                  return 0;
              }
          
              static void presendAnswer(uint8_t adr, uint16_t data)
              {
                  // шаблон ответа на запрос расстояния
                  static char answer[] = {"!08LR55555\r"};
                  uint8_t ansSize = sizeof(answer)-1;
          
                  // преобразуем адрес в строку
                  uint8ToAscii(adr, answer+1);
          
                      // преобразуем данные в строку
                  uint16ToAscii(data, answer+5);
          
                  // заполним очередь
                  uartMock.receiveData( answer, ansSize );
          
              }
          
              // предпослать ответ с рандомными данными
              static uint16_t presendRndAnswer(uint8_t adr)
              {
                  using common_functions::xorshiftRandomByte;
          
                  uint16_t x = xorshiftRandomByte()<<8 | xorshiftRandomByte();
          
                  presendAnswer(adr, x);
          
                  return x;
              }
          
            0
            Но это же не тестирование, а моделирование.
            Причем модель датчика может быть ошибочной.
            Скажем тоже работал с китайскими дешевыми лазерными лидарами.
            Они могут выдать непредсказуемые строки, которые неточно приведены в документации.
            Т.е. моделирование датчиков без их наличия настолько рисковано, что не вижу смысла этим заниматься.
            BLDC мотор в этом плане проще моделируется.

            И почему вам сложно просто накидать десяток наименований тестируемых функций если вы так широко применяете юнит-тестирование?
            Или это все таки не юнит-тестирование?

              +1

              Насколько я понимаю терминологию — это именно юнит-тестирование. Не интеграционное, не системное, не тестирование устройства в сборе — а тестирование отдельных программных компонентов.


              Разумеется, в тестах могут быть ошибки, документация может быть неточной и т.д. и т.п. Наличие юнит-тестов не отменяет необходимость тестирования на всех остальных уровнях.


              И почему вам сложно просто накидать десяток наименований тестируемых функций если вы так широко применяете юнит-тестирование?

              Я хотел привести пример, который был бы более-менее понятен сам по себе (и, собственно, привел его в предыдущем комментарии). Конечно, я могу сюда вставить огромный листинг, только что он вам скажет? Что вы хотите в нем увидеть?


              Скажем тоже работал с китайскими дешевыми лазерными лидарами.
              Они могут выдать непредсказуемые строки, которые неточно приведены в документации.

              В моем случае это был датчик с военной приемкой, поэтому документация все же была более-менее.


              И потом — а что делать-то? Это ведь реальная ситуация; срок поставки — полгода, через полгода времени писать уже не будет. Писать "на шару" и не тестировать никак?

            0
            Ну все же просто — либо у вас защитное программирование во все поля (и существует доказательство безопасности, например), либо вы пишете чистые функции (я не про ФП, «чистые» — в смысле «без излишеств»), а дальше отлавливаете ошибки во время отладки и эксплуатации и покрываете конкретные участки кода конкретными тестами, чтобы те же самые ошибки больше не повторялись. Кажется глупым тестировать код на ошибку, которую только что исправили, но это на самом деле нормально, это то же самое, как системная работа над risk management file. Если вы просто ошибку исправите, то даже сам факт ее существования когда-то потонет в коммитах и тикетах.
              0
              Очеь много непонятных терминов применяете. Не из embedded.
              Поэтому не вижу пока предмета обсуждения. Перечислите хотя бы вы десяток «чистых» функций.

              Но поскольку автор не привел списка функций (ибо думает что никто не занимается тем же чем и он) считаю что этого списка нет. Откуда резонное подозрение — а было ли тестирование?
              А то что он привел — есть иммитационное моделирования. Я всеми руками за иммитационное моделирование, но не в таком урезанном виде как у автора.
              Оно должно быть гораздо обширней.
              Вообще тестирование такая важная вещь что лучше его встраивать в релизный код как это и делается в отвественных системах.
                0
                ибо думает что никто не занимается тем же чем и он

                Не знаю, почему вы так решили.
                Я не привел "список функций", потому что все еще не могу понять, что конкретно вы хотите увидеть в этом списке.
                Вас не затруднит дать свое определение "тестирования"? И "имитационного моделирования"? Мне кажется у нас какое-то расхождение в терминологии.

                  0
                  Очеь много непонятных терминов применяете. Не из embedded.


                  Именно что из embedded. И что там непонятного вы увидели?

                  Перечислите хотя бы вы десяток «чистых» функций


                  Вот вам одна:
                  int CalcSomething(int a, int b)
                  {
                  return (a - b)/b;
                  }
                  


                  «Чистая» она в том смысле, что не имеет защитных проверок, например самой банальной — на равенство b нулю.

                  Вообще тестирование такая важная вещь что лучше его встраивать в релизный код как это и делается в отвественных системах


                  Я выше потому и упомянул «защитное программирование во все поля», что это другая концепция, другой способ написания программы в целом, и как раз таки работоспособность защит тем более требует проверки.
                    +1
                    Мне вот как и автору не понятно чего конкретно вы хотите. Пока, честно, кажется что просто показать что вы здесь самый умный и термины у вас самые правильные. Но предположу что это не так и попробую объяснить что знаю.

                    не понял что конкретно тестировалось

                    Модульное тестирование рассматривает тестирование модулей программы по отдельности. Тест представляет из себя функцию, которая вызывает функции тестируемого модуля и проверяет его реакцию — возвращаемые значения; вызов функций других модулей, от которых зависит тестируемый; если поддерживается языком — исключения; etc. Тестов как правило много — код тестов легко может быть объёмнее кода самого модуля. Тесты используются на этапе подготовки кода, в результирующей программе их нет.

                    пример десятка функций подвергавшихся тестированию и что они должны были делать

                    Хорошо, вот из моего текущего домашнего проекта:
                    • bool ButtonClick_IsDetected() — проверяет произошло-ли нажатие кнопки
                    • int32_t EncoderOffset_Get() — возвращает смещение энкодера
                    • ClockDisplay_SetTime(time) — устанавливает время на дисплее часов
                    • ClockDisplay_SetMode(mode) — устанавливает режим отображения для дисплея часов
                    • ClockDisplay_Periodic() — периодически вызываемая функция, вызывает смену кадра анимации
                    • Animation_ShowTime_Enter(animationParameters) — активирует анимацию отображения времени
                    • uint32_t Animation_AntiPoisoning_GetMinimalDuration() — возвращает минимальную допустимую длительность анимации перебора разрядов
                    • Animation_AntiPoisoning_Enter(animationParameters) — активирует анимацию перебора разрядов для предотвращения отравления катодов
                    • Animation_AntiPoisoning_Periodic(time) — выводит следующий кадр анимации перебора разрядов
                    • Animation_EditHours_Enter(animationParameters) — активирует анимацию редактирования часов

                    Помогло?

                    Не ясна совокупная выгода такого подхода и контекст.

                    Выгода заключается по меньшей мере в уменьшении количества неотловленных ошибок, облегчении редактирования кода (тесты покажут что после изменений не сломалось по крайней мере то что они тестируют), улучшении качества кода (более мелкие модули с меньшей связанностью, большее соответствие принципам SOLID). В численном виде выразить сложно, но попытки были — если есть много свободного времени то можно поискать и поизучать research papers по этой теме в интернете. Контекст — написание программ.

                    это же не тестирование, а моделирование

                    Нет, это не моделирование. Моделирование подразумевает описание реакции модели на все возможные ситуации в которых она может оказаться. При модульном тестировании в пределах каждого теста описывается один-единственный сценарий взаимодействия. «Если светофор переключился на красный свет, то автопилот должен затормозить»; «если светофор переключился на зелёный свет и дорога свободна, то автопилот должен начать движение» — для проверки этих утверждений вам не нужно моделировать весь светофор и окружающую его улицу.

                    моделирование датчиков без их наличия настолько рисковано, что не вижу смысла этим заниматься

                    Не моделирование датчиков, а тестирование кода который будет с ними работать. Идея в том что значительная часть информации доступна заранее, а расхождения можно будет скорректировать когда о них станет известно. Как говорят Томас и Хант в «Прагматичном рпограммисте» — качественный код это такой код, который легко поддаётся изменению.
                      0
                      Моделирование подразумевает описание реакции модели на все возможные ситуации в которых она может оказаться.

                      Я с этим не согласен.
                      Моделирование — это реализация некоторых известных вам аспектов поведения с возможностью коррекции, ради чего моделирование и устраивается.
                      Если вы «тестируете» датчик которого не держали в руках, то это моделирование датчика, а не тестирование.
                      Все возможные ситуации для реальных моделей описать нереалистично.

                      Как говорят Томас и Хант в «Прагматичном рпограммисте» — качественный код это такой код, который легко поддаётся изменению.

                      Здесь я с Томасом и Хантом полность согласен.
                      И устроенный автором процесс минимум в два раза усложняет изменения, в чем он и признался.

                      Приведенный вами список функций интересен.
                      Я тоже широко использую GUI, работаю с энкодерами и часами и много с чем.
                      Но считаю что юнит-тестировать такие функции GUI не имеет особого смысла.
                      Они тестируется в режиме Test-driven development. И оставлять рудименты в виде юнит-тестов после этого не нужно.
                      И некоторые ваши функции без аргументов.
                      Т.е. к ним неприменимо юнит-тестирование, как тут его описывают.
                      Что же за тестирование у вас в таком случае?
                        0
                        Они тестируется в режиме Test-driven development. И оставлять рудименты в виде юнит-тестов после этого не нужно.

                        Ээээ, то есть вы пишете в стиле TDD — а потом тесты удаляете? Но ведь тесты — это гарантия того, что новые изменения не сломают старый функционал.


                        Или по-вашему TDD — это когда вы что-то написали, вручную проверили — работает, все?


                        И некоторые ваши функции без аргументов.
                        Т.е. к ним неприменимо юнит-тестирование, как тут его описывают.
                        Что же за тестирование у вас в таком случае?

                        Эти функции могут опираться на какое-то глобальное состояние; не только чистые (в всмысле ФП) функции можно тестировать — хотя, конечно, проще.


                        Я, скажем, почти всегда пишу объекты (у которых есть внутреннее состояние), поэтому тестирую тоже объекты, а не функции по отдельности.


                        Моделирование — это реализация некоторых известных вам аспектов поведения с возможностью коррекции, ради чего моделирование и устраивается.
                        Если вы «тестируете» датчик которого не держали в руках, то это моделирование датчика, а не тестирование.

                        Да, разумеется, в каком-то смысле тесты — это модель датчика по его спецификации.
                        Но я тестирую не датчик, а я тестирую свой код.
                        Когда я получу датчик в руки я, разумеется, проверю работу кода с ним — и скорее всего выяснится, что я какой-то нюанс не учел. Но мой опыт показывает, что это исправляется достаточно быстро. При этом в тестах я уже смоделировал кучу ситуаций, которые в реальности получать очень долго/трудно — всякие обрывы связей в очень неудачные моменты, испорченные пакеты, мусорные данные и т.д.

                          0
                          Я с этим не согласен.

                          Ох как неловко вышло… Надо пойти всем рассказать, а то тысячи и миллионы глупцов называют юнит-моделирование юнит-тестированием и даже не догадываются! Спасибо за вашу мудрость.

                          устроенный автором процесс минимум в два раза усложняет изменения

                          Вы этот вывод сделали из того что кода больше писать? Ну да, действительно. Вот бы у меня тоже сложность программирования зависела только от скорости печати. Восхищён вами.

                          считаю что юнит-тестировать такие функции GUI не имеет особого смысла

                          Извините, немедленно удалю все тесты.

                          Они тестируется в режиме Test-driven development. И оставлять рудименты в виде юнит-тестов после этого не нужно. И некоторые ваши функции без аргументов. Т.е. к ним неприменимо юнит-тестирование, как тут его описывают. Что же за тестирование у вас в таком случае?

                          Так у вас какой-то свой, новаторский Test Driven Development, с удалением тестов после того как они становятся не нужны? И с обязательным наличием аргументов у всех функций тестируемого кода? Гениально! Нужно немедленно связаться с Кентом Беком и Джеймсом Греннингом, они оказывается все эти годы делали какую-то фигню. Невероятно!

                          А если серьёзно — вы, пожалуйста, почитайте книжек умных прежде чем утверждениями бросаться. Я понимаю что ваши вопросы появились из-за того что статью вы не поняли, но обычно тон комментариев в таких случаях вопросительный, а не поучающий всех подряд что и как правильно.
                            0
                            Я понимаю что ваши вопросы появились из-за того что статью вы не поняли, но обычно тон комментариев в таких случаях вопросительный, а не поучающий всех подряд что и как правильно.

                            Понять это видимо в вашем понимании — принять.
                            Я собственно в вопросительном тоне и пишу. Автор отвечает что я неправильно спрашиваю. Как вам это?

                            Да, мелкие тесты удаляются беспощадно.
                            Вы привели пример удивительно мелких тестов.
                            Это перерасход рабочего времени на поддержку и на рефакторинг.
                            Какие есть основания так мелко гранулировать тестирование?

                            И как у автора везде сквозит — это всего лишь административные меры для навязывания стиля остальным программистам.

                            Но к счастью в embedded есть огромное поле работы для индивидуальных программистов, где не надо бороться за свой стиль.
                            И я говорю именно о таком сценарии.

                            Как могут рухнуть такие мелкие функции если вы их сами написали, сами поддерживаете и сами рефакторите? Это что не то уже с памятью. Так тут не помогут никакие тесты.
                            Но если у вас реально сложные функции с большим количеством состояний, то юнит-тестирование предложенное здесь — детский лепет.

                            А упомянутые вами авторы занимаются проблемами командного программирования. Но есть просто огромная разница между методами командного программирования и индивидуального.
                              0
                              А упомянутые вами авторы занимаются проблемами командного программирования

                              Ага, особенно Греннинг.

                              Послушайте, я уже понял что ваше мнение относительно юнит-тестирования (и полагаю не только его) заключается в том что вы умнее всех. На фоне этого любые попытки объяснить вам тему разбиваются о ваш огромный опыт. Вы не задаёте вопросы, вы требуете объяснений «почему вы делаете так глупо, правильно же вот так». Когда вам указывают на некорректность или бессмысленность вопроса — вы реагируете в стиле «не отвечаешь — значит не можешь, шах и мат».

                              Искренне надеюсь что нам никогда не придётся работать вместе. Всего доброго.
                                0
                                Я собственно в вопросительном тоне и пишу. Автор отвечает что я неправильно спрашиваю. Как вам это?

                                Я не знаю, какое мое сообщение вы умудрились так прочесть; я пытаюсь уточнить, что же вы хотите увидеть — но на мои комментарии вы отвечать перестали… ну ладно, как вам будет угодно.


                                Я все-таки продолжу комментировать, пусть не для вас, но для других читателей :)


                                И как у автора везде сквозит — это всего лишь административные меры для навязывания стиля остальным программистам.

                                Да, отчасти это так. И в командной разработке единство стиля — это очень важно; члены команды должны иметь возможность понимать код друг друга.


                                Как могут рухнуть такие мелкие функции если вы их сами написали, сами поддерживаете и сами рефакторите? Это что не то уже с памятью. Так тут не помогут никакие тесты.
                                Но если у вас реально сложные функции с большим количеством состояний, то юнит-тестирование предложенное здесь — детский лепет.

                                Нуэмм… как бы вам объяснить?
                                Очень нежелательно писать огромные сложные функции со множеством состояний — их трудно читать, их трудно тестировать.
                                Поэтому такие функции разбиваются на отдельные более простые части, которые тестируются по отдельности.


                                А затем тестируются все целиком.


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


                                Я своей памяти не настолько доверяю, а журнал вести лень — зачем, вот же тесты есть. Зачем их удалять-то?


                                Как могут рухнуть такие мелкие функции если вы их сами написали, сами поддерживаете и сами рефакторите

                                Как раз-таки не факт, что их я поддерживать буду сам! Я же не один работаю.


                                А упомянутые вами авторы занимаются проблемами командного программирования. Но есть просто огромная разница между методами командного программирования и индивидуального.

                                Допустим. Но вы же не считаете, что в embedded не существует командной разработки?


                                К тому же, даже в индивидуальной разработке тесты — очень полезная штука. Я не знаю, может быть у вас абсолютная память, но я, глядя на свой код пятилетней давности, далеко не всегда могу понять его сходу.


                                А тесты — это как еще один слой документации, как небольшие примеры использования кода и демонстрация его поведения.

                                  0
                                  Не то что бы я специально не отвечал на ваши комментарии, но просто не так много времени имею.

                                  Я уважаю людей способных организовать продуктивную работу команды.
                                  Если вам такие тесты помогают это сделать, то я ничего возразить не могу.

                                  Я вообще не спец в организации коллективного программирования.
                                  Поэтому и очертил свою организацию работы чтобы не спорить о несоотносимых вещах.

                                  Но память — хитрый механизм. И она затачивается.
                                  Какой бы способ не применили память заточится под него.
                                  Поэтому да, история ошибок важна и я ее храню в архиве версий.
                                  На самые распространенные типа утечек памяти или пустых указателей у меня выработан рефлекс автоматической 10-и кратной самопроверки и аскетизм синтаксиса. С++ ни в коем разе — это мультипликатор забивающих память сущностей. (но в коллективном программировании — ничего против)
                                  На сложные автоматы состояний, я вообще не пишу код, а генерирую его из моделей в MATLAB-е. Тут, кстати, не поуправляешь простой функций. MATLAB запросто выдаст портянку на 100 кБ содержащую только одну функцию!
                                  И наконец для кусков коммуникационных стеков, файловых систем и прочих крупных модулей у меня всегда в релизном приложении храняться интеграционные тесты, которые запускаются из закрытого для юзеров меню.
                                  И все, места юнит-тестам нет. Есть только короткие исследовательские тесты.
                                  Если нужно они остаются в ветках хранилища версий.



                                    0

                                    Штош. У вас процесс разработки существенно отличается от нашего, поэтому вам я ничего возразить по сути не могу.
                                    Единственное, что если бы вы это описали сразу, то ваши, скорее всего, замечания не воспринимались бы так резко :)


                                    Если вы доверяете своим рефлексам — окей, могу только позавидовать. Я себе не доверяю, потому что раз за разом совершаю глупые ошибки (только вот находить их все сложнее и сложнее).
                                    Поэтому стараюсь по максимуму перекладывать проверки на инструменты — компилятор, тесты, программы для статического анализа и т.д. И по возможности привлекать коллег на код-ревью.
                                    Так что лично мне просто страшно писать в одиночку; я знаю, что буду косячить.


                                    Генерированный код тестить, конечно, дело неблагодарное; наверное, логичнее было бы тестить собственно модели в матлабе? Но как это делать (и нужно ли) — не знаю; матлабом не пользуюсь. Так что тут вам видней.

                    0

                    Бегом пробежался пока, с первого взгляда, хорошая статья получилась. Есть вопросы.


                    1. Чем моки вы создаёте?
                    2. Поддерживает ли последний рассмотренный фреймворк с++17 и шаблоны? Ну типа, variadic templates?
                      0

                      Спасибо :)


                      Моки — просто руками -_- Не дорос я до генераторов.
                      Для часто используемых вещей моки просто уже написаны.


                      Поддерживает ли последний рассмотренный фреймворк с++17 и шаблоны? Ну типа, variadic templates?

                      Последний фреймворк — всмысле мой велосипед? Ну, сам он под с++17 должен собраться (хотя я не проверял, но там зависимостей даже от С++11 нет жестких).


                      А что вы имеете в виду под поддержкой?

                        0
                        Для часто используемых вещей моки просто уже написаны.

                        Я имею ввиду, что моки все равно нужно делать практически для каждого теста свои, даже если один и тот же модуль и вроде как мок один тоже должен быть, но разные методы одного класса и тесты к ним пишут разные программисты и каждый под свой метод делает отдельный мок.
                        Есть всякие фреймворк для моков, типа CppUMock, которые позволяют часть работы упростить. Есть вообще полностью автоматизированные юнит тест фреймворки типа VectorCASTа, там про моки даже не вспоминаешь, они создаются автоматом под твой тест — но они дорогие.


                        Вообще небольшой обзор фич фреймворков есть тут:
                        https://en.m.wikipedia.org/wiki/List_of_unit_testing_frameworks


                        Последний фреймворк — всмысле мой велосипед? Ну, сам он под с++17 должен собраться (хотя я не проверял, но там зависимостей даже от С++11 нет жестких).

                        Да. :)


                        А что вы имеете в виду под поддержкой?

                        Ну не все фреймворки умеют тестировать такое. Колонка templates в ссылке, что я дал.

                          0
                          Я имею ввиду, что моки все равно нужно делать практически для каждого теста свои, даже если один и тот же модуль и вроде как мок один тоже должен быть, но разные методы одного класса и тесты к ним пишут разные программисты и каждый под свой метод делает отдельный мок.

                          Вероятно, это зависит от задач. Я лично довольно редко испытываю нужду в новых моках, уж не на каждый тест точно; под группу — ну может быть, хотя это обычно просто структура с пачкой флагов и методов для колбэков.


                          Иначе говоря, я пока не испытываю острой потребности в генерации моков (а может быть просто не осознаю ее), поэтому в эту сторону копать не стал :)


                          Ну не все фреймворки умеют тестировать такое. Колонка templates в ссылке, что я дал.

                          Боюсь, по-прежнему не понимаю, что вы имеете в виду. По ссылке буквально просто "колонка templates", без пояснений :D


                          Типа, тестить допустимые параметры у метафункций? Такое — нет; насколько я понимаю, для этого нужны или тесты на уровне системы сборки или какие-то извращения с unevaluated context или SFINAE, честно говоря особо не задумывался.

                      +1
                      Уже не занимаюсь разработкой на МК, но я бы разделил код работы с железом и основную логику
                      далее основную логику юнит тестами на хосте, железо на тестовом стенде/симуляторе
                      к тому же обычно у меня работа с железом пишется один раз и переходит из проекта в проект
                        0

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

                        0
                        Отличная статья, благодарю. Я правильно понимаю что Вы были первым на своём месте работы, кто начал применять юнит-тесты и убедил в их пользе коллег? Расскажите, пожалуйста, как Вам удалось последнее.
                          +1
                          Я правильно понимаю что Вы были первым на своём месте работы, кто начал применять юнит-тесты и убедил в их пользе коллег? Расскажите, пожалуйста, как Вам удалось последнее.

                          Не совсем так. Начался вот тот самый проект для военных, с полугодовыми сроками поставок, и мысль о необходимости тестов как-то одновременно забрела нескольким людям в головы, по совершенно рациональным соображениям. Как-то так вышло, что за практическое велосипедостроение сел я, ну и завертелось.
                          А с годами "противники" тестов потихоньку от нас уволились :)


                          Но вообще — я не могу сказать (и в посте вроде бы тоже не утверждал такого), что у нас прям все радужно, покрытие 100% и бегают TDD-шные единороги. Сейчас более-менее покрыты только наши самодельные библиотеки и отдельные куски в проектах (опять же, те, которые одновременно легко тестить и очень нужно протестить).


                          Так что увы, никакого управленческого чуда.

                          0
                          Отличная статья! Правда огромная, и с первого раза ее осилить невозможно. Впрочем все ваши предыдущие статьи и комменты по теме юнит-тестирования в МК я читал уже, так что как-нибудь на свежую голову и эту статью осилю. :)

                          Холиварить насчет необходимости юнит-тестов бесполезно, поскольку противники либо просто не в теме (и это в коллективах обычно становится проблемой амбассадора, а не противников), либо просто искренне не имеют тех проблем, о которых все пишут (причем, serious, они могут просто не допускать тех ошибок, ради которых все это нужно, либо умеют быстро их находить, а реально сложные ошибки все равно лежат в сфере интеграционного тестирования). А вот найти «серебряную пулю» и всех заставить ее использовать без оговорок было бы интересно. Кажется на эту роль только googletest претендует, но это только для плюсов, а для Си особо ничего и нет (MinUnit конечно не тестовый фреймворк, а фигня).
                            0
                            Отличная статья! Правда огромная, и с первого раза ее осилить невозможно. Впрочем все ваши предыдущие статьи и комменты по теме юнит-тестирования в МК я читал уже, так что как-нибудь на свежую голову и эту статью осилю. :)

                            Я польщен :) Статью, наверное, можно рассматривать или как тутор или как какой-нибудь справочник и не читать целиком — я просто постарался зафиксировать как можно больше "набитых шишек".


                            они могут просто не допускать тех ошибок, ради которых все это нужно, либо умеют быстро их находить, а реально сложные ошибки все равно лежат в сфере интеграционного тестирования).

                            Согласен, неоднократно наблюдал таких людей и прям не понимаю, как они это делают. Я постоянно косячу без тестов ._. Причем с каждым годом косяки по сути те же, но находить их становится все сложнее и сложнее… Очень странный эффект.


                            А вот найти «серебряную пулю» и всех заставить ее использовать без оговорок было бы интересно. Кажется на эту роль только googletest претендует, но это только для плюсов, а для Си особо ничего и нет (MinUnit конечно не тестовый фреймворк, а фигня).

                            Если бы я сейчас выбирал фреймворк и не был ограничен упоротыми компиляторами для МК, то наверное взял бы Catch2; но эт дело вкуса.


                            А MinUnit… ну, это, конечно, не полноценный фреймворк, но он очень хорош как пример ( я даже студентам показывал что-то похожее в три-четыре строчки).
                            На мой взгляд любое автоматизированное тестирование лучше, чем никакое. А в embedded, по моим наблюдениям, оно в основном никакое.

                              0
                              На мой взгляд любое автоматизированное тестирование лучше, чем никакое. А в embedded, по моим наблюдениям, оно в основном никакое.


                              По моим наблюдениям это связано во-первых с большей загруженностью эмбедеров (я об этом уже говорил не раз), а во-вторых с тем, что так или иначе реализованные элементы защитного программирования (а то ж у нас же эмбед, куда без него!), красной нитью проходящие через всю программу, де-факто дезавуируют необходимость в юнит-тестировании, по крайней мере на уровне досужих рассуждений (и конечно это обязывает автора их проверять как-то, иначе без тестов эти защиты все равно непонятно как работают). Собственно об этом вам выше Indemsys совершенно искренне пишет.
                                0

                                Пожалуй, я могу только позавидовать вашему опыту.
                                Я в основном вижу совсем другое ._.

                                  0
                                  Тут завидовать нечему. Если вы внимательно читали (и если я верно передал мысль), то дело не в том, что все кругом покрыто рантаймовыми проверками и ни один баг не прокрадется, а в том, что оно покрывается в какой-то мере, и из этого авторами делаются далекоидущие выводы и обещания. При том что, повторюсь, наличие рантаймовых проверок — как раз самый яркий повод вставить тесты этих самых проверок.
                                    0

                                    Как вам сказать… я очень часто вижу людей, которые настолько плохо понимают, что такое разработка, что даже не понимают, что не так. Они не слышали ни про контроль версий, ни про комментарии, ни про что вообще. И им нормально, они не видят проблемы. Ну типа работает же.


                                    Им пофиг, что функции на 3000 строк невозможно читать — их код просто никто никогда не читает, кроме них самих.


                                    Какое уж там защитное программирование -_-'

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое