Юнит-тестирование на уровне базы данных PostgreSQL

Страницы:  1

Ответить
 

Professor Seleznov


pic
Юнит-тесты в PostgreSQL, как и в других базах данных, не являются обязательными для CI/CD, но они крайне важны и фактически становятся стандартом. С помощью этих юнит-тестов мы уже нашли и исправили много ошибок в функциях на уровне БД, а также сократили загрузку ручных тестировщиков.
Привет, Хабр! В этой статье мы, старший разработчик Анастасия Цацкина и старший инженер-тестировщик Владимир Белинский из IBS, расскажем о нашем опыте внедрения юнит-тестирования на уровне БД.
Зачем нужно юнит-тестирование функций на уровне БД
Юнит-тестирование — важная часть поддержания качества приложения и процесса разработки. Оно позволяет:
  • Обнаруживать ошибки на ранних этапах. Можно выявлять ошибки до публикации на продуктовые сервера и влияния на другие функции.
  • Предотвратить регрессию.Юнит-тесты значительно облегчают процесс рефакторинга и изменение кода, т. к. легко убедиться в корректности изменений и в том, что изменения не сломали уже работающую функциональность. Это особенно важно в больших командах и при наличии зависимостей между функциями.
  • Оптимизировать производительность. Юнит-тесты позволяют вовремя выявлять деградацию производительности, например, при увеличении объема данных либо при изменении связанной функции.
  • Поддержать CI/CD. Можно реализовать быструю автоматическую проверку качества кода перед его деплоем. 
  • Повышать доверие к системе со стороны пользователей. Можно улучшить общую стабильность и надежность системы. Ошибки не попадают на продуктовые сервера, пользователи с ними не сталкиваются, соответственно, растет их доверие к системе.
Именно для PostgreSQL есть важный нюанс: здесь можно создать абсолютно неработающую функцию, потому что код валидируется в момент исполнения. Без юнит-тестов такая функция может бесконтрольно пройти по всему конвейеру CI/CD и дойти до сервера назначения со всеми вытекающими рисками и проблемами.
При этом юнит-тестирование в сочетании с инструментами CI/CD — это удобно, поскольку запуск тестов можно автоматизировать, настроив систему мониторинга. Так разработчики могут тестировать изменяемые функции при каждом коммите.
Почему юнит-тестирование функций в PostgreSQL внедряется редко?
Юнит-тестирование функции баз данных в целом и в PostgreSQL в частности не так широко распространено, как тестирование приложений на более высоком уровне. Часто связано это с тем, что:
  • У разработчиков баз данных отсутствует опыт в написании юнит-тестов. Тесты как сущность достаточно сильно отличаются от функций с логикой, к которым привыкли разработчики, поэтому внедрение юнит-тестов требует дополнительного обучения, освоения новых инструментов и навыков. 
  • Существует мнение, что юнит-тесты на уровне баз данных — это неэффективно и слишком сложно.
  • Не все понимают, как юнит-тесты могут повысить качество кода приложений и снизить риски.
  • В командах бывают барьеры для внедрения нового или нет отклика на такую идею со стороны руководителя проекта и команды разработки.
  • Если на проекте уже внедрены какие-то инструменты CI/CD, то не все они поддерживают нативную интеграцию с фреймворками для юнит-тирования. PostgreSQL сам по себе тоже может быть препятствием. 
  • Естественно, на написание юнит-тестов тратится определенное время. Не все готовы на это идти.
  • Часто есть фокус на других вариантах тестирования, например интеграционном и ручном. Безусловно, они также важны, у них свои цели и задачи, но они не заменяют юнит-тестов, а скорее дополняют их.
В начале внедрения для нас также была актуальна часть этих пунктов. Мы преодолели их с помощью системного подхода и обучения, о которых и расскажем далее. Мы не ставили амбициозных целей, допустим, покрыть весь проект за два месяца. Понимали, что это долгосрочная инвестиция, которая, безусловно, окупится. 
Юнит-тесты на нашем проекте
Мы начали внедрение юнит-тестирования с одного из наших проектов. 
В двух словах об архитектуре
В нашем проекте база агрегирует данные из различных источников. На их основе впоследствии рассчитываются всевозможные метрики для дашбордов, которые потом используются для принятия управленческих решений.
Хотя проект существует уже 6 лет, он продолжает развиваться и дорабатываться. Команда разработчиков базы данных состоит из пяти человек.
PostgreSQL играет на проекте ключевую роль. Это не только хранилище данных. Вся логика заложена в функции и процедуры PostgreSQL (в общей сложности их около 700).
БД имеет многослойную архитектуру:
  • Самый низкоуровневый слой — слой исходных данных.
  • Бизнес-логика, т.е. сеттеры, которые рассчитывают данные и обновляют буферные таблицы, а также геттеры, извлекающие данные из этих таблиц для конкретных методик. Этот слой извне не видно.
  • Парадный слой, где представлены геттеры, отдающие по запросу данные наружу. Именно к этому слою обращается фронт проекта и внешние пользователи.
  • Самый верхнеуровневый — слой представления данных в удобном виде для бэкенда (JSON-ы, округление, цветовые показатели и т.п.).
