Как тестируют SQLite

Что собой представляет SQLite: это самая распространенная СУБД в мире. Как ее тестируют: ежедневно выполняется около полумиллиарда тест-кейсов.

«Надежность SQLite достигается в том числе за счет очень тщательного тестирования.

Например версия 3.42.0 (2023-05-16) SQLite состоит примерно из 156 KSLOC кода на C (то есть 156 тысяч строк кода без учета пустых строк и комментариев). 

Для сравнения, тестового кода в проекте SQLite в 590 раз больше — 92053 KSLOC (то есть почти 100 миллионов строк тестового кода).

Тестовая инфраструктура

Для тестирования core-библиотеки SQLite используются четыре независимых тестовых пакета (иначе называемых обвязки/оснастки/test harness). Каждый из них разрабатывается, поддерживается и управляется отдельно от остальных.

1. TCL-тесты — это оригинальные тесты для SQLite. Они содержатся в том же дереве исходников, что и ядро SQLite, и так же являются общественным достоянием. Это основные тесты, используемые при разработке. Они пишутся на сценарном языке TCL. Сам по себе этот TCL-пакет состоит из 27,2 KSLOC кода на C, для TCL-интерфейса. Тестовые сценарии — это 1390 файлов общим размером 23,2 МБ. Существует 51445 отдельных тест-кейсов, но многие из них параметризованные и запускаются по нескольку раз с разными параметрами, так что при прогоне тестов выполняются миллионы отдельных тестов.

2. Пакет TH3 — это набор проприетарных тестов на C, которые обеспечивают 100% тестовое покрытие ветвей (и 100% покрытие MC/DC) core-библиотеки SQLite. TH3-тесты предназначены для запуска на embedded и специальных платформах, которые не поддерживают TCL или другие workstation-сервисы. TH3-тесты используют только опубликованные SQLite-интерфейсы. TH3 состоит из 76,9 МБ или 1055 KSLOC кода на С, имплементирующих 50362 отдельных тест-кейса. Тесты TH3 в большинстве параметризованные, поэтому при полном покрытии выполняется около 2,4 миллиона экземпляров тестов. Тест-кейсы, обеспечивающие 100-процентное покрытие ветвей, составляют подмножество в TH3-пакете. При тестировании перед релизом выполняется около 248,5 миллиона тестов. Почитать о TH3 можно здесь.

3. Пакет SQL Logic Test (SLT) предназначен для выполнения огромного количества SQL-запросов как в SQLite, так и с других СУБД. В настоящее время SLT сравнивает SQLite с PostgreSQL, MySQL, Microsoft SQL Server и Oracle 10g. SLT выполняет 7,2 миллиона SQL-запросов, содержащих 1,12 Гб тестовых данных.

4. Движок dbsqlfuzz — собственный фазз-тестер. Другие фаззеры для SQLite мутируют либо входные данные, либо файл БД. Dbsqlfuzz мутирует и SQL, и файл БД одновременно, таким образом способен создавать новые ошибочные состояния. Dbsqlfuzz построен с использованием фреймворка libFuzzer с LLVM и кастомным мутатором. Имеется 336 seed-файлов. Фаззер dbsqlfuzz выполняет около миллиарда тестовых мутаций в день. Dbsqlfuzz помогает обеспечить устойчивость SQLite к атакам через вредоносный SQL-код или входные данные.

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

5. Программа «speedtest1.c» оценивает производительность SQLite при типичной рабочей нагрузке.

6. «mptester.c» создает стресс-тест нескольких процессов, одновременно читающих и записывающих в одну БД.

7. «threadtest3.c», стресс-тест нескольких потоков, одновременно использующих SQLite.

8. «fuzzershell.c» для запуска некоторых типов fuzz-тестов.

9. «jfuzz» — это основанный на libfuzzer фаззер для входных данных JSONB для функций JSON SQL.

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

Перед каждой проверкой дерева исходников SQLite разработчики обычно запускают подмножество (называемое «veryquick») Tcl-тестов, состоящее примерно из 300 тысяч тест-кейсов. Тесты veryquick это большинство тестов, кроме тестов на аномалии, фаззинга и тестов выносливости. Veryquick-тестов достаточно для выявления большинства ошибок, и при этом они выполняются за несколько минут, а не часов.

3. Тестирование аномалий

Тесты аномалий — это тесты проверки правильности поведения SQLite, «когда что-то идет не так». Создать SQL-движок, который будет правильно реагировать на правильно сформированные входные данные на полностью исправном компьютере — относительно легко. Гораздо сложнее создать систему, которая нормально реагирует на некорректные входные данные и продолжает работать после сбоев в системе. Тесты аномалий предназначены именно для этого.

3.1. Тесты Out-of-memory

SQLite, как и все движки SQL, широко использует malloc() (здесь описание динамического распределения памяти в SQLite). На серверах и рабочих станциях malloc() практически никогда не дает сбоев, поэтому тщательная обработка ошибок типа out-of-memory (OOM) не имеет особого значения. Но на embedded-устройствах такие ошибки встречаются пугающе часто, и поскольку SQLite используется на embedded-устройствах, нам важно, чтобы система умела изящно обрабатывать out-of-memory.

