184. Тестирование прошивки: уровни, покрытие, мутации, санитайзеры
Раздел 182 показал, сколько стоит ошибка, дожившая до поля. Здесь — чем её ловить и как понять, что набор тестов действительно что-то проверяет, а не просто «зелёный».
Всё на живом примере: разборщик кадров 0x55 — длина — данные — CRC-8,
тот самый код, который в прошивке разбирает поток из UART. Он собирается
и на плате, и на компьютере, поэтому все замеры ниже — настоящие: gcc
12 в образе stm-lab:1, покрытие через gcov, мутации и санитайзер.
Как это называется в документации
Оригинал Перевод unit test модульный тест integration / system test интеграционный, системный тест test harness, fixture каркас тестов, подготовка окружения code coverage: line / branch покрытие кода: по строкам, по ветвям mutation testing мутационное тестирование mutant killed / survived мутант убит, мутант выжил sanitizer (ASan, UBSan) санитайзер: проверки памяти и неопределённого поведения fuzzing подача случайных входов stub, mock заглушка, подделка зависимости regression test тест на возврат старой ошибки
184.1 Три уровня и что каждый ловит
| Уровень | Где идёт | Что ловит | Чем плох |
|---|---|---|---|
| модульный | на компьютере, без платы | логика: разбор, расчёт, состояния | не видит железа |
| интеграционный | на плате со стендом | драйверы, тайминги, прерывания | нужен стенд, идёт медленно |
| системный | на готовом изделии | сценарии целиком, питание, помехи | самый дорогой и редкий |
Смысл разделения — в цене прогона. Модульные тесты идут за доли секунды на каждое изменение, поэтому ошибку они ловят на этапе кода, где она стоит час (раздел 182.2). Системные проверки идут перед сдачей — там же и цена ошибки.
Для прошивки правило простое: логику отделяют от железа. Разбор кадра, расчёт калибровки, автомат состояний, кольцевой буфер — это обычный C без регистров, и он проверяется на компьютере. Всё, что трогает регистры, прячут за тонкой прослойкой, а в тестах подставляют заглушку.
184.2 Модуль, который можно проверить без платы
Разборщик — автомат из четырёх состояний: ждём старт-байт, читаем длину, набираем данные, сверяем CRC. Ошибка возвращает разборщик в начало:
case FR_LEN:
if (b == 0u || b > FRAME_MAX) {
frame_reset(p);
return FRAME_ERR;
}
p->len = b;
p->got = 0;
p->state = FR_DATA;
return FRAME_NONE;
В нём нет ни одного обращения к регистрам: байты приходят параметром. На плате его кормит прерывание UART, в тестах — массив. По счёту gcov это 45 строк кода и 22 ветви.
184.3 Каркас тестов на двадцать строк
Никакой библиотеки не нужно: считаем проверки и провалы.
static int checks, failed;
static void check(int cond, const char *what)
{
checks++;
if (!cond) {
failed++;
printf("FAIL: %s\n", what);
}
}
Тесты собраны в таблицу, а -DUPTO=k прогоняет только первые k — так
видно, что даёт каждый следующий тест:
| № | Тест |
|---|---|
| 1 | правильный кадр разобран |
| 2 | испорченная CRC отвергнута |
| 3 | длина 0 и длина больше предела отвергнуты |
| 4 | мусор до старт-байта не мешает |
| 5 | кадр предельной длины |
| 6 | старт-байт внутри данных не ломает кадр |
| 7 | два кадра подряд |
| 8 | CRC на известных данных |
Весь набор — 8 тестов и 15 проверок, прогон занимает доли секунды.
184.4 Покрытие: что оно говорит и чего не говорит
gcc --coverage и gcov -b считают, какие строки и ветви выполнились:
| Тестов | Строк | Ветвей | Убито мутантов из 48 |
|---|---|---|---|
| 1 | 91,11 % | 81,82 % | 32 |
| 2 | 95,56 % | 86,36 % | 32 |
| 3 | 100,00 % | 95,45 % | 33 |
| 4 | 100,00 % | 100,00 % | 34 |
| 5 | 100,00 % | 100,00 % | 35 |
| 6 | 100,00 % | 100,00 % | 35 |
| 7 | 100,00 % | 100,00 % | 35 |
| 8 | 100,00 % | 100,00 % | 44 |
Первый же тест — «правильный кадр» — даёт 91,11 % строк: счастливый путь проходит почти через весь модуль. На четвёртом тесте покрытие упирается в потолок: 100 % строк и 100 % ветвей. Дальше оно не растёт — и по нему кажется, что тесты писать больше незачем.
Покрытие честно отвечает на вопрос «что ни разу не выполнялось». Оно ничего не говорит о том, проверен ли результат: строка, выполненная без единой проверки её результата, засчитывается так же, как проверенная.
184.5 Мутационная проверка: тест на сами тесты
Идея простая: испортить код одной правкой и посмотреть, упадут ли
тесты. Если не упали — эта правка тестами не проверяется. Мутации
берутся по порядку, без случайности: сравнения меняются на соседние
(== на !=, < на <=), целые литералы — на единицу больше. На
нашем модуле получилось 48 мутантов.
При 100 % покрытия строк и ветвей (четыре теста) убито только 34 из 48 — 71 %. Полный набор из восьми тестов убивает 44 — 92 %.
Самое интересное — восьмой тест. Он добивает сразу девять мутантов,
и все они про CRC: полином 0x07, начальное значение, счётчик битов.
Почему их не ловили остальные тесты? Потому что они считали ожидаемую
CRC тем же самым кодом:
buf[5] = frame_crc8(data, 3); /* ожидание из проверяемого кода */
...
check(frame_crc8(d, 3) == 0xC0u, "CRC-8 на известных данных");
Первая строка ничего не проверяет в самой CRC: испортим полином — и кадр, и ожидание изменятся одинаково. Вторая — проверяет, потому что ответ записан числом, полученным независимо. Отсюда правило: хотя бы один тест на алгоритм должен сравнивать с числом, а не с вызовом того же кода.
Выжившие четыре мутанта разобрать тоже полезно:
| Мутант | Почему выжил |
|---|---|
p->len = 0 → = 1 в сбросе |
поле всегда переписывается до чтения |
p->got = 0 → = 1 в сбросе |
то же самое |
p->crc = 0 → = 1 в сбросе |
то же самое |
i < p->len → i <= p->len |
тесты не смотрят за границу массива |
Первые три — это не дыра в тестах, а лишний код: сброс присваивает поля, которые и так задаются заново перед использованием. Четвёртый — настоящая дыра, и о ней дальше.
184.6 Санитайзеры и случайные входы
Мутант i <= p->len копирует на один байт больше, чем есть в кадре.
При длине 32 это запись за границу массива — и тесты её не замечают:
лишний байт попадает в соседнюю память и ничего не ломает. Тот же
прогон, собранный с проверками -fsanitize=undefined -fsanitize=bounds-strict,
падает сразу:
runtime error: index 32 out of bounds for type 'uint8_t [32]'
Санитайзер ничего не знает про наши тесты — он проверяет сам код во время выполнения. Поэтому модульные тесты гоняют дважды: обычной сборкой и сборкой с санитайзером.
Заодно выясняется, откуда берутся «плавающие» тесты. Другой мутант —
i < n на i <= n в расчёте CRC — читает один байт за границей
массива, и обычная сборка убивает его через раз: что оказалось в
соседней памяти, то и попало в CRC. Поэтому все замеры собираются с
-ftrivial-auto-var-init=zero — локальные переменные обнуляются, и
прогон повторяется число в число. В настоящей прошивке этот же мутант —
чтение лишнего байта, и санитайзеры его не видят: проверки undefined
и bounds не знают размера за указателем, а -fsanitize=address молчит,
потому что байт за data лежит внутри той же структуры. Ловят его только
тесты.
Второе дешёвое средство — случайные входы. Прогон 2 000 000 случайных байт через разборщик (генератор свой, чтобы прогон повторялся):
| Что считали | Значение |
|---|---|
| байт подано | 2 000 000 |
| собралось кадров | 4 |
| отвергнуто как ошибка | 7 786 |
| кадров с неверной длиной | 0 |
Санитайзер за весь прогон не сказал ни слова. Четыре случайных кадра на два миллиона байт — это нормально: чтобы кадр собрался, случайные данные должны сойтись с CRC. Ценность прогона в другом: разборщик 7 786 раз корректно отверг мусор и ни разу не вышел за массив и не завис.
184.7 Что из этого держать в конвейере
- Модульные тесты на каждое изменение — они идут доли секунды (раздел 110).
- Вторая сборка с санитайзером — те же тесты, другой компилятор-ключ.
- Покрытие как сигнал о непроверенном, а не как цель: 100 % — это «ничего не забыли запустить», а не «всё проверено».
- Мутации — изредка: прогон 48 мутантов по восемь наборов занимает минуты, поэтому их гоняют не на каждое изменение, а перед выпуском или по расписанию.
- Тест на каждую найденную ошибку. Условие уже известно, тест пишется за минуты, а возврат ошибки он ловит навсегда.
Окупаемость считается ровно так, как в 182.3: вложения в каркас тестов против часов, сэкономленных на ошибках, не доехавших до поля.
184.8 Грабли
Тест сравнивает с вызовом того же кода. Девять мутантов CRC выжили именно поэтому. Хотя бы одно ожидание должно быть независимым числом.
100 % покрытия принимают за качество. При 100 % строк и ветвей выживали 14 мутантов из 48.
Тесты только на счастливый путь. Один тест «правильный кадр» даёт 91,11 % строк — и не ловит ни испорченную CRC, ни неверную длину.
Логика перемешана с регистрами. Такой модуль нельзя собрать на компьютере, и он остаётся без модульных тестов вовсе.
Тесты не гоняют с санитайзером. Выход за границу массива на один байт обычный прогон не замечает годами — пока не переставят поля структуры.
Плавающий тест оставляют «пока так». Тест, который иногда падает, через неделю никто не читает, и вместе с ним перестают читать весь набор.
184.9 Итоги
- Логику отделяют от железа: разбор кадра, расчёты и автоматы проверяют на компьютере за доли секунды, стенд и изделие — уровнями выше.
- Покрытие показывает непроверенное, но не качество: 4 теста дают 100 % строк и ветвей, а мутантов убивают 34 из 48 (71 %); 8 тестов — 44 из 48 (92 %).
- Тест, который считает ожидание тем же кодом, не проверяет алгоритм: восьмой тест с числом 0xC0 добил сразу девять мутантов CRC.
- Санитайзер ловит то, что не видят тесты: выход за границу массива на кадре предельной длины.
- Случайные входы дешевы: 2 000 000 байт, 7 786 отвергнутых кадров, ни одного отказа и ни одной неверной длины.
Полные исходники
frame.c — разбор кадра — код под тестами.
#include "frame.h"
void frame_reset(frame_parser_t *p)
{
p->state = FR_WAIT;
p->len = 0;
p->got = 0;
p->crc = 0;
}
uint8_t frame_crc8(const uint8_t *data, size_t n)
{
uint8_t crc = 0x00u;
for (size_t i = 0; i < n; i++) {
crc ^= data[i];
for (int bit = 0; bit < 8; bit++) {
if (crc & 0x80u) {
crc = (uint8_t)((crc << 1) ^ 0x07u);
} else {
crc = (uint8_t)(crc << 1);
}
}
}
return crc;
}
int frame_feed(frame_parser_t *p, uint8_t b, frame_t *out)
{
switch (p->state) {
case FR_WAIT:
if (b == FRAME_START) {
p->state = FR_LEN;
}
return FRAME_NONE;
case FR_LEN:
if (b == 0u || b > FRAME_MAX) {
frame_reset(p);
return FRAME_ERR;
}
p->len = b;
p->got = 0;
p->state = FR_DATA;
return FRAME_NONE;
case FR_DATA:
p->data[p->got] = b;
p->got++;
if (p->got == p->len) {
p->state = FR_CRC;
}
return FRAME_NONE;
case FR_CRC:
default:
p->crc = frame_crc8(p->data, p->len);
if (p->crc != b) {
frame_reset(p);
return FRAME_ERR;
}
out->len = p->len;
for (uint8_t i = 0; i < p->len; i++) {
out->data[i] = p->data[i];
}
frame_reset(p);
return FRAME_READY;
}
}
tests.c — набор тестов к разбору кадра.
/* Набор тестов к разбору кадра. UPTO=k — прогнать только первые k тестов. Раздел 184.
Разборщик, кадр и данные объявлены static с запасным нулевым байтом в конце: тогда
мутант, залезающий за границу массива, ведёт себя одинаково от прогона к прогону. */
#include <stdio.h>
#include <string.h>
#include "frame.h"
static int checks, failed;
static frame_parser_t p;
static frame_t out;
static void check(int cond, const char *what)
{
checks++;
if (!cond) {
failed++;
printf("FAIL: %s\n", what);
}
}
/* прогнать массив байт через разборщик; вернуть последний результат */
static int feed_all(const uint8_t *b, size_t n)
{
int r = FRAME_NONE;
for (size_t i = 0; i < n; i++) {
r = frame_feed(&p, b[i], &out);
}
return r;
}
static void test_good(void)
{
static const uint8_t data[4] = {0x01, 0x02, 0x03, 0x00};
static uint8_t buf[6] = {FRAME_START, 3, 0x01, 0x02, 0x03, 0};
buf[5] = frame_crc8(data, 3);
frame_reset(&p);
check(feed_all(buf, 6) == FRAME_READY, "правильный кадр принят");
check(out.len == 3 && memcmp(out.data, data, 3) == 0, "данные кадра совпали");
}
static void test_bad_crc(void)
{
static const uint8_t data[3] = {0x10, 0x20, 0x00};
static uint8_t buf[5] = {FRAME_START, 2, 0x10, 0x20, 0};
buf[4] = (uint8_t)(frame_crc8(data, 2) ^ 0xFFu);
frame_reset(&p);
check(feed_all(buf, 5) == FRAME_ERR, "неверная CRC отвергнута");
check(p.state == FR_WAIT, "после ошибки разборщик ждёт старт-байт");
}
static void test_bad_len(void)
{
static const uint8_t zero[2] = {FRAME_START, 0};
static const uint8_t big[2] = {FRAME_START, FRAME_MAX + 1};
frame_reset(&p);
check(feed_all(zero, 2) == FRAME_ERR, "длина 0 отвергнута");
frame_reset(&p);
check(feed_all(big, 2) == FRAME_ERR, "длина больше предела отвергнута");
}
static void test_garbage_before_start(void)
{
static const uint8_t data[2] = {0xAA, 0x00};
static uint8_t buf[7] = {0x00, 0xFF, 0x12, FRAME_START, 1, 0xAA, 0};
buf[6] = frame_crc8(data, 1);
frame_reset(&p);
check(feed_all(buf, 7) == FRAME_READY, "мусор до старт-байта не мешает");
check(out.len == 1 && out.data[0] == 0xAA, "кадр после мусора разобран");
}
static void test_max_len(void)
{
static uint8_t data[FRAME_MAX + 1];
static uint8_t buf[FRAME_MAX + 3];
for (unsigned i = 0; i < FRAME_MAX; i++) {
data[i] = (uint8_t)(i * 7 + 1);
}
buf[0] = FRAME_START;
buf[1] = FRAME_MAX;
memcpy(buf + 2, data, FRAME_MAX);
buf[FRAME_MAX + 2] = frame_crc8(data, FRAME_MAX);
frame_reset(&p);
check(feed_all(buf, FRAME_MAX + 3) == FRAME_READY, "кадр предельной длины принят");
check(out.len == FRAME_MAX, "длина предельного кадра верна");
}
static void test_start_inside_data(void)
{
static const uint8_t data[3] = {FRAME_START, 0x55, 0x00};
static uint8_t buf[5] = {FRAME_START, 2, FRAME_START, 0x55, 0};
buf[4] = frame_crc8(data, 2);
frame_reset(&p);
check(feed_all(buf, 5) == FRAME_READY, "старт-байт внутри данных не ломает кадр");
}
static void test_two_frames(void)
{
static const uint8_t d1[2] = {0x41, 0x00};
static const uint8_t d2[2] = {0x42, 0x00};
static uint8_t buf[8] = {FRAME_START, 1, 0x41, 0, FRAME_START, 1, 0x42, 0};
buf[3] = frame_crc8(d1, 1);
buf[7] = frame_crc8(d2, 1);
frame_reset(&p);
check(feed_all(buf, 4) == FRAME_READY && out.data[0] == 0x41, "первый кадр");
check(feed_all(buf + 4, 4) == FRAME_READY && out.data[0] == 0x42, "второй кадр подряд");
}
static void test_crc_known(void)
{
static const uint8_t d[4] = {0x31, 0x32, 0x33, 0x00};
check(frame_crc8(d, 3) == 0xC0u, "CRC-8 на известных данных");
check(frame_crc8(d, 0) == 0x00u, "CRC-8 пустых данных");
}
#ifndef UPTO
#define UPTO 8
#endif
typedef void (*test_fn)(void);
static const test_fn TESTS[] = {
test_good, /* 1 — счастливый путь */
test_bad_crc, /* 2 — испорченная CRC */
test_bad_len, /* 3 — длина 0 и больше предела */
test_garbage_before_start, /* 4 — мусор до старт-байта */
test_max_len, /* 5 — кадр предельной длины */
test_start_inside_data, /* 6 — старт-байт внутри данных */
test_two_frames, /* 7 — два кадра подряд */
test_crc_known, /* 8 — CRC на известных данных */
};
int main(void)
{
unsigned n = UPTO;
if (n > sizeof(TESTS) / sizeof(TESTS[0])) {
n = sizeof(TESTS) / sizeof(TESTS[0]);
}
for (unsigned i = 0; i < n; i++) {
TESTS[i]();
}
printf("тестов: %u, проверок: %d, провалов: %d\n", n, checks, failed);
return failed ? 1 : 0;
}
Дальше: раздел 185 «Dart для инженера прошивки: байты, числа, разбор кадра».