Тестирование СУБД: 10 лет опыта

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

Популярной статьей такого рода является описание тестирования библиотеки SQLite за авторством Ричарда Хиппа. Но у SQLite есть специфика: их инструменты тяжело переиспользовать в других проектах. Это следствие того, что у команды разработчиков SQLite есть обязательства поддерживать библиотеку как минимум до 2050 года, и для сокращения внешних зависимостей они все инструменты пишут сами с нуля (например, тест-раннер, инструмент для мутационного тестирования, Fossil SCM). У нас таких требований нет, поэтому в выборе инструментов мы не ограничены и пользуемся всем, что приносит пользу. И если вас что-то заинтересует, то вы достаточно легко сможете это принести в свой проект на C/C++.

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

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

В большинстве случаев качество внешних компонентов на хорошем уровне, но было исключение — библиотека libcurl. При её использовании иногда случались memory corruptions. Поэтому из runtime-зависимости libcurl стал git-модулем в нашем репозитории.

За поддержку языка Lua отвечает LuaJIT, который включает в себя как среду исполнения языка, так и трассирующий JIT компилятор. Наша версия LuaJIT уже давно отличается от ванильного набором патчей, они добавляют как фичи, например профилировщик, так и новые тесты, поэтому мы тщательно тестируем свой форк, чтобы не допустить регрессий. Хотя исходный код LuaJIT открыт и доступен под свободной лицензией, однако он не включает в себя регрессионные тесты. Поэтому мы собрали свой регрессионный тестовый набор из тестов для реализации Lua от PUC Rio, набора тестов от Франсуа Перра (François Perrad), тестов для других форков LuaJIT, и, конечно же, дополнили нашими собственными тестами.

Из других внешних библиотек это:

  • MsgPuck для сериализации данных в формате msgpack;
  • libcoro для реализации файберов;
  • libev для асинхронного ввода-вывода;
  • c-ares для асинхронного разрешения DNS-имён;
  • libcurl для работы с протоколом HTTP;
  • icu4c для поддержки Unicode;
  • OpenSSL, libunwind и zstd для сжатия данных;
  • small — наш набор специализированных аллокаторов памяти;
  • lua-cjson для работы с JSON, lua-yaml, luarocks, xxHash, PMurHash и других.

Основная часть проекта написана на C, небольшие части на C++ (в сумме 36 KLOC) и меньшая часть на Lua (14 KLOC).

767 text files.
758 unique files.
82 files ignored.
github.com/AlDanial/cloc v 1.82 T=0.78 s (881.9 files/s, 407614.4 lines/s)

-------------------------------------------------------------------------------
Language      files   blank   comment   code
-------------------------------------------------------------------------------
C             274     12649   40673     123470
C/C++ Header  287     7467    36555     40328
C++           38      2627    6923      24269
Lua           41      1799    2059      14384
yacc          1       191     342       1359
CMake         33      192     213       1213
...
-------------------------------------------------------------------------------
SUM:          688     25079   86968     205933
-------------------------------------------------------------------------------

Остальные языки относятся к инфраструктуре проекта или тестам: CMake, Make, Python (часть старых тестов написана на нём, но сейчас мы его не используем для написания тестов).

2076 text files.
2006 unique files.
851 files ignored.

github.com/AlDanial/cloc v 1.82 T=2.70 s (455.5 files/s, 116365.0 lines/s)

-------------------------------------------------------------------------------
Language      files   blank   comment   code
-------------------------------------------------------------------------------
Lua           996     31528   46858     194972
C             89      2536    2520      14937
C++           21      698     355       4990
Python        57      1131    1209      4500
C/C++ Header  11      346     629       1939
SQL           4       161     120       1174
...
-------------------------------------------------------------------------------
SUM:         1231     37120   51998     225336
-------------------------------------------------------------------------------

Из такого распределения используемых языков программирования следует один из основных акцентов тестирования: выявление проблем, присущих языкам с ручным управлением памятью (stack-overflow, heap-buffer-overflow, use-after-free и других). Наша система непрерывной интеграции неплохо с этим справляется. В следующей части расскажу о её устройстве.

Непрерывная интеграция

