Хабрахабр

Модульные тесты для проектов Ардуино

Что ж, обвинение вполне справедливо, окошко Монитора последовательного порта (Serial Monitor) плюс Serial.println — не самый лучший инструмент отладки. «Серьезные» разработчики встраиваемых систем (читай: стмщики) время от времени любят шпынять голозадых «ардуинщиков», у которых среда разработки, помимо всего прочего, не поддерживает даже аппаратные отладчики с точками останова и просмотром значений переменных под курсором мышки или в специальной табличке в реальном времени. Однако грамотный ардуинщик сможет с легкостью парировать атаку и поставить зарвавшегося стмщика на место в том случае, если он (ардуинщик) использует модульные тесты.

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

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

При подготовке к внедрению в проект модульных тестов следует иметь ввиду:

  • Тесты требуют дополнительного времени для написания кода (на самом деле, нет: время, потраченное на автоматические тесты, вполне сравнимо со временем, потраченным на ручную отладку того же участка, а на долгой дистанции оно еще многократно окупится), при этом код теста может превышать по размеру код тестируемого участка.
  • В покрытом тестами проекте может быть сложно проводить глобальную реорганизацию кода (рефакторинг) — особенно актуально на начальном этапе разработки, когда кодовая база и внутренний API еще не достаточно устаканились (с другой стороны, рефактор проекта, не покрытого тестами, повлечет все те же регрессии, просто вы про них не узнаете)
  • Нужно писать модули приложения так, чтобы их можно было запускать как в рамках приложения, так и внутри отдельных тестов
  • Необходимо проработать структуру и связи внутри проекта так, чтобы в нем нашлось место коду основного приложения, исполняемой прошивке основного приложения, коду тестов, исполняемой прошивке («запускальщик»/ланчер) для запуска тестов.

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

Далее рассмотрим:

  • Несколько стратегий организации рабочего пространства проекта с модульными тестами с учетом особенностей платформы Ардуино.
  • Вариант «все в одном» (и код и тесты в одном файле скетча),
  • вынесение тестов в отдельный модуль в каталоге скетча,
  • вынесение тестов в отдельный проект.
  • Запуск тестов на устройстве,
  • запуск этих же тестов на настольном компьютере без загрузки на устройство, заглушки для API Ардуино

Выбор библиотеки для модульного тестирования

Нам нужен фреймворк модульного тестирования:

  • Для Си/С++
  • Должен работать на устройствах семейства Ардуино
  • Должен работать на настольных системах
  • Люблю легковесные библиотеки (моё персональное предпочтение)

Дело в том, что для Ардуино реализованы кое-какие вызовы стандартной библиотеки libc, но далеко не все, поэтому не каждый фреймворк, работающий с libc, скомпилируется для Ардуино. Для программирования Ардуино используется язык С++ вперемешку с Си, поэтому, теоретически, пойдет любой фреймворк модульного тестирования для С++, но мы хотим запускать тесты и на настольном компьютере и на устройстве. Верно и в обратную сторону: если фреймворк сделан специально для Ардуино, то он может не заработать на настольной системе с libc.

Я просмотрел несколько фреймворков и остановился на 2х:

  • ArduinoUnit: https://github.com/mmurdoch/arduinounit. В общем, он удовлетворяет ключевым исходным требованиям: работает как на Ардуино (очевидно из названия), так и на настольных системах (см раздел «En Vitro Testing» на сайте проекта), но на беглый взгляд показался тяжеловатым и я решил посмотреть другие варианты.
  • Библиотека Sput (Sput Unit Testing Framework for C/C++) https://www.use-strict.de/sput-unit-testing/. Это библиотека легкая настолько, насколько это возможно: всего один заголовочный файл, даже без пары с исходником «.cpp» (все сделано на нескольких макросах). Однако вывод сообщений идет через std::out (что совершенно естественно для libc), который на Ардуино как раз не реализован.

И все-таки мои симпатии перевесили в пользу sput, а проблему с std::out удалось решить несколькими исправлениями.

В итоге получился проект sput-ino — порт библиотеки sput на платформу Ардуино с сохранением совместимости с настольными системами с libc

— пример однофайлового скетча с тестами
/sput-ino/examples/sput-ino-monolith/

— пример с разделением основного кода и тестов на модули
sput-ino/examples/sput-ino-modules/

— запуск тестов на настольной системе
sput-ino/example-desktop/

— пример с разделением основного кода и тестов на разные проекты — в отдельном репозитории
https://github.com/sadr0b0t/sput-ino-demo

Установим библиотеку

Просто клонируйте репозиторий git https://github.com/sadr0b0t/sput-ino.git в каталог $HOME/Arduino/libraries:

cd $HOME/Arduino/libraries/
git clone https://github.com/sadr0b0t/sput-ino.git

и перезапустите среду Ардуино IDE.

ZIP библиотеку.... Или на странице проекта github https://github.com/sadr0b0t/sput-ino/ нажмите кнопку Клонировать или скачать > Скачать ZIP (Clone or download > Download ZIP), после этого установите архив sput-ino-master.zip через меню установки библиотек Ардуино: Скетч > Подключить библиотеку > Добавить .

Примеры появятся в меню Файл > Примеры > sput-ino (File > Examples > sput-ino)

Простой вариант: однофайловый скетч с кодом и тестами

image

В простейшем случае проект (скетч) состоит из одного файла с расширением «.ino». При внедрении тестов в проект Ардуино придется учитывать некоторые особенности её сборочной системы. При сборке файл «.ino» с незначительными изменениями конвертируется в «.cpp» (подключается заголовок Arduino.h и еще кое-чего по мелочи), сгенерированный файл компилируется в прошивку.

Создаем новый скетч
sput-ino/examples/sput-ino-monolith/sput-ino-monolith.ino

добавляем какой-то полезный код:

/** * @return a плюс b */
int a_plus_b(int a, int b) { return a + b;
} /** * @return a минус b */
int a_minus_b(int a, int b) { return a - b;
} /** * Включить лампочку, если число четное * @param pin номер ножки лапмочки * @param num число * @return true, если число num четное */
bool led_on_even(int pin, int num) else { digitalWrite(pin, LOW); } return num % 2 == 0;
}

Пишем тесты с библиотекой sput (подробнее документация: http://www.use-strict.de/sput-unit-testing/tutorial.html):

#include "sput.h" /** Test a_plus_b call */
void test_a_plus_b() { sput_fail_unless(a_plus_b(2, 2) == 4, "2 + 2 == 4"); sput_fail_unless(a_plus_b(-2, 2) == 0, "-2 + 2 == 0"); // this one would pass on 32-bit controllers and would fail on 16-bit Arduino sput_fail_unless(a_plus_b(34000, 34000) == 68000, "34000 + 34000 == 68000");
} /** Test a_minus_b call */
void test_a_minus_b() { sput_fail_unless(a_minus_b(115, 6) == 109, "115 - 6 == 109"); sput_fail_unless(a_minus_b(13, 17) == -4, "13 - 17 == -4");
} /** Test test_led_on_even call */
bool test_led_on_even() { pinMode(13, OUTPUT); sput_fail_unless(led_on_even(13, 2), "num=2 => led#13 on"); // would pass on desktop, might fail or pass on difference devices // (e.g.: Arduino Due - fail, ChipKIT Uno32 - pass) sput_fail_unless(digitalRead(13) == HIGH, "num=2 => led#13 on"); sput_fail_unless(!led_on_even(13, 5), "num=5 => led#13 off"); sput_fail_unless(digitalRead(13) == LOW, "num=5 => led#13 off"); sput_fail_unless(led_on_even(13, 18), "num=18 => led#13 on"); sput_fail_unless(digitalRead(13) == HIGH, "num=18 => led#13 on");
}

Комплектуем наборы тестов (тест-сьюты).

Все тесты в одном наборе:

/** All tests in one bundle */
int mylib_test_suite() { sput_start_testing(); sput_enter_suite("a plus b"); sput_run_test(test_a_plus_b); sput_enter_suite("a minus b"); sput_run_test(test_a_minus_b); sput_enter_suite("led on even"); sput_run_test(test_led_on_even); sput_finish_testing(); return sput_get_return_value();
}

и по одному набору на каждый тест:

/** Test suite for a_plus_b call */
int mylib_test_suite_a_plus_b() { sput_start_testing(); sput_enter_suite("a plus b"); sput_run_test(test_a_plus_b); sput_finish_testing(); return sput_get_return_value();
} /** Test suite for a_minus_b call */
int mylib_test_suite_a_minus_b() { sput_start_testing(); sput_enter_suite("a minus b"); sput_run_test(test_a_minus_b); sput_finish_testing(); return sput_get_return_value();
} /** Test suite for led_on_even call */
int mylib_test_suite_led_on_even() { sput_start_testing(); sput_enter_suite("led on even"); sput_run_test(test_led_on_even); sput_finish_testing(); return sput_get_return_value();
}

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

Запускаем тесты здесь:

void run_tests() { Serial.println("#################### Start testing..."); // comment out specific test suites if firmware does not // fit to device memory // Test suite for a_plus_b call mylib_test_suite_a_plus_b(); // Test suite for a_minus_b call mylib_test_suite_a_minus_b(); // Test suite for led_on_even call mylib_test_suite_led_on_even(); // All tests in one bundle //mylib_test_suite(); Serial.println("#################### Finished testing");
}

Добавляем обычные setup/loop, запускаем тесты с run_tests в setup в самом начале, предварительно инициировав последовательный порт Serial.begin, чтобы тесты могли печатать сообщения:

void setup() { Serial.begin(9600); while (!Serial); // run tests run_tests(); // other code - kinda application business logic Serial.println("Just show that we call functions from tested lib, nothing useful here"); pinMode(13, OUTPUT); Serial.print("14+23="); Serial.println(a_plus_b(14, 23)); Serial.print("14-23="); Serial.println(a_minus_b(14, 23)); Serial.print("34000+34000="); Serial.println(a_plus_b(34000, 34000));
} void loop() { static int i = 0; led_on_even(13, i++); delay(2000);
}

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

Компилируем, загружаем на устройство, смотрим результат в окошке монитора последовательного порта (Инструменты > Монитор порта / Tools > Serial monitor)

Результат выполнения на плате ChipKIT Uno32 (клон Ардуино с 32-битным чипом PIC32):

#################### Start testing... == Entering suite #1, "a plus b" == [1:1] test_a_plus_b:#1 "2 + 2 == 4" pass
[1:2] test_a_plus_b:#2 "-2 + 2 == 0" pass
[1:3] test_a_plus_b:#3 "34000 + 34000 == 68000" pass --> 3 check(s), 3 ok, 0 failed (0.00%) ==> 3 check(s) in 1 suite(s) finished after 0.00 second(s), 3 succeeded, 0 failed (0.00%) [SUCCESS] == Entering suite #1, "a minus b" == [1:1] test_a_minus_b:#1 "115 - 6 == 109" pass
[1:2] test_a_minus_b:#2 "13 - 17 == -4" pass --> 2 check(s), 2 ok, 0 failed (0.00%) ==> 2 check(s) in 1 suite(s) finished after 0.00 second(s), 2 succeeded, 0 failed (0.00%) [SUCCESS] == Entering suite #1, "led on even" == [1:1] test_led_on_even:#1 "num=2 => led#13 on" pass
[1:2] test_led_on_even:#2 "num=2 => led#13 on" pass
[1:3] test_led_on_even:#3 "num=5 => led#13 off" pass
[1:4] test_led_on_even:#4 "num=5 => led#13 off" pass
[1:5] test_led_on_even:#5 "num=18 => led#13 on" pass
[1:6] test_led_on_even:#6 "num=18 => led#13 on" pass --> 6 check(s), 6 ok, 0 failed (0.00%) ==> 6 check(s) in 1 suite(s) finished after 0.00 second(s), 6 succeeded, 0 failed (0.00%) [SUCCESS]
#################### Finished testing
Just show that we call functions from tested lib, nothing useful here
14+23=37
14-23=-9
34000+34000=68000

запуск на обычной Arduino Uno (чип AVR 16 бит):

#################### Start testing... == Entering suite #1, "a#################### Start testing... == Entering suite #1, "a plus b" == [1:1] test_a_plus_b:#1 "2 + 2 == 4" pass
[1:2] test_a_plus_b:#2 "-2 + 2 == 0" pass
[1:3] test_a_plus_b:#3 "34000 + 34000 == 68000" FAIL
! Type: fail-unless
! Condition: a_plus_b(34000, 34000) == 68000
! Line: 14 --> 3 check(s), 2 ok, 1 failed (?%) ==> 3 check(s) in 1 suite(s) finished after ? second(s), 2 succeeded, 1 failed (?%) [FAILURE] == Entering suite #1, "a minus b" == [1:1] test_a_minus_b:#1 "115 - 6 == 109" pass
[1:2] test_a_minus_b:#2 "13 - 17 == -4" pass --> 2 check(s), 2 ok, 0 failed (?%) ==> 2 check(s) in 1 suite(s) finished after ? second(s), 2 succeeded, 0 failed (?%) [SUCCESS] == Entering suite #1, "led on even" == [1:1] test_led_on_even:#1 "num=2 => led#13 on" pass
[1:2] test_led_on_even:#2 "num=2 => led#13 on" pass
[1:3] test_led_on_even:#3 "num=5 => led#13 off" pass
[1:4] test_led_on_even:#4 "num=5 => led#13 off" pass
[1:5] test_led_on_even:#5 "num=18 => led#13 on" pass
[1:6] test_led_on_even:#6 "num=18 => led#13 on" pass --> 6 check(s), 6 ok, 0 failed (?%) ==> 6 check(s) in 1 suite(s) finished after ? second(s), 6 succeeded, 0 failed (?%) [SUCCESS]
#################### Finished testing
Just show that we call functions from tested lib, nothing useful here
14+23=37
14-23=-9
34000+34000=2464

Обратим внимание на пару моментов:

34000 + 34000 == 68000 только на 32-битном контроллере PIC32, на 16-битном AVR размер int = 2 байта, максимальное число, которое можно в него положить = 2^16-1=65536-1=65535 (в беззнаковом режиме unsigned). — На PIC32 все тесты завершились успешно, а на AVR один тест со сложением провалился. Такие особенности платформы стоит учитывать там, где они могут себя проявить, и добавлять в тесты. На 16-битном AVR происходит переполнение, а на 32-битном PIC32 (и на 64-битном десктопе с x86_64) все ок.

— Тест test_led_on_even (включить лампочку, если передано четное число) успешно проходит на обоих контроллерах, но, вообще говоря, использовать чтение digitalRead для проверки успешности записи digitalWrite на реальном железе — не самая хорошая идея.

Во-первых, digitalRead (прочитать значение GPIO в режиме ввода pinMode INPUT) совершенно не обязан выдавать значение, которое было отправлено в порт GPIO с digitalWrite в режиме вывода pinMode OUTPUT: в официальной документации на digitalRead про такое использование метода ничего не говорится, хотя на железке это и срабатывает.

Успешность прохождения теста зависит не только от тестируемого кода, но и от того, как именно реализована связка digitalWrite/digitalRead на конкретном контроллере и нет ли в ней ошибок (кстати, на Arduino UNO с AVR тест провалится, если убрать строку перевода ножки в режим вывода pinMode(13, OUTPUT), на ChipKIT Uno32 с PIC32 тест проходит в любом случае). Во-вторых, полагаясь на то, что digitalRead вернет нужное значение после вызова digitalWrite, мы встаем на скользкую дорожку тестирования не своего, но чужого кода.

Здесь мы проверяем, что digitalWriite БЫЛ ВЫЗВАН с нужными нам параметрами. Здесь мы не должны проверять, что digitalWrite ЗАПИСАЛ значение в порт GPIO так, что digitalRead смог его прочитать. При запуске тестов на реальном железе мы навряд ли сможем это сделать без построения каких-то некрасивых вспомогательных конструкций, но в режиме тестирования на настольной системе это будет легко реализовано при помощи заглушек (см ниже).

Тестируемый код и тесты в отдельные модули

image

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

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

Заголовочные файлы будут подключаться как обычно директивой #include, файлы с исходниками C/C++ будут автоматически компилироваться и собираться в единую исполняемую прошивку. Система сборки Ардуино позволяет дробить проект на модули: в каталоге со скетчем (.ino) можно размещать дополнительные заголовочные файлы (*.h), файлы с исходниками Си (.c) и C++ (.cpp). Среда разработки Arduino IDE показывает все исходные файлы проекта на вкладках.

Реорганизуем проект:
sput-ino/examples/sput-ino-modules/

Модуль с тестируемым кодом: mylib.h+mylib.cpp

Заголовочный файл — объявления функций:
sput-ino/examples/sput-ino-modules/mylib.h

#ifndef MYLIB_H
#define MYLIB_H /** * @return a плюс b */
int a_plus_b(int a, int b); /** * @return a минус b */
int a_minus_b(int a, int b); /** * Включить лампочку, если число четное * @param pin номер ножки лапмочки * @param num число * @return true, если число num четное */
bool led_on_even(int pin, int num); #endif // MYLIB_TEST_H

Если хотите здесь взаимодействовать с железом и использовать API Arduino, просто подключайте Arduino.h. Исходный код модуля.

sput-ino/examples/sput-ino-modules/mylib.cpp

#include "Arduino.h" /** * @return a плюс b */
int a_plus_b(int a, int b) { return a + b;
} /** * @return a минус b */
int a_minus_b(int a, int b) { return a - b;
} /** * Включить лампочку, если число четное * @param pin номер ножки лапмочки * @param num число * @return true, если число num четное */
bool led_on_even(int pin, int num) { if(num % 2 == 0) { digitalWrite(pin, HIGH); } else { digitalWrite(pin, LOW); } return num % 2 == 0;
}

Модуль с тестами: mylib-test.h+mylib-test.cpp

Заголовочный файл — объявления наборов тестов (тест-сьютов), сами тесты объявлять на публику не обязательно:
sput-ino/examples/sput-ino-modules/mylib-test.h

#ifndef MYLIB_TEST_H
#define MYLIB_TEST_H /** Test suite for a_plus_b call */
int mylib_test_suite_a_plus_b(); /** Test suite for a_minus_b call */
int mylib_test_suite_a_minus_b(); /** Test suite for led_on_even call */
int mylib_test_suite_led_on_even(); /** All tests in one bundle */
int mylib_test_suite(); #endif // MYLIB_TEST_H

Тесты и наборы тестов: все тоже без изменений, только теперь подключаем mylib.h и Arduino.h вручную.

sput-ino/examples/sput-ino-modules/mylib-test.cpp

// http://www.use-strict.de/sput-unit-testing/tutorial.html
#include "sput.h" #include "Arduino.h"
#include "mylib.h" /** Test a_plus_b call */
void test_a_plus_b() { sput_fail_unless(a_plus_b(2, 2) == 4, "2 + 2 == 4"); sput_fail_unless(a_plus_b(-2, 2) == 0, "-2 + 2 == 0"); // this one would pass on 32-bit controllers and desktop (libc) and would fail on 16-bit Arduino sput_fail_unless(a_plus_b(34000, 34000) == 68000, "34000 + 34000 == 68000");
} /** Test a_minus_b call */
void test_a_minus_b() { sput_fail_unless(a_minus_b(115, 6) == 109, "115 - 6 == 109"); sput_fail_unless(a_minus_b(13, 17) == -4, "13 - 17 == -4");
} /** Test test_led_on_even call */
bool test_led_on_even() { pinMode(13, OUTPUT); sput_fail_unless(led_on_even(13, 2), "num=2 => led#13 on"); // would pass on desktop, might fail or pass on difference devices // (e.g.: Arduino Due - fail, ChipKIT Uno32 - pass) sput_fail_unless(digitalRead(13) == HIGH, "num=2 => led#13 on"); sput_fail_unless(!led_on_even(13, 5), "num=5 => led#13 off"); sput_fail_unless(digitalRead(13) == LOW, "num=5 => led#13 off"); sput_fail_unless(led_on_even(13, 18), "num=18 => led#13 on"); sput_fail_unless(digitalRead(13) == HIGH, "num=18 => led#13 on");
} /*******************************************/
// test suites /** Test suite for a_plus_b call */
int mylib_test_suite_a_plus_b() { sput_start_testing(); sput_enter_suite("a plus b"); sput_run_test(test_a_plus_b); sput_finish_testing(); return sput_get_return_value();
} /** Test suite for a_minus_b call */
int mylib_test_suite_a_minus_b() { sput_start_testing(); sput_enter_suite("a minus b"); sput_run_test(test_a_minus_b); sput_finish_testing(); return sput_get_return_value();
} /** Test suite for led_on_even call */
int mylib_test_suite_led_on_even() { sput_start_testing(); sput_enter_suite("led on even"); sput_run_test(test_led_on_even); sput_finish_testing(); return sput_get_return_value();
} /** All tests in one bundle */
int mylib_test_suite() { sput_start_testing(); sput_enter_suite("a plus b"); sput_run_test(test_a_plus_b); sput_enter_suite("a minus b"); sput_run_test(test_a_minus_b); sput_enter_suite("led on even"); sput_run_test(test_led_on_even); sput_finish_testing(); return sput_get_return_value();
}

Главный скетч для исполняемой прошивки: здесь остались только обращения к модулю приложения mylib.h и модулю с тестами mylib-test.h.

sput-ino/examples/sput-ino-modules/sput-ino-modules.ino

#include "mylib.h"
#include "mylib-test.h" /** run tests on device */
void run_tests() { Serial.println("#################### Start testing..."); // comment out specific test suites if firmware does not // fit to device memory // Test suite for a_plus_b call mylib_test_suite_a_plus_b(); // Test suite for a_minus_b call mylib_test_suite_a_minus_b(); // Test suite for led_on_even call mylib_test_suite_led_on_even(); // All tests in one bundle //mylib_test_suite(); Serial.println("#################### Finished testing");
} void setup() { Serial.begin(9600); while (!Serial); // run tests run_tests(); // other code - kinda application business logic Serial.println("Just show that we call functions from tested lib, nothing useful here"); pinMode(13, OUTPUT); Serial.print("14+23="); Serial.println(a_plus_b(14, 23)); Serial.print("14-23="); Serial.println(a_minus_b(14, 23)); Serial.print("34000+34000="); Serial.println(a_plus_b(34000, 34000));
} void loop() { static int i = 0; led_on_even(13, i++); delay(2000);
}

Прошиваем, открываем монитор последовательного порта, результат идентичен предыдущему.

Итого, структура проекта:
sput-ino-modules/

— исполняемая прошивка для основного приложения и тестов:
sput-ino-modules/sput-ino-modules.ino

— тестируемый код:
sput-ino-modules/mylib.h
sput-ino-modules/mylib.cpp

— код тестов:
sput-ino-modules/mylib-test.h
sput-ino-modules/mylib-test.cpp

Однако не всем может понравится, что тесты и исходники хранятся вперемешку в одном и том же каталоге, а так же то, что для переключения режимов тест/приложение нужно что-то комментировать/раскомментировать в одной и той же исполняемой прошивке, поэтому В целом, с такими установками уже можно жить вполне комфортно.

Выносим тесты в отдельный проект

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

  • В простейшем случае проект состоит из одного файла с расширением «.ino» (скетч), который должен храниться в каталоге с таким же именем (например: «myproj1/myproj1.ino»).
  • В этом же каталоге могут находиться другие исходники — заголовочные файлы «.h», модули на Си «.c», модули на С++ «.cpp», но не другие файлы «.ino».
  • В начале процедуры компиляции все содержимое каталога проекта копируется в другой временный каталог (что-то вроде /tmp/build2b91b1aecd83593cdd811791fcf30e97.tmp/), там файл «.ino» превращается в «.cpp», потом все файлы «.cpp» и «.c» компилятор gcc превращает в объектные файлы «.o», потом все объектные файлы «.o» линкер превращает в единый файл с исполняемой прошивкой «.hex» и (если был выбран вариант «скомпилировать и прошить») программный программатор avrdude отправляет её на устройство (совет: откройте меню Файл > Настройки, включите галочки Показывать подробный вывод для компиляции и загрузки).
  • Общие библиотеки устанавливают в каталог $HOME/Arduino/libraries/ — они будут доступны при компиляции и сборке любых проектов на этом компьютере.

Итого, имеем:

  • Один проект Ардуино может содержать только один исполняемый файл «.ino». Если мы хотим иметь два разных исполняемых файла «.ino», нам нужно сделать два разных проекта в разных каталогах файловой системы.
  • Мы можем разбивать исходный код на модули и подключать их один к другому с помощью директивы #include (например: #include "mylib.h") внутри каталога одного проекта.
  • Мы НЕ можем из одного проекта напрямую ссылаться на модули из других проектов через относительные ссылки, полагаясь на взаимное положение проектов в файловой системе (например: #include "../proj2/proj2lib.h"), т.к. перед сборкой каждый из проектов будет скопирован во временный каталог и эти связи будут нарушены.
  • Даже если мы решим подключить заголовочные файлы «.h» второго проекта не через относительные, а абсолютные ссылки (а мы это делать, конечно, не будем), система сборки все равно не подцепит исходные файлы «.cpp» и «.c», так тоже не получится.
  • Если мы хотим сделать так, чтобы модули одного нашего проекта были доступны для использования внутри другого нашего проекта, мы должны оформить первый проект в виде библиотеки Ардуино.

Значит, теперь такой план:

  • Конвертировать исходный проект в библиотеку Ардуино и разместить её в $HOME/Arduino/libraries/
  • Вынести тесты в отдельный проект, который будет обращаться к исходному проекту как к общедоступной библиотеке

Пример такого проекта (его можно использовать, как шаблон для ваших новых проектов) я вынес в отдельный репозиторий:
https://github.com/sadr0b0t/sput-ino-demo

Скачайте демо-проект себе на компьютер.

Первым делом в каталоге $HOME/Arduino/libraries нужно создать символьную ссылку на каталог проекта

cd $HOME/Arduino/libraries/
ln -s /path/to/projects/dir/sput-ino-demo

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

получаем:

$HOME/Arduino/libraries/sput-ino-demo/

Структура этого проекта — структура библиотеки Ардуино.

здесь у нас исходники библиотеки — заголовочные файлы и код Си/С++:
sput-ino-demo/src/
sput-ino-demo/src/mylib.h
sput-ino-demo/src/mylib.cpp

Мы сможем подключать заголовочные файлы этой библиотеки из любого проекта Ардуино на текущем компьютере обычным:

#include "mylib.h"

Но чтобы это работало, в корень библиотеки нужно положить еще файл с информацией о библиотеке library.properties:
sput-ino-demo/library.properties

name=sput-ino-demo
version=0.0.1
author=sadr0b0t
maintainer=sadr0b0t
sentence=Demo project for sput-ino, Sput unit testing framework for C/C++ port to Arduino
paragraph=Demo project for sput-ino. Sput is an unit testing framework for C/C++ that focuses on simplicity of use and maximum portability. It is implemented as a single ANSI C compliant header file that provides all macros needed to start unit testing in nearly no time.
category=Other
url=https://github.com/sadr0b0t/sput-ino-demo
architectures=*

Они так же будут подключаться/компилироваться с прошивками ссылающихся на них проектов, но мы так делать не будем, т.к. (Кстати, можно обойтись без library.properties, если положить все исходники .h, .c, .cpp не в src/, а в корень библиотеки sput-ino-demo/. с src/, конечно, аккуратнее.)

Главный проект — скетч, теперь опять однофайловый:
sput-ino-demo/sput-ino-demo/sput-ino-demo.ino

Чтобы открыть скетч для редактирования, воспользуйтесь обычным Файл > Открыть и найдите его в файловой системе. Кстати-2, после установки проекта-библиотеки и перезапуска среды Ардуино этот скетч появится в меню Файл > Примеры > sput-ino-demo/sput-ino-demo, но он оттуда откроется только для чтения.

они находятся за пределами каталога скетча sput-ino-demo/), вам придется редактировать их в вашем любимом текстовом редакторе. Кстати-3, файлы проекта-библиотеки mylib.h и mylib.cpp теперь не будут появляться в окне среды Arduino IDE (т.к. Придется это принять как данность, кому к сожалению, а кому и к счастью.

Кстати-4, теперь у вас в проекте может быть более одного скетча «.ino».

Итак, с библиотекой и запускаемым скетчем разобрались, теперь к тестам.

Тесты мы разместим теперь в отдельном каталоге:
sput-ino-demo/test/

Запускаемый скетч для Ардуино и сами тесты:
sput-ino-demo/test/mylib-test-arduino/
sput-ino-demo/test/mylib-test-arduino/mylib-test-arduino.ino
sput-ino-demo/test/mylib-test-arduino/mylib-test.h
sput-ino-demo/test/mylib-test-arduino/mylib-test.cpp

Для настольной системы:
sput-ino-demo/test/mylib-test-desktop/

Тесты для настольной системы обсуждаем далее.

Запуск тестов на настольном компьютере

Теперь посмотрим, получится ли запустить эти же тесты на настольном компьютере. Итак, с запуском тестов на устройстве в целом разобрались. Во-первых, это удобно и быстро: поменяли в исходниках пару строк, быстро пересобрали, запустили тесты, здесь же в консольке посмотрели результат; в случае с устройством одна процедура прошивки может занять больше времени, чем все описанные выше действия. Для чего вообще запускать тесты на настольном компьютере? Так же существует мнение, что запускать тесты на микроконтроллерах вообще не правильно, а правильно их запускать только на настольных системах. Во-вторых, некоторые ситуации, которые можно легко отработать в настольной симуляции (или, точнее, на макете, mock), на железке будет воспроизвести проблематичнее (например, отработать получение значения с одного или нескольких датчиков, отследить правильность ответной реакции).

В общем, мы хотим:

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

Как было сказано в начале статьи, библиотека sput-ino по этому условию проходит: исходная библиотека sput работает на настольных системах с libc, sput-ino — порт библиотеки sput на платформу Ардуино с полным сохранением совместимости API, а также с поддержкой обеих платформ в одной библиотеке. Для того, чтобы решить эту задачу, во-первых, у нас должна быть библиотека для модульного тестирования, которая запустится одновременно и на железке с Ардуино и на настольной системе. Короче, тесты, использующие библиотеку sput-ino, можно компилировать как для настольных систем с libc, так и для платформы Ардуино.

Далее, условно разделим исходники на две части:

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

Части приложения НЕ используют API Ардуино

Скорее всего это какие-то математические, алгоритмические или структурные блоки. В первом случае (у нас это a_plus_b и a_minis_b) всё ясно — это части приложения, написанные на чистом Си/С++. Однако даже с ними не стоит забывать о различиях между платформами (выше мы уже рассмотрели случай с тестом, провалившимся из-за переполнения int на 16-битном чипе AVR, когда на 32-битном PIC32 и 64-битном настольном Intel/AMD все проходит). Как они компилировались и запускались на Ардуино, точно так же они скомпилируются и запустятся с тестами на настольной системе без дополнительных телодвижений. Такие отличия стоит учитывать при написании тестов и время от времени гонять тесты на целевом устройстве.

Части приложения используют API Ардуино

Допустим, мы хотим протестировать функцию, которая помимо других действий обращается к железу контроллера через родные ардуинные digitalRead или digitalWrite. Во втором случае (у нас это led_on_even) ситуация кажется еще интереснее. Что делать? Совершенно очевидно, что никаких digitalRead и digitalWrite в стандартных библиотеках libc на настольной системе нет, этот блок приложения просто так не скомпилируется, тем более не запустится (и где у ноутбука пины GPIO?). Компилировать исходники Ардуино под x86? Неужели искать эмулятор или симулятор плат Ардуино и каким-то образом тащить все это счастье к себе в проект? Писать симулятор чипа AVR со всей его внутренней регистровой кухней и драйверами самому?

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

Нет, реализация заглушки не будет иметь никакого отношения к дереву исходников или к железу оригинальной Ардуино. Да, для каждого используемого вызова API Ардуино мы добавляем в проект собственную заглушку: объявляем функцию с таким же именем и сигнатурой (тип возвращаемого значения и аргументы), добавляем ей собственную реализацию. В некоторых случаях заглушка может представлять пустую функцию вообще без кода.

sput-ino/example-desktop/

Вот заголовок фейкового Arduino.h
sput-ino/example-desktop/Arduino.h

#ifndef WPROGRAM_H
#define WPROGRAM_H #define OUTPUT 1
#define INPUT 0 #define HIGH 1
#define LOW 0 unsigned long micros(); void pinMode(int pin, int mode); void digitalWrite(int pin, int val); int digitalRead(int pin); #endif // WPROGRAM_H

а вот реализация заглушек Arduino.cpp:
sput-ino/example-desktop/Arduino.cpp

// saved values for pins
static int _pin_modes[64];
static int _pin_values[64]; // from Arduino.h /** * micros stub */
unsigned long micros() { return 0;
} /** * Set GPIO pin mode */
void pinMode(int pin, int mode) { _pin_modes[pin] = mode;
} /** * Write GPIO pin value */
void digitalWrite(int pin, int val) { _pin_values[pin] = val;
} /** * Read GPIO pin value */
int digitalRead(int pin) { return _pin_values[pin];
}

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

Добавляем главный исполняемый файл с main:
sput-ino/example-desktop/mylib-test-main.cpp В общем, этого уже достаточно, чтобы скомпилировать и запустить наши тесты на настольном компьютере.

#include "mylib-test.h" int main() { return mylib_test_suite();
}

собираем
sput-ino/example-desktop/build.sh

#!/bin/sh
# simple build script, feel free to modify or convert it
# to your favourite build system config #gcc -c c_file_stub.c
#g++ -std=c++11 -c cpp_file_stub.cpp g++ -std=c++11 -c \ -I. -I../examples/sput-ino-modules -I$HOME/Arduino/libraries/sput-ino/src \ Arduino.cpp \ ../examples/sput-ino-modules/mylib.cpp \ ../examples/sput-ino-modules/mylib-test.cpp \ mylib-test-main.cpp
g++ *.o -o test_mylib

(видим тесты из модульной версии проекта Ардуино)

запускаем

./test_mylib

здесь же в консольке:

== Entering suite #1, "a plus b" == [1:1] test_a_plus_b:#1 "2 + 2 == 4" pass
[1:2] test_a_plus_b:#2 "-2 + 2 == 0" pass
[1:3] test_a_plus_b:#3 "34000 + 34000 == 68000" pass --> 3 check(s), 3 ok, 0 failed (0.00%) == Entering suite #2, "a minus b" == [2:1] test_a_minus_b:#1 "115 - 6 == 109" pass
[2:2] test_a_minus_b:#2 "13 - 17 == -4" pass --> 2 check(s), 2 ok, 0 failed (0.00%) == Entering suite #3, "led on even" == [3:1] test_led_on_even:#1 "num=2 => led#13 on" pass
[3:2] test_led_on_even:#2 "num=2 => led#13 on" pass
[3:3] test_led_on_even:#3 "num=5 => led#13 off" pass
[3:4] test_led_on_even:#4 "num=5 => led#13 off" pass
[3:5] test_led_on_even:#5 "num=18 => led#13 on" pass
[3:6] test_led_on_even:#6 "num=18 => led#13 on" pass --> 6 check(s), 6 ok, 0 failed (0.00%) ==> 11 check(s) in 3 suite(s) finished after 0.00 second(s), 11 succeeded, 0 failed (0.00%) [SUCCESS]

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

Расширение API макета; тесты, которые получится запускать только на настольной системе

Мы проверяем, что digitalWriite БЫЛ ВЫЗВАН с нужными нам параметрами. Выше мы отметили, что мы не должны проверять, что digitalWrite ЗАПИСАЛ значение в порт GPIO так, что digitalRead смог его прочитать. Да, если говорить конкретно про пару digitalWrite/digitalRead, еще можно как-то рассуждать о целесообразности такого желания (ведь при запуске тестов на настольной системе digitalRead все равно является заглушкой и мы можем вставлять в нее любой удовлетворяющий нас код), но мы вполне можем захотеть проверить обращения и к другим вызовам API Ардуино, у которых нет даже такой пары (например, pinMode). Другими словами, мы хотим проверить, что digitalWrite был вызван с определенными параметрами, но мы не хотим использовать для этого digitalRead.

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

Для порядка объявим дополнительные вызовы для макета в отдельном заголовочном файле, я назвал его _Arduino.h (в начале нижнее подчеркивание):
sput-ino/example-desktop/_Arduino.h

#ifndef _ARDUINO_H
#define _ARDUINO_H // Additional calls to get extended info from Arduino mocks /** Get pin mode */
int _get_pin_mode(int pin); /** Get pin value */
int _get_pin_value(int pin); #endif // _ARDUINO_H

добавим реализацию в Arduino.cpp:
sput-ino/example-desktop/Arduino.cpp

// From _Arduino.h
// Calls to get extended info from Arduino mocks /** Get pin mode */
int _get_pin_mode(int pin) { return _pin_modes[pin];
} /** Get pin value */
int _get_pin_value(int pin) { return _pin_values[pin];
}

Как видим, реализация _get_pin_value идентична заглушке для digitalRead, но _get_pin_mode уже не имеет прямого аналога в API Ардуино.

Этот тест уже не скомпилируется и не запустится на устройстве, поэтому мы его размещаем в отдельном модуле за пределами проекта Ардуино — в каталоге с исходными файлами для тестирования на настольном компьютере sput-ino/example-desktop/ Далее пишем новую версию теста test_led_on_eventest_led_on_even_desktoponly, использующую новый вызов _get_pin_value вместо digitalRead.

заголовочный файл с наборами тестов:
sput-ino/example-desktop/mylib-test-desktoponly.h

#ifndef MYLIB_TEST_DESKTOPONLY_H
#define MYLIB_TEST_DESKTOPONLY_H /** Test suite for led_on_even call */
int mylib_test_suite_led_on_even_desktoponly(); /** Desktop-only tests in one bundle */
int mylib_test_suite_desktoponly(); #endif // MYLIB_TEST_DESKTOPONLY_H

Код теста:
sput-ino/example-desktop/mylib-test-desktoponly.cpp

// http://www.use-strict.de/sput-unit-testing/tutorial.html
#include "sput.h" #include "_Arduino.h"
#include "Arduino.h"
#include "mylib.h" /** Test test_led_on_even call */
bool test_led_on_even_desktoponly() { // we do not use Arduino API calls here to get info about // moked chip state, use calls from _Arduino.h instead sput_fail_unless(led_on_even(13, 2), "num=2 => led#13 on"); sput_fail_unless(_get_pin_value(13) == HIGH, "num=2 => led#13 on"); sput_fail_unless(!led_on_even(13, 5), "num=5 => led#13 off"); sput_fail_unless(_get_pin_value(13) == LOW, "num=5 => led#13 off"); sput_fail_unless(led_on_even(13, 18), "num=18 => led#13 on"); sput_fail_unless(_get_pin_value(13) == HIGH, "num=18 => led#13 on");
} /*******************************************/
// test suites /** Test suite for led_on_even call */
int mylib_test_suite_led_on_even_desktoponly() { sput_start_testing(); sput_enter_suite("led on even (only desktop)"); sput_run_test(test_led_on_even_desktoponly); sput_finish_testing(); return sput_get_return_value();
} /** All tests in one bundle */
int mylib_test_suite_desktoponly() { sput_start_testing(); sput_enter_suite("led on even (only desktop)"); sput_run_test(test_led_on_even_desktoponly); sput_finish_testing(); return sput_get_return_value();
}

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

sput-ino/example-desktop/mylib-test-main.cpp

#include "mylib-test.h"
#include "mylib-test-desktoponly.h" int main() { return mylib_test_suite() | mylib_test_suite_desktoponly();
}