Тестирование выполняется путем симуляции ошибок. SQLite позволяет приложению подставить альтернативную реализацию malloc() с помощью интерфейса sqlite3_config(SQLITE_CONFIG_MALLOC,…). Тесты типа TCL и TH3 могут вставлять модифицированную версию malloc(), которая после определенного количества аллокаций будет завершена сбоем. Эти инструментированные malloc можно настроить так, чтобы они отказали только один раз, а затем снова начали работать, или чтобы продолжали отказывать. OOM-тесты выполняются в цикле. В первой итерации цикла инструментированный malloc настраивается так, чтобы отказать при первой аллокации. Затем выполняется какая-то операция SQLite и проверяется, правильно ли SQLite обработал OOM-ошибку. Затем счетчик времени до отказа на инструментированном malloc увеличивается на 1, и тест повторяется. Цикл продолжается до тех пор, пока вся операция не завершится, ни разу не столкнувшись с симулированным OOM-отказом. Тесты, подобные этому, выполняются дважды, один раз с инструментированным malloc, настроенным на отказ только один раз, и второй раз с инструментированным malloc, настроенным на постоянный отказ после первого.

3.2. Тесты ошибок ввода-вывода

Тестирование I/O-ошибок проверяет, как SQLite реагирует на неудачные операции ввода-вывода. Ошибки ввода-вывода могут возникать из-за переполнения диска, аппаратной неисправности, сбоев сети (при использовании распределенной файловой системы), изменения в конфигурации системы или в правах доступа при выполнении операций SQL, или других сбоев оборудования или операционной системы.

Тестирование ошибок ввода-вывода схоже по концепции с OOM-тестированием, описанным выше; имитируются ошибки, и проводятся проверки реакции SQLite. Ошибки ввода/вывода имитируются в тестовых пакетах TCL и TH3 путем вставки нового объекта виртуальной файловой системы, который настроен конкретно на имитацию ошибки ввода/вывода после заданного количества операций ввода/вывода. Как и при тестировании ошибок OOM выше, симуляторы I/O-ошибок можно настроить на однократный сбой или на непрерывный сбой после первого сбоя. Тесты запускаются в цикле, постепенно увеличивая порог отказа, пока тест-кейс не пройдет без ошибок. Цикл запускается дважды, один раз с симулятором ошибок настроенным на имитацию только одного сбоя, а второй раз настроенным на сбой всех операций ввода/вывода после первого сбоя.

В тестах на I/O-ошибки, после отключения механизма имитации сбоя, база данных проверяется с помощью PRAGMA integrity_check, чтобы убедиться, что ошибка ввода-вывода не привела к повреждению базы данных.

3.3. Crash-тесты

Краш-тестирование призвано продемонстрировать, что база данных SQLite не будет повреждена в случае сбоя приложения или операционной системы или отключения питания в процессе обновления БД. В отдельном документе под названием Atomic Commit в SQLite описываются мероприятия SQLite для предотвращения повреждения баз данных после сбоя. Краш-тесты позволяют убедиться в том, что эти мероприятия работают корректно.

Разумеется, проводить краш-тесты с использованием реальных сбоев питания непрактично, поэтому краш-тесты проводятся в режиме симуляции. Создается виртуальная файловая система, которая позволяет тестовому пакету имитировать состояние файла БД после сбоя.

В тестовом TCL-пакете имитация сбоя выполняется в отдельном процессе. Основной процесс тестирования порождает дочерний процесс, который запускает SQLite-операцию и случайным образом выдает сбой во время операции записи. Специальная VFS случайным образом реорганизует и повреждает несинхронизированные операции записи, чтобы имитировать результат этого в буферизованной файловой системе. После закрытия дочернего процесса, изначальный тестовый процесс открывает и читает тестовую БД и проверяет, что изменения, которые пытался внести дочерний процесс, либо успешно завершились, либо был откат к предыдущему состоянию. Чтобы убедиться в отсутствии повреждений базы данных, используется PRAGMA integrity_check.

Тестовый TH3-пакет должен отработать на embedded-системах, которые не всегда имеют возможность порождать дочерние процессы, поэтому для имитации сбоев используется in-memory VFS. Ее можно настроить так, чтобы она делала снимок всей файловой системы после заданного количества операций ввода-вывода. Краш-тесты запускаются в цикле. На каждой итерации цикла — точка, в которой делается снимок, сдвигается до тех пор, пока тестируемые операции не завершатся без создания снимка. Внутри цикла, после завершения тестируемой операции, файловая система возвращается к снимку, и создается случайное повреждение файлов, характерное для тех видов повреждений, которые можно ожидать после потери питания. Затем открывается база данных и проверяется, что она правильно сформирована, и что транзакция либо выполнена до конца, либо возвращена к предыдущему состоянию. Внутренняя часть цикла повторяется несколько раз для каждого снимка с разными случайными повреждениями.

3.4. Тестирование сложных отказов

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

4. Fuzz-тесты

Fuzz-тестирование проверяет, что SQLite корректно реагирует на невалидные, выходящие за пределы диапазона, или неправильно сформированные входные данные.

4.1. Fuzz-тесты SQL

Fuzz-тестирование заключается в генерировании синтаксически правильных, но дико нелепых SQL-запросов и передаче их в SQLite, чтобы посмотреть, что он с ними сделает. Обычно возвращается какая-нибудь ошибка (например «нет такой таблицы»). Иногда, чисто случайно, SQL-оператор оказывается семантически корректным. В этом случае запускается результирующий подготовленный оператор, чтобы убедиться, что он выдает приемлемый результат.