В разработке у нас находится несколько веток Tarantool: основная ветка (master) и по одной ветке для каждой версии (1.10.x, 2.1.x, 2.2.x и т.д.). Новая функциональность появляется в новых минорных версиях, а bug fix’ы появляются во всех ветках. При слиянии в каждой из веток проходит полный цикл регрессионного тестирования с разными компиляторами, с разными опциями сборки, сборка пакетов под разные платформы и много чего ещё, об этом подробнее ниже. Всё это происходит автоматически в едином конвейере. Патчи попадают в основную ветку только после успешного прохождения всего конвейера. Пока что мы добавляем патчи в основную ветку вручную, но движемся в сторону автоматизации.

Сейчас у нас примерно 870 интеграционных тестов, и при тестировании в 5 потоков они проходят за 10 минут. Это, вроде бы, не так много, но тестирование в CI параметризовано разными семействами и версиями операционных систем, архитектурами, разными компиляторами и их опциями, поэтому общее время тестирования может достигать получаса.

Мы запускаем тесты на большом наборе ОС:

  • шесть версий Ubuntu;
  • три версии Debian;
  • пять версий Fedora;
  • две версии CentOS;
  • две версии OpenSUSE и FreeBSD;
  • две версии macOS.

Некоторые конфигурации ещё параметризуются версиями и опциями компилятора. Некоторые платформы имеют номинальный статус поддержки, например, macOS; их в основном используют разработчики. Другие, типа FreeBSD, активно тестируются, но случаи использования FreeBSD-порта Tarantool в production мне неизвестны. Третьи, как, например, Linux, широко используются для промышленной эксплуатации Tarantool нашими клиентами и пользователями.

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

Основная архитектура для нас это amd64, недавно добавили поддержку arm64 и она тоже представлена в CI. Запуск тестов на процессорах разных архитектур позволяет сделать код более портируемым за счёт разделения платформозависимого и платформонезависимого кода. Так можно выявлять баги, связанные с другим порядком байтов (big-endian vs little-endian), разной скоростью выполнения инструкций, разными результатами математических функций или с такими редкостями, как «минус ноль». Ещё такое тестирование облегчает портирование кода на новую архитектуру, если появится такая необходимость. Ещё такое тестирование облегчает портирование кода на новую архитектуру, если появится такая необходимость. Сильнее всего привязан к платформе LuaJIT, в нём используется много ассемблера и из кода на Lua он сразу генерирует машинный код.

Когда-то давно, когда не было такого разнообразия облачных CI-систем, мы, как и многие проекты, использовали Jenkins. Потом появился Travis-CI, интегрированный с GitHub, мы переехали на него. Наша матрица тестирования сильно выросла и бесплатная версия Travis-CI не позволяла подключать свои серверы, поэтому переехали на Gitlab CI. Из-за проблем с интеграцией GitHub PR’ов мы, как только появился GitHub Actions, начали плавный переезд на него, и теперь во всех проектах (а у нас в GitHub-организации их несколько сотен) пользуемся только им.

Можно сказать, что Github это наша платформа для всего цикла разработки: планирования задач, хранения кода, проверки новых изменений — полностью тестируем там. Для этого мы используем как свои физические серверы или виртуальные машины в облаке VK Cloud Solutions, так и виртуальные машины, которые предоставляет Github Actions. GitHub не лишён недостатков: иногда он недоступен, иногда происходят глюки, но у него хорошее соотношение цена-качество.

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

Инспекция кода

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

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

Инспекция кода позволяет проверять изменения ещё одной парой глаз.

Статический и динамический анализ

Статический анализ мы используем для контроля общего стиля программирования и для поиска ошибок. Оформление кода должно соответствовать руководствам по стилю Lua, Python и С. Руководство по стилю для С во многом повторяет руководство по стилю ядра Linux, а руководство по стилю для кода на Lua следует стилю по умолчанию в luacheck, за исключением некоторых предупреждений, которые мы обычно выключаем. Всё это позволяет содержать код в едином стиле и улучшает удобочитаемость.

В сборочных файлах CMake мы используем флаги компиляторов, которые включают дополнительные проверки во время сборки, и следуем правилу «чистой» сборки, когда в выводе компилятора нет никаких необработанных предупреждений. Помимо статического анализа в самих компиляторах мы используем статический анализ в Coverity. Один раз использовали PVS-Studio, и он нашёл несколько некритичных ошибок в самом Tarantool и коннекторе tarantool-c. Ещё эпизодически использовали cppcheck, но не то чтобы он приносил много багов.