чуть правим сборочный скрипт (добавляем mylib-test-desktoponly.cpp)

#!/bin/sh
# simple build script, feel free to modify or convert it
# to your favourite build system config #gcc -c c_file_stub.c
#g++ -std=c++11 -c cpp_file_stub.cpp g++ -std=c++11 -c \ -I. -I../examples/sput-ino-modules -I$HOME/Arduino/libraries/sput-ino/src \ Arduino.cpp \ ../examples/sput-ino-modules/mylib.cpp \ ../examples/sput-ino-modules/mylib-test.cpp \ mylib-test-desktoponly.cpp \ mylib-test-main.cpp
g++ *.o -o test_mylib

собираем

./build.sh

запускаем

./test_mylib

== Entering suite #1, "a plus b" == [1:1] test_a_plus_b:#1 "2 + 2 == 4" pass
[1:2] test_a_plus_b:#2 "-2 + 2 == 0" pass
[1:3] test_a_plus_b:#3 "34000 + 34000 == 68000" pass --> 3 check(s), 3 ok, 0 failed (0.00%) == Entering suite #2, "a minus b" == [2:1] test_a_minus_b:#1 "115 - 6 == 109" pass
[2:2] test_a_minus_b:#2 "13 - 17 == -4" pass --> 2 check(s), 2 ok, 0 failed (0.00%) == Entering suite #3, "led on even" == [3:1] test_led_on_even:#1 "num=2 => led#13 on" pass
[3:2] test_led_on_even:#2 "num=2 => led#13 on" pass
[3:3] test_led_on_even:#3 "num=5 => led#13 off" pass
[3:4] test_led_on_even:#4 "num=5 => led#13 off" pass
[3:5] test_led_on_even:#5 "num=18 => led#13 on" pass
[3:6] test_led_on_even:#6 "num=18 => led#13 on" pass --> 6 check(s), 6 ok, 0 failed (0.00%) ==> 11 check(s) in 3 suite(s) finished after 0.00 second(s), 11 succeeded, 0 failed (0.00%) [SUCCESS] == Entering suite #1, "led on even (only desktop)" == [1:1] test_led_on_even_desktoponly:#1 "num=2 => led#13 on" pass
[1:2] test_led_on_even_desktoponly:#2 "num=2 => led#13 on" pass
[1:3] test_led_on_even_desktoponly:#3 "num=5 => led#13 off" pass
[1:4] test_led_on_even_desktoponly:#4 "num=5 => led#13 off" pass
[1:5] test_led_on_even_desktoponly:#5 "num=18 => led#13 on" pass
[1:6] test_led_on_even_desktoponly:#6 "num=18 => led#13 on" pass --> 6 check(s), 6 ok, 0 failed (0.00%) ==> 6 check(s) in 1 suite(s) finished after 0.00 second(s), 6 succeeded, 0 failed (0.00%) [SUCCESS]

