Тестирование sx127x
Я продолжаю разрабатывать библиотеку для работы с чипом sx127x, и в этом посте речь пойдёт о тестировании. Но для начала небольшая предыстория. Изначально библиотека поддерживала только LoRa модуляцию и была достаточно небольшой. По сути это была небольшая обёртка над регистрами sx127x, которая позволяла из Си вызывать нужные функции. Основная ценность скорее была в том, чтобы перевести магические числа и SPI протокол в понятные слова из даташита. После того как я решил добавить поддержку FSK модуляции, библиотека разрослась. Нужно было поддержать потоковую обработку входящих сообщений, реагировать на гораздо большее количество прерываний и при этом не сломать старый функционал. В этот момент стало понятно, что нужны тесты.
Юнит тесты
Из-за того, что библиотека не зависит от конкретных плат и операционных систем, писать юнит тесты оказалось просто. Достаточно сделать mock-реализацию SPI интерфейса и возвращать ожидаемые данные. Например, регистры sx127x можно эмулировать следующим образом:
void spi_mock_registers(uint8_t *expected, int code) {
sx127x_mock_registers = expected;
sx127x_mock_expected_code = code;
}
А в реализации использовать их вот так:
#include <sx127x_spi.h>
int sx127x_spi_read_registers(int reg, void *spi_device, size_t data_length, uint32_t *result) {
for (int i = 0; i < data_length; i++) {
*result = ((*result) << 8);
*result = (*result) + sx127x_mock_registers[reg + i];
}
}
Единственное, что нужно учитывать - это специальный регистр 0x00 (FIFO), чтение и запись в который должна работать по-другому. Полную реализацию mock SPI можно посмотреть в репозитории.
Для юнит тестов я использовал библиотеку libcheck, потому что другие мои проекты sdr-server и sdr-modem тоже её используют. Как и любая другая библиотека эта позволяет инициализировать состояние перед каждым тестом:
void setup() {
registers = (uint8_t *)malloc(registers_length * sizeof(uint8_t));
memset(registers, 0, registers_length);
registers[0x42] = 0x12;
spi_mock_registers(registers, SX127X_OK);
ck_assert_int_eq(SX127X_OK, sx127x_create(NULL, &device));
spi_mock_write(SX127X_OK);
}
Вызывает тест. Например, тест обработчика CAD прерывания выглядит так:
START_TEST(test_lora_cad) {
ck_assert_int_eq(SX127X_OK, sx127x_set_opmod(SX127x_MODE_CAD, SX127x_MODULATION_LORA, device));
ck_assert_int_eq(registers[0x40], 0b10000000);
sx127x_lora_cad_set_callback(cad_callback, device);
registers[0x12] = 0b00000101; // cad detected
sx127x_handle_interrupt(device);
ck_assert_int_eq(1, cad_status);
registers[0x12] = 0b00000100; // cad not detected
sx127x_handle_interrupt(device);
ck_assert_int_eq(0, cad_status);
}
END_TEST
И очищать состояние после каждого теста с помощью функции teardown()
:
void teardown() {
if (device != NULL) {
sx127x_destroy(device);
device = NULL;
}
if (registers != NULL) {
free(registers);
registers = NULL;
}
}
В итоге мне удалось написать порядочное количество тестов и добиться 74.7% покрытия тестами.
Вообще, несмотря на кажущуюся простоту решения, мне удалось найти несколько довольно досадных ошибок! В основном связанных с тем, что я где-то неправильно сдвигал биты или выставлял не те регистры. Так что время потраченное на написание юнит тестов и их настройку полностью себя оправдало.
Интеграционные тесты
Юнит тесты всем хороши. Но как понять, что выставленные биты в правильном регистре действительно позволяют отправлять и получать сообщения? На помощь приходят интеграционные тесты. Если бы меня спросили: “А как написать интеграционные тесты для Java проекта?”, то я бы без запинки ответил, что-нибудь про докер, среду тестирования или mock HTTP сервер. Когда же встал вопрос о том, как протестировать железо, то мне потребовалось несколько недель, чтобы разобраться с проблемой.
Так как у меня есть платы TTGO, то я решил взять ESP-IDF в котором есть поддержка юнит и интеграционных тестов. Они скорее ориентированы на тестирования фреймворка, но для сторонних приложений тоже подходят.
Все мои интеграционные тесты должны быть построены примерно по одному шаблону:
- Первое устройство должно начать слушать эфир
- Второе устройство должно послать сообщение
- Первое устройство должно успешно получить сообщение
- Тест должен проверить, что сообщение получено и оно верное
ESP-IDF фреймворк предлагает реализовывать такие тесты следующим образом:
- Создать отдельное приложение на Си с тестовыми сценариями
- Использовать pytest + pytest-embedded для того, чтобы запускать сценарии в нужной последовательности
Тестовые сценарии
Тестовые сценарии должны лежать в обычном приложении для ESP32. Оно должно иметь точку входа app_main()
, конфигурацию CMake, зависимости и пр. Основной его задачей будет запуск меню библиотеки unity:
unity - это ещё одна библиотека юнит тестов для Си. Она позволяет запускать их в интерактивном режиме. Если подключиться к устройству по serial интерфейсу, то можно напечатать название теста, нажать “Enter” и библиотека запустит этот тест. Основная функция выглядит достаточно просто:
#include <unity_test_runner.h>
#include "test_lora.c"
void app_main(void) {
unity_run_menu();
}
Я решил вынести тесты в отдельные файлы для удобства. На самом деле ничего не мешает их реализовывать сразу в main.c
. Так как unity_run_menu
ожидает ввод в бесконечном цикле, то необходимо выключить TWDT. Это специальная мера предосторожности, которая перезапускает приложение, если какой-то таск уходит в бесконечный цикл. Выключается в файле sdkconfig.defaults
:
CONFIG_ESP_TASK_WDT=n
Тесты для отправки сообщения и проверки выглядят следующим образом:
TEST_CASE("sx127x_test_lora_rx_explicit_header", "[lora]") {
sx127x_rx_set_callback(rx_callback, fixture->device);
sx127x_set_opmod(SX127x_MODE_RX_CONT, SX127x_MODULATION_LORA, fixture->device);
xSemaphoreTake(fixture->rx_done, portMAX_DELAY);
uint8_t expected[] = {0xCA, 0xFE};
TEST_ASSERT_EQUAL_UINT16(sizeof(expected), fixture->rx_data_length);
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, fixture->rx_data, sizeof(expected));
}
TEST_CASE("sx127x_test_lora_tx_explicit_header", "[lora]") {
// some initialization
sx127x_lora_tx_set_for_transmission(data, sizeof(data), fixture->device);
sx127x_set_opmod(SX127x_MODE_TX, SX127x_MODULATION_LORA, fixture->device);
ESP_LOGI("sx127x_test", "done. waiting for message");
xSemaphoreTake(fixture->tx_done, portMAX_DELAY);
}
Это скорее даже не тесты, а небольшие куски кода, которые нужно запускать на разных устройствах в определённой последовательности. После компиляции прошивку с одинаковыми тестами нужно загрузить на два разных устройства. На первом устройстве запустить тест sx127x_test_lora_rx_explicit_header
, а на втором sx127x_test_lora_tx_explicit_header
. Как только сообщение отправится, tx
тест сразу же завершится. А rx
дождётся получения сообщения и проверит результат. Выглядит достаточно просто, хотя у меня заняло несколько дней в этом разобраться. Меня в первую очередь смутила официальная документация, которая говорит, что нужно использовать макрос TEST_CASE_MULTIPLE_DEVICES
. Хотя на самом деле этого ничего не нужно, потому что устройства между собой никак не синхронизированы. И синхронизацию нужно по-любому делать через serial интерфейс вручную. Или с помощью python скрипта.
pytest
Алгоритм для запуска таких интеграционных тестов может быть автоматизирован с помощью скрипта на python. В моём случае это выглядит как то так:
@pytest.mark.supported_targets
@pytest.mark.generic
@pytest.mark.parametrize('count', [
2,
], indirect=True)
def test_single(dut: Tuple[Dut, Dut]) -> None:
dut_rx = dut[0]
dut_tx = dut[1]
dut_rx.expect('Press ENTER to see the list of tests')
dut_rx.write('"sx127x_test_lora_rx_explicit_header"')
dut_tx.expect('Press ENTER to see the list of tests')
dut_tx.write('"sx127x_test_lora_tx_explicit_header"')
dut_tx.expect_unity_test_output()
dut_rx.expect_unity_test_output()
Запускается этот скрипт с помощью команды pytest:
pytest --target esp32 --port="/dev/ttyACM0|/dev/ttyUSB0"
Эта команда выполнит следующие шаги:
- Загрузит прошивку на два устройства
/dev/ttyACM0
и/dev/ttyUSB0
- Запустит скрипт
- Скрипт подключится по serial интерфейсу к обоим устройствам
- Запустит тест
sx127x_test_lora_rx_explicit_header
- Потом тест
sx127x_test_lora_tx_explicit_header
- Дождётся выполнения тестов на обоих устройствах
Послесловие
Тестировать железо можно, но для этого нужно иметь много утилит и инструментов. ESP-IDF из коробки предоставляет хороший набор, который позволяет писать сложные тесты. Посмотрим, хватит ли его функционала для моих нужд. У меня в планах добавить тесты для LoRa, FSK, OOK, различные режимы отправки сообщений с контрольной суммой и без, а так же проверить потребление памяти.