В кодовой базе Tarantool много Lua-кода, и когда мы решили исправить все предупреждения, которые он нашёл, то большинство их было о нарушении стиля программирования, и только четыре настоящих ошибки в основном коде и одна ошибка в коде тестов. Так что, если вы пишете на Lua, то не пренебрегайте luacheck и пользуйтесь им с самого начала.

Все новые изменения тестируются на сборках с включенными динамическими анализаторами для выявления проблем с памятью в C/C++ (Address Sanitizer), неопределённого поведения в C/C++ (UndefinedBehavior Sanitizer). Так как эти анализаторы могут влиять на производительность приложения, то по умолчанию флаги, которые их включают, выключены. Address Sanitizer хорошо себя показывает в CI, но для использования в канареечных сборках у него всё-таки большой overhead. Я пробовал пользоваться ночной сборкой Firefox, когда Mozilla стала включать в них ASAN, и комфортно им пользоваться было невозможно. Что говорить о СУБД с высокими требованиями к скорости. Но с GWP-ASAN этот overhead меньше, и мы думаем, как его применить в пакетах с промежуточными сборками.

Если санитайзеры выявляют проблемы на уровне кода, то assert’ы, повсеместно используемые в нашем коде, выявляют проблемы нарушения инвариантов в функциональности. Технически это макрос, который является частью стандартной библиотеки Си. assert() проверяет переданное выражение и завершает выполнение, если результат равен нулю. Всего около 5 000 таких проверок, они всегда включены только в отладочной сборке и выключены в релизных.

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

Функциональные регрессионные тесты

Так как интерпретатор Lua встроен в Tarantool и интерфейс к СУБД реализован с помощью Lua API, то использование Lua для тестов выглядит логичным следствием. Большая часть наших регрессионных тестов написана на Lua с использованием встроенных модулей Tarantool. Один из них — модуль TAP для тестирования кода на Lua. Он реализует набор примитивов для проверок в коде и структурирования тестов. Удобно, что есть некоторый минимум, которого достаточно для тестирования приложений на Lua. Многие модули или приложения, которые мы делаем, только этот модуль для тестирования и используют. Как очевидно из названия, он позволяет выводить результаты в формате TAP (Test Anything Protocol); пожалуй, это самый старый формат для тестовой отчётности. Часть тестов параметризованные (например, выполняются с двумя движками), и если учитывать тесты в разных конфигурациях, то их количество вырастает в полтора раза.

Большая часть функциональности Tarantool доступна с помощью Lua API, а если и нет, то можно получить к ней доступ с помощью FFI. FFI удобен в использовании, когда функция на C не должна быть частью Lua API, но нужна для теста. Главное, чтобы она не была объявлена как static. Пример использования С-кода в Lua с помощью FFI (правда, лаконично?):

local ffi = require "ffi"

ffi.cdef [[
int printf(const char *fmt, ...);
]]
ffi.C.printf("Hello %s!", "world")

Для некоторых частей Tarantool, таких как самодостаточные библиотеки raft, http_parser, csv, msgpuck, swim, uuid, vclock и т. д., написаны модульные тесты. Для их написания используется header-only библиотека на С в стиле TAP-тестов.

Для запуска тестов мы используем собственный инструмент — test-run.py. Сейчас кажется спорным писать свой тест-раннер с нуля, но он уже есть и мы его поддерживаем. В проекте есть разные типы тестов: модульные написаны на С и запускаются как бинари, и, как и в случае c TAP тестами, test-run.py для них анализирует успешность выполнения тестовых сценариев по выводу в TAP-формате:

TAP version 13
1..20                                                                      
ok 1 - trigger is fired                                                    
ok 2 - is not deleted                                                      
ok 3 - ctx.member is set    
ok 4 - ctx.events is set                                                   
ok 5 - self payload is updated                                             
ok 6 - self is set as a member
ok 7 - both version and payload events are presented
ok 8 - suspicion fired a trigger                                           
ok 9 - status suspected       
ok 10 - death fired a trigger
ok 11 - status dead                                                                                                                                       
ok 12 - drop fired a trigger                                               
ok 13 - status dropped                                                     
ok 14 - dropped member is not presented in the member table                
ok 15 - but is in the event context      
ok 16 - yielding trigger is fired
ok 17 - non-yielding still is not
ok 18 - trigger is not deleted until all currently sleeping triggers are finished 