Зачем проекту юнит-тесты
На нашем проекте есть тесты, которые работают на уровне Java-бэкенда. Там трудится другая команда, у которой есть свои задачи. Мы пишем код базы данных, и эти тесты закрывают не все наши потребности. 
Мы взялись за внедрение юнит-тестов на этом проекте по нескольким причинам. Во-первых, планировали полноценную настройку CI/CD-конвейера. Без юнит-тестирования после окончания настройки ошибки могли бы бесконтрольно уходить на препрод. Этого допустить мы не могли.
Кроме того, как упомянуто выше, вся бизнес-логика находится у нас в функциях базы данных, при этом реализовано очень много зависимостей между функциями, и эти функции постоянно дорабатываются. А еще у нас есть внешние пользователи, которые обращаются к функциям, часто используя нетипичные для фронта срезы. Это невозможно воспроизвести на фронте, а значит, нужно тестировать после доработки функций отдельно, что усложняет процесс разработки.
Так что юнит-тесты должны были нам серьезно упростить ситуацию.
Как запустили работу
Для начала из общего объема функций мы выбрали около 180 наиболее активно дорабатываемых. Эту задачу также декомпозировали, выбрав схему, к которой больше всего обращений.
Первые тесты для нас разрабатывали коллеги-тестировщики (старший и линейный специалисты), а мы учились подходу, совместно определяя объем тест-плана и работая над кодом функции юнит-теста. Со временем стали писать тесты сами — командой в три разработчика и одного старшего тестировщика. Разработчики создают тест-планы для юнит-тестов и сами тесты, а ревью проводит старший тестировщик.
Конечно, поначалу написание тестов отнимало много времени. Но сейчас пришли к тому, что среднее время разработки одного теста — около 2 часов. Если тестируется семейство более-менее похожих функций, то дело идет быстрее. Если же мы берем в разработку новую функцию, тест для нее создается медленнее. Однако в среднем это 2 часа, что намного меньше создания непосредственно тестируемой функции.
Промежуточные итоги
Внедрение еще идет, но по первым этапам можно подвести промежуточные итоги.
Сейчас у нас написано 37 юнит-тестов, в которых выполняется более 700 только задокументированных проверок. По факту их больше, поскольку вместе с проверкой на условия мы, как правило, проверяем наличие данных по нужному срезу. Пока мы тестируем геттеры, чуть позже перейдем и к сеттерам.
Выяснилось, что само по себе написание теста уже полезно, поскольку в процессе мы находили ошибки в функциях (16 в общей сложности).
Естественно, мы заметили, что количество инцидентов по тестируемым функциям сократилось. Но самый главный эффект, который мы для себя видим, — ошибки не попадают на прод. Мы запускаем юнит-тесты перед релизом и уверены, что функция остается стабильной — мы уверены во внесенных изменениях. 
Какие используем инструменты
Для проекта мы выбрали простой в установке и использовании фреймворк PLPG Unit, разработанный как раз для решения подобных задач в PostgreSQL. Он не имеет дополнительных зависимостей и основан на языке plpgsql. Фреймворк свободно распространяется, а его лицензия допускает доработку функционала.
Вот несколько функций из этого фреймворка для примера:
  • assert.ok(text) — размещается в конце тела тестовой функции, чтобы сообщить, что тест прошел успешно;
  • assert.is_equal(IN have anyelement, IN want anyelement, OUT message text, OUT result boolean) — сравнивает два первых входных параметра и возвращает FAIL, если они не равны;
  • assert.is_less_than(IN x anyelement, IN y anyelement, OUT message text, OUT result boolean) — сравнивает два первых параметра и возвращает FAIL, если первый более или равен второму.
Хотя фреймворк PLPG Unit довольно примитивен, в этом и заключается одно из основных его преимуществ для нашей команды. С точки зрения разработчика PostgreSQL, это просто обычная функция — не нужно дополнительного обучения или навыков, чтобы им пользоваться.
Наша методология
  • Разработка юнит-теста начинается с тест-плана, который оформляется в Confluence. Это своего рода спецификация тестирующей функции.
  • Мы договариваемся не добавлять все возможные проверки сразу, чтобы был заметный прогресс в разработке юнит-тестов (иначе разработка одного теста будет занимать слишком много времени).
  • Фичи юнит-тестов, добавляемые в последующие версии, не портируются в предыдущие юнит-тесты (также в целях ускорения разработки).
  • Тестируем влияние входных параметров функций на результирующую таблицу, которую возвращает функция, и соответствие выходных параметров входным параметрам.
  • В основном мы производим позитивное тестирование. Но сейчас уже начали добавлять по несколько негативных проверок в каждый юнит-тест. Например, вызываем функцию с пустыми входными атрибутами — при этом она должна оставаться стабильной и не падать.
  • При проектировании автоматизированных проверок юнит-тестов мы опираемся на принципы тест-дизайна.