Ну и на десерт

Хороший пример: потестируем обработчик прерываний

Допустим, у нас есть небольшой проект с модулем управления шаговым мотором:

  • Мотор шагает на фронте HIGH > LOW,
  • модуль проверяет выход за границы с концевых датчиков и
  • программно считает сделанные шаги.

Один шаг — 3 тика таймера: тик 1 — проверяем границы (концевые датчики), тик 2 — взводим ножку STEP в HIGH, тик 3 — делаем шаг: сбрасываем STEP в LOW, увеличиваем счетчик. Мотор шагает в фоне по сигналам из программного обработчика прерываний от таймера, несколько тысяч (или десятков тысяч) раз в секунду.

Код управления мотором может выглядеть примерно так:

#define ACTION_STOP 0
#define ACTION_CHECK_BOUNDS 1
#define ACTION_GO_HIGH 2
#define ACTION_STEP 3 int step_count = 0;
int action = ACTION_STOP; void timer_handle_interrupts() { // мы можем пропустить несколько вызовов, // чтобы двигаться с нужной скоростью if(!timeForStep()) return; // время делать шаг if(ACTION_CHECK_BOUNDS == action) { // проверяем границы - концевые датчики if(checkBounds()) { // сработал концевик - останавливаемся action = ACTION_STOP; } else { // все ок, на следующий тик готовим шаг action = ACTION_GO_HIGH; } } else if(ACTION_GO_HIGH == action) { // взводим ножку STEP, шаг на следующий тик таймера digitalWrite(STEP_PIN, HIGH); action = ACTION_STEP; } else if(ACTION_STEP == action) { // шагаем digitalWrite(STEP_PIN, LOW); step_count++; if(step_count < max_steps) { // готовим новый шаг action = ACTION_CHECK_BOUNDS; } else { // нашагались action = ACTION_STOP; } }
}