Часть тестов сравнивает фактический вывод теста с эталонным: вывод нового теста сохраняют в файл и при дальнейших запусках сравнивают с фактическим. Такой подход, например, популярен для SQL-тестов (что в MySQL), что в PostgreSQL): достаточно написать нужные конструкции на SQL, выполнить, убедиться, что вывод корректный, и сохранить в файл. Надо только убедиться, что ввод получается всегда детерминированный, иначе добавите себе flaky-тестов. Вывод может зависеть от установленной локали в системе (поможет NO_LOCALE=1), от сообщений об ошибках, от времени или даты в выводе и т.д.

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

test-run.py позволяет нам запускать все типы тестов однообразно с формированием общего отчёта.

Для тестирования проектов на Lua у нас есть отдельный фреймворк luatest. Изначально это форк другого хорошего фреймворка luaunit. Форк позволил нам теснее интегрировать его с Tarantool (например, добавить специфичные фикстуры) и добавить много новых фич (интеграция с luacov, поддержка статуса XFail и др.) без зависимости от проекта luaunit.

Интересна история появления тестов для поддержки SQL в Tarantool. Мы взяли за основу часть кода SQLite, а именно парсер SQL-запросов и компилятор в байт-код с помощью VDBE. Одной из причин этого было близкое к 100 % покрытие тестами кода SQLite. Только вот тесты были написаны на языке TCL, а мы его совсем не используем. Чтобы портировать тесты, написанные на TCL, мы написали транслятор с TCL на Lua и после причёсывания получившегося кода импортировали их в кодовую базу. Этими тестами пользуемся до сих пор и по мере необходимости добавляем новые.

Отказоустойчивость — одно из требований к серверному ПО. Поэтому во многих тестах у нас используется внедрение сбоев (error injections) на уровне самого кода Tarantool. Для этого в исходном коде есть набор макросов и интерфейс в Lua API для включения. К примеру, нам нужно добавить сбой, который будет эмулировать задержку при записи в WAL. Добавляем ещё одну запись в массив ERRINJ_LIST в src/lib/core/errinj.h:

--- a/src/lib/core/errinj.h
+++ b/src/lib/core/errinj.h
@@ -151,7 +151,6 @@ struct errinj {
    _(ERRINJ_VY_TASK_COMPLETE, ERRINJ_BOOL, {.bparam = false}) \
    _(ERRINJ_VY_WRITE_ITERATOR_START_FAIL, ERRINJ_BOOL, {.bparam = false})\
    _(ERRINJ_WAL_BREAK_LSN, ERRINJ_INT, {.iparam = -1}) \
    + _(ERRINJ_WAL_DELAY, ERRINJ_BOOL, {.bparam = false}) \
    _(ERRINJ_WAL_DELAY_COUNTDOWN, ERRINJ_INT, {.iparam = -1}) \
    _(ERRINJ_WAL_FALLOCATE, ERRINJ_INT, {.iparam = 0}) \
    _(ERRINJ_WAL_IO, ERRINJ_BOOL, {.bparam = false}) \
    и добавляем этот сбой в код, который отвечает за запись операции в WAL:

    --- a/src/box/wal.c
    +++ b/src/box/wal.c
    @@ -670,7 +670,6 @@ wal_begin_checkpoint_f(struct cbus_call_msg *data)
}
vclock_copy(&msg->vclock, &writer->vclock);
msg->wal_size = writer->checkpoint_wal_size;
+ ERROR_INJECT_SLEEP(ERRINJ_WAL_DELAY);
return 0;
}

После этого можно включить задержку записи в журнал в отладочной сборке с помощью Lua-функции:

$ tarantool
Tarantool 2.8.0-104-ga801f9f35
type 'help' for interactive help
tarantool> box.error.injection.get('ERRINJ_WAL_DELAY')

---
- false
...
tarantool> box.error.injection.set('ERRINJ_WAL_DELAY', true)

---
- true
...

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

Интеграционное тестирование с экосистемой

Экосистема Tarantool состоит из большого количества коннекторов для разных языков программирования, вспомогательных библиотек для реализации популярных архитектурных паттернов (например, кеш или персистентная очередь). Помимо этого есть продукты, написанные на Lua с использованием функциональности Tarantool: Tarantool DataGrid и Tarantool Cartridge. Мы дополнительно тестируем предрелизные версии Tarantool с этими модулями и продуктами для проверки обратной совместимости.