Пример юнит-теста
Юнит-тест — это простая функция на языке SQL, которая не имеет параметров и возвращает значение типа test_result. Вызывать тестовую функцию можно вручную, а можно пакетом (для этого предусмотрена функция фреймворка).
Пример теста:
CREATE OR REPLACE FUNCTION unit_tests.example2()
RETURNS test_result
AS
$$
DECLARE
message test_result;
result boolean;
have integer;
want integer;
BEGIN
want := 100;
SELECT 50 + 49 INTO have;
SELECT * FROM assert.is_equal
(have, want) INTO message, result;
--Test failed.
IF result = false THEN
RETURN message;
END IF;
--Test passed.
SELECT assert.ok('End of test.') INTO message;
RETURN message;
END
$$
LANGUAGE plpgsql;
SELECT * FROM unit_tests.example2();
В первой строке указано имя функции. Далее мы описываем тип возвращаемого значения и объявляем несколько переменных. После begin задаются ожидаемый и фактический результаты. Функция assert.is_equal их сравнивает.
В данном примере ожидаемый и фактический результат заведомо не равны — мы используем этот пример для демонстраций того, как юнит-тест отработает ошибку. Когда тест падает (result = FALSE), тест выдает сообщение message.
Запуск и отчетность
При ручном запуске данного примера мы получаем:
ASSERT FAILED : ASSERT IS_EQUAL FAILED.
Have -> 99
Want -> 100
В случае запуска тестов пакетом дополнительно указывается название тестирующей функции:
3. unit_tests.example2() --> ASSERT IS_EQUAL FAILED.
Have -> 99
Want -> 100
Результат сохраняется в таблицу для последующей отчетности. В ней есть информация о том, когда прогон был запущен, когда завершился, сколько тестов было запущено, сколько из них прошло или упало. У нас есть также вторая таблица с результатами. Она показывает информацию о каждом отдельном юнит-тесте — наименование функции, статус, данные о причинах падения.
Жизненный цикл тестов
Юнит-тестирование вполне допускает подход TDD (Test-Driven Design или разработка через тестирование). Юнит-тест можно написать на SQL еще до того, как была создана сама тестируемая функция (которая также пишется на SQL). Запустить тест можно уже после написания первой версии тестируемой функции. Впоследствии можно перезапускать его каждый раз после обновления функции (в качестве регрессионного теста).
В этом случае цикл жизни теста выглядит следующим образом:
  • Red (тест падает);
  • Green (тест прошел);
  • Refactor (сохраняем работоспособность кода).
Внутри нашего рабочего пространства мы создали раздел юнит-тестирования, где тестирующие функции группируем по смысловым группам.
Разработку новых тестов мы начинаем с тест-плана и создаем его сразу в Confluence. Пытались вести документацию в Word, но идея уже показала свою несостоятельность. Когда команда состоит из нескольких разработчиков, это крайне неудобно.
Тест-кейсы мы также группируем по смыслу. Сначала идут основные проверки, справедливые для любого вызова тестируемой функции, а потом — специальные проверки, для каждой из которых нужен собственный вызов тестируемой функции.
Функции тестов пишем на языке plpgsql. В начале теста всегда идет метакомментарий, где мы указываем ссылку на постановку, пример вызова и историю изменений.
У нас предусмотрены внутренние требования к неймингу тестирующей функции, возвращаемым значениям и т.п.:
  • тестирующая функция должна иметь имя: f_test_имя_тестируемой_функции();
  • функции юнит-тестов должны возвращать тип значения test_result. По этому типу функция unit_tests.begin() находит все функции тестов в базе данных;
  • функции юнит-тестов не имеют аргументов.
Для удобства работы в функцию пакетного запуска unit_tests.begin() мы добавили дополнительный входной атрибут schema_name, чтобы запускать тесты конкретной схемы.
Как было отмечено выше, запускать можно один тест по имени:
select test_schema.test_function();
все тесты через select:
select unit_tests.begin();
Если функции тестов содержат команды DML, то вызов unit_tests.begin() выполняем в транзакции с последующим откатом.
Пример скрипта, который позволяет выбрать все тесты, запущенные сегодня: 
select t.*, d.*
from unit_tests.tests t
join unit_tests.test_details d
on d.test_id = t.test_id
where started_on > current_date;
Дальнейшие планы
  • Естественно, мы бы хотели покрыть юнит-тестами все запланированные на данный момент 183 функции (их общее количество — величина переменная).
  • Параллельно у нас идет настройка CI/CD с сервера разработки на препрод. Планируем завершить ее в ближайшее время.
  • Изначально мы не углубляемся в проверки — закрываем юнит-тестами самые критичные моменты. В будущем необходимо увеличить количество проверок.
  • Хотим добавить проверки времени выполнения функций.
  • Нужно вернуться — оптимизировать и отрефакторить уже написанные тесты.
  • Планируем формировать культуру, где качество кода и тестирование являются приоритетными задачами для всей команды. Юнит-тесты в этом помогают. К тому же они служат дополнительной документацией с вариантами использования функции, что тоже немаловажно.
-Источник
 
Loading...
Error