4.1.1. Фазз-тесты с помощью The American Fuzzy Lop Fuzzer

Концепция фазз-тестирования существует уже несколько десятилетий, но оно было неэффективным до 2014 года, когда Михал Залевски изобрел первый практически ориентированный фаззер с профилями, American Fuzzy Lop (AFL). В отличие от предыдущих фаззеров, которые тупо генерировали рендомные данные, AFL инструментирует тестируемую программу (путем модификации ассемблерного вывода компилятором C) и использует эту инструментацию для обнаружения событий, когда входные данные заставляют программу делать что-то неправильное — следовать по новому пути выполнения или неправильно зацикливаться. Входные данные, которые провоцируют новое поведение, сохраняются и мутируют далее. Таким образом, AFL может обнаружить новое поведение тестируемой программы, включая поведение, которое не было предусмотрено разработчиками.

AFL был полезен в поиске сложных ошибок в SQLite. В основном это были ассерты — assert(), в которых условие оказывалось ложным при неясных обстоятельствах. AFL также обнаруживал достаточное количество креш-багов в SQLite, и даже было несколько случаев, когда SQLite выдавал неправильные результаты.

AFL был стандартной составляющей стратегии тестирования SQLite, начиная с версии 3.8.10 (2015-05-07), пока в версии 3.29.0 (2019-07-10) не был вытеснен более совершенными фаззерами.

4.1.2. Google OSS Fuzz

В 2016 году команда инженеров Google запустила проект OSS Fuzz. OSS Fuzz использует управляемый фаззер в стиле AFL, работающий на инфраструктуре Google. Фаззер автоматически загружает последние регистрации тестируемых проектов, проверяет их и отправляет письма разработчикам, сообщая о найденных проблемах. Когда багфикс зарегистрирован, фаззер автоматически обнаруживает это и отправляет разработчикам подтверждение на почту.

SQLite — один из многих проектов с открытым исходным кодом, которые тестирует OSS Fuzz. Исходный файл test/ossfuzz.c в репозитории SQLite — это интерфейс SQLite к OSS fuzz.

OSS Fuzz больше не находит старые баги в SQLite. Но он все еще работает и иногда находит проблемы в новых проверках разработки. Примеры: [1] [2] [3].

4.1.3. Фаззер Dbsqlfuzz

Начиная с конца 2018 года, SQLite подвергается фаззированию с помощью собственного фаззера под названием «dbsqlfuzz». Dbsqlfuzz построен с использованием LLVM-фреймворка libFuzzer.

Фаззер dbsqlfuzz одновременно мутирует и входной SQL-файл, и файл базы данных. Dbsqlfuzz использует кастомный мутатор типа Structure-Aware Mutator на специализированном входном файле, который определяет как исходную базу данных, так и SQL-текст для выполнения в этой БД. Поскольку он одновременно мутирует и входную БД, и входной SQL-текст, dbsqlfuzz находил некоторые сложные баги в SQLite, которые были пропущены предыдущими фаззерами, мутировавшими только входной SQL или только файл БД. Разработчики SQLite держат dbsqlfuzz постоянно запущенным примерно на 16 ядрах. Каждый экземпляр программы dbsqlfuzz способен обрабатывать около 400 тест-кейсов в секунду, что означает, что ежедневно выполняется около 500 миллионов тест-кейсов.

Фаззер dbsqlfuzz очень успешно защищает кодовую базу SQLite от внешних атак. С тех пор как dbsqlfuzz добавлен во внутренний тест-сьют SQLite, баг-репорты от внешних фаззеров, таких как OSSFuzz, практически прекратились.

Обратите внимание, что dbsqlfuzz не является фаззером SQLite типа structure-aware на основе Protobuf, который используется в Chromium и описан в статье о Structure-Aware Mutator. Между этими двумя фаззерами нет никакой связи, кроме того, что они оба основаны на libFuzzer. Protobuf-фаззер для SQLite написан и поддерживается командой Chromium в Google, а dbsqlfuzz написан и поддерживается разработчиками SQLite. Наличие нескольких независимо разработанных фаззеров для SQLite — это хорошо, так как повышает вероятность обнаружения сложных багов.

4.1.4. Другие third-party фаззеры

SQLite является популярной целью для взломщиков. Разработчики замечали много попыток взломать SQLite и время от времени получают сообщения об уязвимостях, найденных независимыми экспертами. Все такие сообщения оперативно обрабатываются, что позволяет улучшить продукт и принести пользу всему сообществу пользователей SQLite. Множество независимых тестировщиков по закону Линуса: «при достаточном количестве глаз все баги заметны».

Одним из исследователей фаззинга, заслуживающим особого внимания, является Мануэль Риггер, который в работает в университете ETH Zurich. Большинство фаззеров ищут только ошибки ассертов, креши, неопределенное поведение (undefined behavior, UB) или другие легко обнаруживаемые аномалии. Фаззеры доктора Риггера, напротив, способны находить кейсы, когда SQLite выдает неверный ответ. Риггер нашел множество таких кейсов. Большинство из этих находок — неясные так называемые угловые случаи, связанные с преобразованиями типов и аффинными преобразованиями, и значительная часть находок относится к фичам, еще не выпущенным официально. Тем не менее, его находки важны, так как это реальные баги, и разработчики SQLite благодарны за возможность выявить и исправить лежащие в их основе проблемы.