Вызов timer_handle_interrupts — обработчик прерывания от таймера, вызывается на каждый тик таймера определенное заранее количество раз в секунду (как запустить таймер на Ардуино: arduino-timer-api).

Подключение электроники в порядке, проверено на простых тестах, проблема явно в программе. Теперь представьте, что код загружен на контроллер, мотор подключен, крутится, но что-то не в порядке: может вращается слишком быстро, может не докручивает часть предполагаемого пути, может что-то еще. Допустим, у вас есть полноценный аппаратный отладчик с просмотром памяти и переменных, точками останова и красивой поддержкой в IDE. Как бы вы стали отлавливать ошибку? Ставить точку останова с динамическим условием в надежде поймать проблему в середине цикла? Будем ставить брейкпоинт в обработчик прерывания и проверять значения переменных все 100500 тиков? Возможно какой-то из этих или других приемов поможет отловить и исправить проблему.

Но посмотрим, как будет выглядеть процедура отладки этого участка при помощи автоматических тестов:

void test_timer_handle_interrupts() { // тик 1 test_timer_handle_interrupts(); // проверка 1 // проверка 2 // проверка 3 // тик 2 test_timer_handle_interrupts(); // проверка 1 // проверка 2 // проверка 3 // тик 3 test_timer_handle_interrupts(); // проверка 1 // проверка 2 // проверка 3 // тик 100500 for(long i = 0; i < 100500 - 3; i++) { } // проверка 1 // проверка 2 // проверка 3 // тик 100500+1 test_timer_handle_interrupts(); // проверка 1 // проверка 2 // проверка 3 // ... // и так далее
}

Как видим, таким образом можно легко контролировать каждый тик: 1й, 2й, 3й, 103й, предпоследний, последний, — и после каждого тика спокойно делать любые нужные проверки. Прерывания от таймера мы симулируем элементарным ручным вызовом обработчика test_timer_handle_interrupts.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»