Рандомизированное тестирование

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

Ядро Tarantool написано, в основном, на С, и даже при аккуратной разработке трудно избежать проблем с управлением памятью: use-after-free, heap buffer overflow, NULL pointer dereference. Их наличие крайне нежелательно для серверного ПО. К счастью, развитие динамического анализа и технологий фаззинг-тестирования в последнее время позволяют снизить количество этих неприятностей в проекте.

Выше я говорил, что Tarantool использует сторонние библиотеки. Многие из них уже применяют фаззинг-тестирование: проекты curl, c-ares, zstd и openssl регулярно тестируются в инфраструктуре OSS Fuzz. В коде Tarantool много мест, где используется код для синтаксического разбора (например, SQL или парсинг HTTP-запросов) или разбора структур MsgPack. Такой код может быть уязвим для багов, связанных с управлением памятью. К счастью, фаззинг-тесты хорошо выявляют такие проблемы. Для Tarantool тоже есть интеграция с OSS Fuzz, но тестов пока не очень много и мы нашли только один баг в библиотеке http_parser. Возможно, со временем количество таких тестов будет увеличиваться, для желающих добавить новый есть подробная инструкция.

В 2020 году мы добавили поддержку синхронной репликации и MVCC. Появился запрос на тестирование этой функциональности и мы решили часть тестов сделать на основе фреймворка Jepsen. Консистентность проверяем с помощью анализа истории транзакций. Но рассказ про тестирование с помощью Jepsen вполне потянет на отдельную статью, поэтому об этом в следующий раз.

Нагрузочное тестирование и тестирование производительности

Одна из фич, из-за которых выбирают Tarantool, это высокая производительность. Было бы странно не тестировать её. У нас есть неформальный критерий — 1 миллион операций вставки кортежей в секунду на обычном железе. Каждый может запустить его на своей машине и получить 1 Mops на Tarantool своими собственными руками. Вот такой просто сниппет на Lua может быть неплохим вариантом бенчмарка для запуска с синхронной репликацией:

sergeyb@pony:~/sources$ tarantool relay-1mops.lua 2
making 1000000 operations, 10 operations per txn using 50 fibers
starting 1 replicas
master done 1000009 ops in time: 1.156930, cpu: 2.701883
master speed    864363  ops/sec
replicas done 1000009 ops in time: 3.263066, cpu: 4.839174
replicas speed  306463  ops/sec
sergeyb@pony:~/sources$ 

Для тестирования производительности мы запускаем и стандартные бенчмарки: популярные YCSB (Yahoo! Cloud Serving Benchmark), nosqlbench, linkbench, sysbench, TPC-H и TPC-C. А также cbench, наш собственный бенчмарк для Tarantool API. В нём примитивные операции написаны на С, а сценарии описываются на Lua.

Метрики

Для оценки покрытия кода регрессионными тестами мы собираем информацию о покрытии кода. Сейчас у нас покрыто 83 % всех строк и 51 % всех веток кода, это неплохие показатели. Для визуализации покрытых участков используем Coveralls. В случае сбора информации о покрытии кода на C/C++ ничего нового — инструментирование кода с опцией -coverage, запуск тестов и формирование отчёта с помощью gcov и lcov. А вот в случае Lua ситуация чуть хуже: здесь примитивный профилировщик, и luacov предоставляет информацию только о покрытии строк. Это немного расстраивает.

Релизный чеклист

Выпуск каждой новой версии сопряжён с ворохом разнообразных задач, за которые отвечают разные команды: тегирование релиза в репозитории, публикация пакетов со сборками, публикация документации на сайте, проверка результатов функционального тестирования и производительности, проверка открытых багов и триаж на следующий milestone, и т.д. Выпуск новой версии легко превратить в хаос или забыть о каком-то из шагов. Чтобы такого не случилось, мы описали процесс выпуска в виде чеклиста и следуем ему перед выпуском новой версии.

Заключение

Как говорил Козьма Прутков, «Нет предела совершенству». Со временем совершенствуются процессы и технологии для выявления багов, баги становятся сложнее и заковыристее, и чем сложнее и изощреннее система тестирования и обеспечения качества, тем меньше багов доходит до пользователя.

Полезные ссылки

Теги: softwaretestingopensourcetarantoolfeed