4.1.5. Тестовая обвязка fuzzcheck

Исторические тест-кейсы из AFL, OSS Fuzz и dbsqlfuzz собираются в набор файлов БД в основном дереве исходников SQLite и затем повторно запускаются утилитой fuzzcheck каждый раз, когда выполняется «make test». Fuzzcheck проверяет только несколько тысяч самых «интересных» из миллиардов кейсов, которые различные фаззеры исследовали за годы работы. Интересные кейсы — это кейсы, демонстрирующие ранее не проверенное поведение. Реальные баги, найденные фаззерами, всегда включаются в число интересных тест-кейсов, но большинство кейсов, запускаемых fuzzcheck, никогда не стали реальными багами.

4.1.6. Fuzz vs 100% MC/DC

Фазз-тестирование и тестирование на 100% покрытия MC/DC находятся в противоречии друг с другом. Иными словами, код, протестированный на 100% путей MC/DC, будет более уязвим, найденных фаззинг-тестированием, а код, который хорошо себя показал во время фазз-тестирования, будет иметь (намного) меньше багов, чем со 100%-но протестированными MC/DC-путями. Это потому, что тестирование MC/DC не проверяет защитный код с недостижимым ветвлением, а без защитного кода фаззер с большей вероятностью найдет путь, вызывающий проблемы. MC/DC-тестирование подходит для кода, устойчивого при стандартном использовании, в то время как fuzz-тестирование хорошо для проверки кода, который устойчив к вредоносным атакам.

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

На протяжении большей части своей истории SQLite был ориентирован на 100%-е тестирование MC/DC. Устойчивость к фаззинговым атакам стала проблемой только с появлением AFL в 2014 году. В течение некоторого времени фаззеры находили множество проблем в SQLite. В последние годы стратегия тестирования SQLite изменилась в сторону бОльшего внимания к фазз-тестированию. Мы по-прежнему поддерживаем 100%-е покрытие путей MC/DC основного кода SQLite, но большая часть процессорных циклов тестирования теперь посвящена фаззингу.

Хотя фазз-тестирование и 100% MC/DC-тестирование находятся в противоречии, они не являются полностью противоположными. Тот факт, что тестовый набор SQlite тестирует MC/DC на 100%, означает, что когда фаззеры находят проблемы, их можно быстро исправить с небольшим риском появления новых ошибок.

4.2.Неправильно сформированная БД

Существует множество тест-кейсов, которые проверяют, что SQLite способен работать с неправильно сформированными (malformed) файлами БД. В этих тестах сначала создается правильно сформированный файл, затем добавляется повреждение путем изменения одного или нескольких байтов в файле каким-либо способом, независящим от SQLite. Затем SQLite читает эту БД. В некоторых случаях байты изменяются внутри данных. Это приводит к изменению содержимого БД при сохранении ее правильной формы. В других кейсах изменяются неиспользуемые байты файла, что не влияет на целостность БД. Интересны кейсы, когда изменяются байты файла, определяющие структуру БД. Тесты на неправильную форму БД проверяют, что SQLite находит ошибки формата файла и сообщает о них с помощью кода возврата SQLITE_CORRUPT, не переполняя буферы, не затрагивая NULL-указатели, и не выполняя других нежелательных действий.

Фаззер dbsqlfuzz также отлично справляется с проверкой, что SQLite нормально реагирует на неправильно сформированные файлы БД.

4.3. Граничные значения

SQLite поддерживает определенные ограничения, такие как максимальное количество столбцов в таблице, максимальная длина SQL-оператора или максимальное значение целого числа. Тест-сьюты TCL и TH3 содержат множество тестов, которые заставляют SQLite выйти за пределы установленных ограничений и проверяют, что он работает правильно для всех допустимых значений. Еще дополнительные тесты выходят за установленные пределы и проверяют, что SQLite корректно возвращает ошибки. Исходный код содержит макросы тест-кейсов проверяющие обе стороны каждой границы.

5. Регрессы

Когда сообщают о баге в SQLite, он не считается пофикшенным до тех пор, пока в тестовые наборы TCL или TH3 не будут добавлены новые тест-кейсы, в которых этот баг появляется. За прошедшие годы это привело к появлению тысяч и тысяч новых тестов. Регрессионные тесты гарантируют, что ошибки, которые были исправлены в прошлом, не появятся вновь в будущих версиях SQLite.

6. Автоматическое обнаружение утечек ресурсов

Утечка ресурсов происходит, когда системные ресурсы выделяются, но не освобождаются. Самыми проблемными утечками во многих приложениях являются утечки памяти — когда память выделяется с помощью malloc(), но не освобождается с помощью free(). Утечка может происходить и с другими видами ресурсов: дескрипторами файлов, потоками, мьютексами и т. д.

Оба тестовых набора TCL и TH3 автоматически отслеживают системные ресурсы и сообщают об утечках ресурсов при каждом запуске теста. Никакой специальной настройки или установки не требуется. Особенно бдительно тестовые наборы относятся к утечкам памяти. Если изменение приводит к утечке памяти, тестовые наборы быстро распознают это. SQLite спроектирован таким образом, чтобы никогда не допускать утечек, даже после таких серьезных исключений, как OOM-ошибка или ошибка ввода-вывода с диска. Тестовые наборы ревностно следят за соблюдением этого.

7. Тестовое покрытие

Ядро SQLite, включая unix VFS, имеет 100-процентное тестовое покрытие ветвей в TH3-наборе в дефолтной конфигурации, как показывает gcov. Расширения, такие как FTS3 и RTree, исключены из этого анализа.

7.1. Покрытие операторов vs покрытие ветвлений

Существует множество способов измерения тестового покрытия. Наиболее популярной метрикой является покрытие операторов (statement coverage). Когда вы слышите, что у какого-то приложения «XX% тестового покрытия» без дополнительных объяснений, обычно имеют в виду именно покрытие операторов. Оно измеряет, какой процент строк кода выполняется хотя бы один раз в тестовом наборе.

Покрытие ветвей (ветвлений, branch coverage) является более «строгим» показателем. Покрытие ветвей измеряет количество инструкций ветвления машинного кода, которые выполняются хотя бы один раз (в обоих направлениях).

Чтобы проиллюстрировать разницу между покрытием операторов и покрытием ветвлений, рассмотрим следующую гипотетическую строку кода на языке C:

if( a>b && c!=25 ){ d++; }

Такая строка кода может генерировать дюжину отдельных инструкций машинного кода. Если хоть одна из этих инструкций будет протестирована, то мы говорим, что оператор был протестирован. Так, например, может оказаться, что условное выражение всегда ложно и переменная «d» никогда не увеличивается. Но даже в этом случае покрытие оператора считает эту строку кода протестированной.

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

  • a<=b
  • a>b && c==25
  • a>b && c!=25

Любой из приведенных выше кейсов обеспечивает 100-процентное покрытие операторов, но для 100-процентного покрытия ветвей требуются все три. Вообще говоря, 100 %-ное покрытие ветвей подразумевает 100 %-ное покрытие операторов, но обратное не верно. Еще раз подчеркнем, что тестовый пакет TH3 для SQLite обеспечивает более надежную форму тестового покрытия — 100% покрытие ветвей.

7.2. Тестирование defensive-кода

Хорошо написанная программа на C обычно содержит некоторые защитные условия (defensive conditionals), которые на практике всегда истинны или всегда ложны. Это приводит к дилемме: удалять ли защитный код, чтобы получить 100-процентное покрытие ветвей?

В SQLite ответ на предыдущий вопрос — «нет». Для целей тестирования в исходном коде SQLite определены макросы ALWAYS() и NEVER(). Макрос ALWAYS() охватывает условия, которые, как ожидается, всегда будут оцениваться как истинные, а NEVER() охватывает условия, которые всегда будут оцениваться как ложные. Эти макросы служат в качестве комментариев, указывающих на то, что условия являются защитным кодом. В релизных билдах эти макросы являются «проходными»:

#define ALWAYS(X)  (X)
#define NEVER(X)   (X)

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

#define ALWAYS(X)  ((X)?1:assert(0),0)
#define NEVER(X)   ((X)?assert(0),1:0)

При измерении тестового покрытия эти макросы определяются как постоянные значения «истинно», так что они не генерируют инструкции ветвления на ассемблере и, следовательно, не участвуют в расчете покрытия ветвей:

#define ALWAYS(X)  (1)
#define NEVER(X)   (0)

Набор тестов рассчитан на три прогона, по одному разу для каждого из определений ALWAYS() и NEVER(), показанных выше. Все три прогона должны дать одинаковый результат. Существует тест времени выполнения, использующий интерфейс sqlite3_test_control(SQLITE_TESTCTRL_ALWAYS…), который можно использовать для проверки правильности установки макросов в первую форму («проходную») для деплоя.

7.3. Повышение покрытия граничных значений и тесты значений boolean vector 

Еще один макрос, используемый при измерении тестового покрытия, — это макрос testcase(). Его аргументом является условие, для которого мы хотим получить тест-кейсы, оцениваемые как true, так и false. В сборках без акцента на покрытии (то есть в релизных сборках) макрос testcase() не используется:

#define testcase(X)

Но в сборке с измерением покрытия макрос testcase() генерирует код, который оценивает условное выражение в своем аргументе. Затем во время анализа проверяется, существуют ли тесты, которые оценивают условное выражение как на true, так и на false. Макросы testcase() используются, например, для проверки того, что протестированы граничные значения. Например:

testcase( a==b );
testcase( a==b+1 );
if( a>b && c!=25 ){ d++; }

Макросы тест-кейсов также используются, когда два или более кейсов оператора switch переходят в один и тот же блок кода, чтобы убедиться, что код был проверен для всех кейсов:

switch( op ){
  case OP_Add:
  case OP_Subtract: {
    testcase( op==OP_Add );
    testcase( op==OP_Subtract );
    /* ... */
    break;
  }
  /* ... */
}

Для тестов с битовой маской используются макросы testcase(), чтобы убедиться, что каждый бит битовой маски влияет на результат. Например, в следующем блоке кода условие истинно, если маска содержит один из двух битов, указывающих на то, что открыта либо MAIN_DB, либо TEMP_DB. Макросы testcase(), которые предшествуют оператору if, проверяют, что тестируются оба кейса:

testcase( mask & SQLITE_OPEN_MAIN_DB );
testcase( mask & SQLITE_OPEN_TEMP_DB );
if( (mask & (SQLITE_OPEN_MAIN_DB|SQLITE_OPEN_TEMP_DB))!=0 ){ ... }

В исходном коде SQLite содержится 1184 использования макроса testcase().

7.4. MC/DC vs Branch coverage

Выше были описаны два метода измерения тестового покрытия: покрытие операторов и покрытие ветвей. Существует множество других метрик тестового покрытия, помимо этих двух. Еще одна популярная метрика — модифицированное покрытие условий/решений или MC/DC. Википедия определяет MC/DC так:

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

В языке C, где && и || являются операторами «короткого замыкания», MC/DC и покрытие ветвей — это почти одно и то же. Основное различие заключается в тестах булевых векторов. Можно протестировать любой из нескольких битов битового вектора и все равно получить 100-процентное покрытие тестов ветвления, даже если второй элемент MC/DC; а требование, чтобы каждое условие в решении принимало все возможные исходы, может не соблюдаться.

SQLite использует макросы testcase(), как описано в предыдущей главе, чтобы проверить, что каждое условие в решении битового вектора принимает все возможные исходы. Таким образом, SQLite достигает 100% покрытие MC/DC в дополнение к 100% покрытию ветвей.

7.5. Метрики покрытия ветвей

Покрытие ветвей в SQLite в настоящее время измеряется с помощью gcov с опцией «-b». Сначала компилируется тестовая программа с помощью опции «-g -fprofile-arcs -ftest-coverage», затем тестовая программа запускается. Затем запускается «gcov -b» для создания репорта о покрытии. Репорт является очень большим и неудобным для чтения, поэтому сгенерированный gcov репорт обрабатывается с помощью простых скриптов, чтобы привести его в удобочитаемый формат. Разумеется, весь этот процесс автоматизирован с помощью скриптов.

Обратите внимание, что запуск SQLite с помощью gcov не является тестированием SQLite — это тестирование тестового набора. Запуск gcov не тестирует SQLite, потому что опции -fprofile-args и -ftest-coverage заставляют компилятор генерировать другой код. Прогон gcov просто проверяет, что тестовый набор обеспечивает 100 % покрытие ветвей. Прогон gcov это тест теста — мета-тест.

После запуска gcov для проверки 100-процентного покрытия ветвей, тестовая программа перекомпилируется с использованием delivery-опций компилятора (без специальных опций -fprofile-arcs и -ftest-coverage), и тестовая программа запускается повторно. Этот второй запуск и есть реальное тестирование SQLite.

Важно убедиться, что тестовый прогон gcov и второй реальный прогон дают одинаковые результаты. Любые различия в результатах указывают либо на неопределенное или неопределяемое поведение в коде SQLite (и, следовательно, на ошибку), либо на ошибку в компиляторе. Обратите внимание, что за последнее десятилетие в SQLite были обнаружены ошибки в GCC, Clang и MSVC. Ошибки компилятора, хоть и редко, но случаются, поэтому так важно тестировать код в конфигурации «как есть».

7.6. Мутационное тестирование

Использование gcov (или подобного) для демонстрации того, что каждая инструкция ветвления выполняется хотя бы один раз в обоих направлениях, является хорошим показателем качества тестового набора. Но еще лучше показать, что каждая инструкция ветвления вносит разницу в вывод. Другими словами, мы хотим показать не только то, что каждая инструкция ветвления как «перепрыгивает», так и «проваливается», но и то, что каждая ветвь выполняет полезную работу и что тестовый набор способен обнаружить и проверить это. Если найдено ветвление, которое не меняет результат, это говорит о том, что код, связанный с этим ветвлением, можно удалить (уменьшив размер библиотеки и, возможно, ускорив ее работу) или что тестовый набор неадекватно тестирует функцию, которую это ветвление реализует.

SQLite стремится убедиться, что каждая инструкция ветвления имеет значение, используя мутационное тестирование. Сначала скрипт компилирует исходный код SQLite в ассемблер-код (например, с помощью опции -S в gcc). Затем скрипт просматривает сгенерированный ассемблер-код и одну за другой изменяет каждую инструкцию ветвления либо на безусловный переход, либо на no-op, компилирует результат и проверяет, что тестовый набор «поймал» мутацию.

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

55  static unsigned int strHash(const char *z){
56    unsigned int h = 0;
57    unsigned char c;
58    while( (c = (unsigned char)*z++)!=0 ){     /*OPTIMIZATION-IF-TRUE*/
59      h = (h<<3) ^ h ^ sqlite3UpperToLower[c];
60    }
61    return h;
62  }

Если инструкцию ветвления, реализующую тест «c!=0» в строке 58, заменить на no-op, то цикл while-loop будет циклиться вечно, и тестовый набор завершится по тайм-ауту. Но если это ответвление заменить на безусловный переход, то хэш-функция всегда будет возвращать 0. Проблема в том, что 0 — это правильный хэш. Хэш-функция, которая всегда возвращает 0, все равно работает, в том смысле, что SQLite все равно всегда получает правильный ответ. Хэш-таблица table-name «вырождается» в связный список linked-list, поэтому поиск table-name при обработке SQL-операторов может быть немного медленнее, но конечный результат будет тот же.

Чтобы обойти эту проблему, в исходный код SQLite вставляются комментарии вида «/*OPTIMIZATION-IF-TRUE*/» и «/*OPTIMIZATION-IF-FALSE*/», которые заставляют скрипт мутационного тестирования игнорировать некоторые инструкции ветвления.

7.7. 100% тестовое покрытие

Разработчики SQLite обнаружили, что достижение полного покрытия является чрезвычайно эффективным методом поиска и предотвращения ошибок. Поскольку каждая инструкция ветвления в коде ядра SQLite покрывается тест-кейсами, разработчики могут быть уверены, что изменения, внесенные в одну часть кода, не приведут к непредвиденным последствиям в других частях кода. Множество новых функций и улучшений производительности, которые были добавлены в SQLite за последние годы, были бы невозможны без тестирования с полным 100% покрытием.

Поддержание 100% уровня MC/DC требует больших усилий и времени. Количество труда, необходимого для поддержания полного покрытия, вероятно, не является экономически эффективным для обычного приложения. Однако мы считаем, что 100% тестирование оправдано для такой популярной вещи как SQLite, и это особенно актуально для библиотеки баз данных, которая по своей природе «помнит» прошлые ошибки.

8. Динамический анализ

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

8.1. Ассерты

Ядро SQLite содержит 6754 оператора assert(), которые проверяют предусловия и постусловия функций и инварианты циклов. Assert() — это макрос, который является стандартной частью ANSI-C. Аргументом является булево значение, которое предполагается всегда истинным. Если ассерт-утверждение ложно, программа выводит сообщение об ошибке и останавливает выполнение.

Макросы Assert() отключаются при компиляции с определенным макросом NDEBUG. В большинстве систем ассерты включены по умолчанию. Но в SQLite ассерты настолько многочисленны и находятся в таких критичных для производительности местах, что движок базы данных работает примерно в три раза медленнее, когда ассерты включены. Поэтому в стандартной (production) сборке SQLite ассерты отключены. Ассерты включаются только в том случае, если SQLite скомпилирован с заданным макросом препроцессора SQLITE_DEBUG.

О том, как SQLite использует assert(), см. в документе Use Of assert in SQLite.

8.2. Valgrind

Valgrind — пожалуй, самый удивительный и полезный инструмент разработчика в мире. Valgrind — это симулятор, он симулирует x86, на которой запущен бинарный файл Linux. (Порты Valgrind для других платформ находятся в разработке, но на момент написания этой статьи Valgrind надежно работает только в Linux, что, по мнению разработчиков SQLite, означает, что Linux должен быть предпочтительной платформой для разработки любого программного обеспечения). Когда Valgrind запускает бинарник Linux, он ищет всевозможные интересные ошибки, такие как выход за границы массива, чтение из неинициализированной памяти, переполнение стека, утечки памяти и так далее. Valgrind находит проблемы, которые могут легко проскочить незамеченными через все другие тесты. А когда Valgrind находит ошибку, он может вывести разработчика прямо в символьный отладчик в точке, где происходит ошибка, чтобы ускорить исправление.

Поскольку это симулятор, запуск бинарных файлов в Valgrind происходит медленнее, чем на нативном железе. (В первом приближении, приложение, запущенное в Valgrind на рабочей станции, будет работать примерно так же, как и на смартфоне). Поэтому нецелесообразно пропускать полный тестовый набор SQLite через Valgrind. Однако veryquick-тесты и тесты покрытия в наборе TH3 проходят через Valgrind перед каждым релизом.

8.3. Memsys2

SQLite содержит подключаемую подсистему распределения памяти. Имплементация по умолчанию использует системные malloc() и free(). Однако если SQLite скомпилирован с параметром SQLITE_MEMDEBUG, то в систему вставляется альтернативная обертка выделения памяти (memsys2), которая ищет ошибки выделения памяти во время выполнения. Обертка memsys2 проверяет утечки памяти, но также ищет выходы за пределы буфера, использование неинициализированной памяти и попытки использовать память после того как она была освобождена. Эти же проверки выполняет и valgrind (и, более того, Valgrind делает это лучше), но преимущество memsys2 в том, что он намного быстрее Valgrind, а значит, проверки можно проводить чаще и для более объемных тестов.

8.4. Mutex-ассерты

SQLite содержит подключаемую подсистему мьютексов. В зависимости от опций компиляции, система мьютексов по умолчанию содержит интерфейсы sqlite3_mutex_held() и sqlite3_mutex_notheld(), которые определяют, удерживается ли конкретный мьютекс вызывающим потоком или нет. Эти два интерфейса широко используются в операторах assert() в SQLite для проверки того, что мьютексы удерживаются и освобождаются в нужные моменты — чтобы перепроверить, что SQLite корректно работает в многопоточных приложениях.

8.5. Тесты журнала откатов

Для обеспечения атомарности транзакций при системных сбоях и отключениях питания SQLite записывает все изменения в файл журнала откатов перед изменением базы данных. Тестовый пакет TCL содержит альтернативную имплементацию бэкенда ОС, которая помогает проверить правильность этого процесса. VFS «journal-test» отслеживает весь дисковый трафик ввода-вывода между файлом базы данных и журналом отката, проверяя, что в файл базы данных не записывается ничего, что не было бы сначала записано и синхронизировано в журнале отката. Если обнаружены какие-либо несоответствия, выдается ошибка ассерта.

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

8.6. Тестирование undefined-поведения

В языке C очень легко написать код, который имеет «неопределенное» или «определяемое имплементацией» поведение. Это означает, что код может работать на одной машине, но не работать на другой, или не работать при перекомпиляции с другими опциями компилятора. Примеры неопределенного и определенного имплементацией поведения в ANSI C включают:

  • Переполнение целых чисел типа signed integer. (Переполнение этих чисел не обязательно оборачивается, как считает большинство разработчиков).
  • Сдвиг N-битного целого числа более чем на N бит.
  • Сдвиг на отрицательную величину.
  • Сдвиг отрицательного числа.
  • Использование функции memcpy() в перекрывающихся буферах.
  • Порядок вычисления аргументов функции.
  • Являются ли переменные «char» signed или unsigned.
  • И так далее

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

Чтобы убедиться, что SQLite не использует неопределенное или определенное имплементацией поведение, тестовые наборы запускаются с использованием инструментированных билдов, которые пытаются обнаружить неопределенное поведение. Например, тестовые наборы запускаются с опцией «-ftrapv» в GCC. И снова запускаются с опцией «-fsanitize=undefined» в Clang. И снова с помощью опции «/RTC1» в MSVC. Затем тестовые наборы повторно запускаются с опциями «-funsigned-char» и «-fsigned-char», чтобы убедиться, что различия в имплементации также не значимые. Затем тесты повторяются на 32- и 64-битных системах, на big-endian и little-endian системах, на различных процессорных архитектурах. Кроме того, в наборы включены множество тест-кейсов, которые намеренно разработаны так, чтобы спровоцировать неопределенное поведение. Например: «SELECT -1*(-9223372036854775808);».

9. Тесты с отключением оптимизации

Интерфейс sqlite3_test_control(SQLITE_TESTCTRL_OPTIMIZATIONS, …) позволяет отключать выбранные оптимизации SQL-операторов во время выполнения. SQLite всегда должен генерировать абсолютно одинаковый ответ с включенными и отключенными оптимизациями; просто при включенных оптимизациях ответ приходит быстрее. Поэтому в production-окружении оптимизацию всегда оставляют включенной (по умолчанию).

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

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

10. Чеклисты

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

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

Чеклист релизов не автоматизирован: разработчики выполняют каждый пункт вручную. Мы считаем, что важно держать человека в курсе событий. Иногда проблемы обнаруживаются во время выполнения того или иного пункта, даже если сам тест прошел. Важно, чтобы человек проверял результаты тестирования на самом высоком уровне и постоянно спрашивал: «Все правильно?»

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

11. Статический анализ

Статический анализ — это анализ исходного кода во время компиляции для проверки его корректности. Статический анализ включает в себя предупреждения компилятора и более глубокие механизмы анализа, такие как Clang Static Analyzer. SQLite компилируется без предупреждений в GCC и Clang с флагами -Wall и -Wextra в Linux и Mac и в MSVC в Windows. Инструмент статического анализатора Clang «scan-build» также не выдает никаких предупреждений (хотя последние версии clang, похоже, генерируют много ложных срабатываний). Тем не менее, некоторые предупреждения могут быть выданы другими статическими анализаторами. Пользователям рекомендуется не напрягаться по поводу этих предупреждений и удовлетвориться интенсивным тестированием SQLite, описанным выше.

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

Итак

СУБД SQLite — с открытыми исходниками. Это создает у некоторых людей представление о том, что она недостаточно хорошо тестируется, хуже чем типичное коммерческое программное обеспечение и, возможно, ненадежна. Но это представление ошибочно. SQLite демонстрирует очень высокую надежность в работе и очень низкий уровень дефектов, особенно если учесть, как быстро развивается эта система. Качество SQLite достигается отчасти за счет тщательного проектирования и имплементации кода. Однако обширное тестирование также играет весьма важную роль в поддержании и улучшении качества SQLite. Выше кратко описаны процедуры тестирования, которым подвергается каждый релиз SQLite, чтобы укрепить уверенность в том, что SQLite подходит для использования в критически важных приложениях.»

Источник


Какой была ваша первая зарплата в QA и как вы искали первую работу?

Мега обсуждение в нашем телеграм-канале о поиске первой работы. Обмен опытом и мнения.

Подписаться
Уведомить о
guest

0 комментариев
Межтекстовые Отзывы
Посмотреть все комментарии

Мы в Telegram

Наш официальный канал
Полезные материалы и тесты
Готовимся к собеседованию
Project- и Product-менеджмент

? Популярное

? Telegram-обсуждения

Наши подписчики обсуждают, как искали первую работу в QA. Некоторые ищут ее прямо сейчас.
Наши подписчики рассказывают о том, как не бояться задавать тупые вопросы и чувствовать себя уверенно в новой команде.
Обсуждаем, куда лучше податься - в менеджмент или по технической ветке?
Говорим о конфликтных ситуациях в команде и о том, как их избежать
$1100*
медианная зарплата в QA в июне 2023

*по результатам опроса QA-инженеров в нашем телеграм-канале

Собеседование

19%*
IT-специалистов переехало или приняло решение о переезде из России по состоянию на конец марта 2022

*по результатам опроса в нашем телеграм-канале

live

Обсуждают сейчас