В этой статье я поделюсь своим опытом разработки юнит-тестов внутри базы данных под управлением PostgreSQL. А также расскажу, зачем мне потребовалось доработать расширение pgTap.

pgTap – это расширение PostgreSQL для разработки юнит-тестов. Сами тесты, как, собственно, и pgTap, пишутся на plpgSQL, что означает низкий порог вхождения для разработчиков PG.

Уверен, что читатель, по крайней мере, знаком с техникой разработки через тесты. Поэтому не буду пускаться в описание теории юнит-тестирования. Благо литературы на эту тему более чем достаточно. Тем не менее, чтобы быть понятым, приведу некоторые детали ниже.

Если совсем коротко, то с уверенностью могу сказать, что, используя pgTap на постоянной основе, можно не только иметь щит против багов, но также ускорить саму разработку. Но обо всем по порядку.

Сначала опыт и результаты

С pgTap я знаком был давно. Семантику его не пришлось изучать, т.к. у меня есть опыт разработки на tSQLt в бытность работы на Microsoft SQL Server. Я разве что только прочитал про его возможности. Да, впечатлился, но в работу брать не стал, т.к. pgTap не поддерживал некоторые возможности, без которых, на мой взгляд, полноценная разработка тестов невозможна. О них речь пойдет ниже с подробностями.

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

С самого начала было замечено, что бывает сложно и трудоёмко создать набор тестовых данных. Тем не менее вложения оправдываются. Поясню. Ранее я «шаманил» в DBeaver, чтобы подставить данные и выполнить свой код. При этом в нем необходимо указывать параметры и следить за контекстом. Дело, в общем, привычное, но после этих действий не остаётся никаких артефактов. Хочу сказать, что повторить все «шаманство» через пару месяцев бывает и невозможно. Скрипты, которые ранее создавал, где-то лежат, но где они — еще найти надо. А после того, как нашел заново прочитать, вникнуть и только потом выполнять. Дак что проще: создать набор тестовых данных и оформить в тест или же хранить все в папках и напрягаться при повторном использовании? Ответ для меня очевиден – тест и только тест.

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

Бывает, требуется поправить баг в хотфикс. Да, поправить можно. И даже работать будет. Но будут ли работать все бизнес-кейсы, если ваша процедура/функция сложная и покрывает множество кейсов? Что выбрать: прогон всего подряд по найденным скриптам или же просто запустить все тесты? Ответ – только тесты! Все остальное невозможно из-за ограниченности времени.

Переписать нельзя хотфикс (поставьте запятую)

А бывает нужно в хотфикс добавить некую функциональность. Был у меня такой случай. Вам знакома ситуация, когда есть некий кусок кода, за который никто не хочет браться? Единственный ответ разработчика – «сначала нужно все это переписать». Знакомо? В один прекрасный день и ко мне пришли с просьбой кое-что поправить в монстре. Деваться было некуда. Автор статьи был единственным БД-разработчиком на проекте. Сроки сжаты. Код запутан.

Помогли ли тут тесты? Да, помогли. Сначала сделал один мегатест. Взял набор данных, результат обработки, которых точно известен как правильный. Вставил этот набор в тест в качестве тестовых данных. Получил на них результат и вставил этот результат в ассерт типа results_eq. Так у меня получилась проверка, которая гарантировала, что я не поломаю большую часть функциональности.

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

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

Тестовые данные не только данные

Аксиома. Заполняя тестовые данные, вы находите ошибки в коде без всяких тестов. Пример.

create table universe(
  id int, 
  entity_name text, 
  entity_position numeric[], 
  entity_weight numeric
);

create or replace function get_entity_weight(_entity_name text)
returns table (id int, entity_weight numeric, ent_name text)
language plpgsql
as $$
begin
    return query
    select u.id, u.entity_weight, u.entity_name 
    from universe u 
    where ent_name = _entity_name;
end;
$$;

select * from get_entity_weight('earth');

id|entity_weight|ent_name|

--+-------------+--------+

Почему функция ничего не возвращает — понятно, а вот почему она работает — нет. Как говорится: толи баг, толи фича. В жизни на это я внимания не обратил и пошел заполнять тестовые данные. Т.к. функция возвращала набор полей ровно как в таблице, начал писать insert into universe(id, entity_weight, ent_n… Тут и всплыло, что поля ent_name в таблице нет, есть только entity_name.

Это только маленький пример. На самом деле, пока вы делаете тестовые данные, вы вновь и вновь проигрываете алгоритмы. Таким образом, происходит неявная, но строгая проверка еще до теста. На выходе более чистый код и золотые тестовые данные.

Возможности pgTap коротко

Для начала обязан сказать, что pgTap полностью готов к применению в производственной среде. Устанавливается легко. Написан полностью на plpgSQL. Код легко читается. Имеет отличную документацию. В общем, низкий поклон автору.

Итак, основные возможности:

  • Легко устанавливать

  • Обширный набор ассертов

  • Можно проверять структуру БД и привилегии

  • Можно сравнивать наборы данных и скалярные значения

  • Проверять производительность

  • Один тест — одна транзакция

  • Интегрируется в CI 

Лично для меня — такие возможности впечатляют. Но давайте посмотрим глубже.

Невозможности pgTap

  1. Трудно работать, когда в БД есть связи (primary and foreign key)

  2. Нет возможности создавать имитации(mock) вместо функций или представлений

  3. Невозможно посчитать количество вызовов

  4. Сложно отлаживать, когда весь код в транзакции

По пункту 1.

Наступает время обещанных деталей теории. Дело в том, что юнит-тест призван проверять небольшую часть бизнес-логики. Для этого он должен быть изолирован от внешних зависимостей. Так, например, в Python или C# такими зависимостями могут считаться файлы, http-службы и даже базы данных. В нашем случае к зависимостям можно отнести весь набор таблиц, который не участвует напрямую в логике работы тестируемой функциональности. В следующем примере приведена база данных и запрос, демонстрирующий сказанное.

create table parent(
    id int primary key, parent_name text
);

create table child(
  id int, id_parent int, revenue numeric, 
  constraint child_fk foreign key(id_parent) 
  references parent(id)
);

insert into parent(id, parent_name) values(1, 'X'), (2, 'Y');
insert into child(id, id_parent, revenue) values(1, 1, 10),(2, 1, 20),(2, 2, 40);

select id_parent, sum(revenue) from child group by id_parent;

id_parent|revenue|
---------+-------+
        2|     40|
        1|     30|

Обратите внимание, что для запроса revenue, сгруппированного, по сути, по полю id из таблицы parent, нет необходимости иметь значения 1 и 2 в этой таблице. Нам достаточно иметь эти значения только в таблице child.

Представим, что БД полна primary and foreign keys. Это значит, что если понадобится создать набор тестовых данных для таблицы, у которой есть только foreing keys, то придётся заполнять и primary keys. Кроме того, что это отнимает время также, повторяется из раза в раз. Да, можно создать исчерпывающий набор тестовых данных, оформить его создание в виде процедуры и вызывать в нужном месте. К счастью, есть другой путь, который, к примеру, используется в tSQLt. tSQLt — это, по сути, аналогичный фреймворк, только для Microsoft SQL Server. Проблема foreign key не единственная, и для её решения разработана хранимая процедура fake_table. Вот её сигнатура.

CREATE PROCEDURE fake_table(
     _table_ident text[],
     _make_table_empty boolean DEFAULT false,
     _leave_primary_key boolean default false,
     _drop_not_null boolean DEFAULT false,
     _drop_collation boolean DEFAULT false
);

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

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

Параметры:

  • _table_ident – массив с именами таблиц в формате ‘{schema.table}’::text[], из которых мы делаем фейки.

  • _make_table_empty – TRUE, если таблицу необходимо очистить. Будет вызвана инструкция TRUNCATE. По умолчанию FALSE.

  • _leave_primary_key – TRUE, если надо сохранить PRIMARY KEY. По умолчанию FALSE. Если код использует ON CONFLICT ON CONSTRAINT, то возникнет ошибка. Избежать её можно, передав TRUE. Надо заметь, что уникальные ограничения не удаляются.

  • _drop_not_null – TRUE, если необходимо избавиться от ограничений NOT NULL. Без NOT NULL ограничений заполнять тестовые данные намного проще.

  • _drop_collation – deprecated.

По пункту 2.

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

create procedure mock_func(
    _func_schema text
    , _func_name text
    , _func_args text
    , _return_set_value text default null
    , _return_scalar_value anyelement default null::text
)

Цель процедуры – заменить реализацию указанной функции так, чтобы она возвращала заранее известный результат. Возможно создать имитацию, как для функций, возвращающих скалярные значения, так для функций возвращающих наборы данных. Язык функции (sql или plpgsql), которую имитируем определяется автоматически. Аналогичная имеется и в tSQLt. Как и в других широко известных тестовых фремворках.

Параметры.

  • _func_schema – схема, в которой создана функция

  • _func_name – имя функции, имитацию которой необходимо создать

  • _func_args – аргументы функции в текстовом виде, например, ‘(int, text, numeric)’. Здесь указываются только типы параметров без их имен. Параметр необходим, чтобы точно идентифицировать любую перегрузку функции.

  • _return_set_value – набор данных, который должна вернуть имитация. По умолчанию NULL

  • _return_scalar_value – скалярное значение, которое должна вернуть имитация функции. По умолчанию NULL

Представьте, что у вас есть функция, которая возвращает текущее время, как я это называю, паттерн «машина времени». Суть в том, чтобы в продакшен-контексте функция возвращала реальное текущее время, тогда как в тестовом контексте с помощью имитации возвращала известное время. Такой подход можно применять для реализации логики, зависящей от текущего времени. К примеру, показывать определенные данные только в определенный временной промежуток. «Машина времени», конечно, не должна применяться на больших объёмах данных, но все же имеет право на жизнь.

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

По пункту 3.

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

create function call_count(
     _call_count int
     , _func_schema name
     , _func_name name
     , _func_args name[]
);

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

Параметры.

  • _call_count – ожидаемое количество вызовов.

  • _func_schema – схема, в которой создана функция

  • _func_name – имя функции, количество вызовов которой необходимо посчитать

  • _func_args – аргументы функции в текстовом виде, например, ‘(int, text, numeric)’. Здесь указываются только типы параметров без их имен. Параметр необходим, чтобы точно идентифицировать любую перегрузку функции.

Для того чтобы сервер вел подсчет вызовов, необходимо установить параметр конфигурации track_functions = all.

По пункту 4.

Знаком ли вам инструмент под названием гаечный ключ? Уверен, что да. Инструмент столь же простой насколько незаменимый. Такой теперь есть и в pgTap. Вот его сигнатура.

create procedure print_table_as_json(
  _table_schema text, 
  _table_name text
);

Цель проста – вывести данные, содержащиеся в переданной таблице. Считаю эту процедуру гаечным ключом из-за очень простой реализации. Все, что она делает – это возвращает через raise notice запрос, в котором вызывается функция json_to_recordset, в которую, в свою очередь, передаются данные в формате json из переданной таблицы. Чтобы избежать нежелательных последствий, процедура выбирает первые 1000 строк без сортировки.

Параметры.

  • _table_schema – схема, в которой создана таблица

  • _table_name – имя таблицы содержимое таблицы, которой необходимо обследовать

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

Debug or not debug

Хочу у читателя спросить совета. Доработки pgTap — это хорошо. Но остаётся вопрос с отладкой. Изыскания показали, что отладчика у PostgreSQL, по сути, нет. Фактически он есть, но ни в pgAdmin, ни в DBeaver Lite он не работает как ожидалось.

К тому же, есть ли необходимость ставить точки прерывания где-нибудь внутри WHERE или SELECT? Сдаётся мне, нам больше интересны промежуточные результаты запроса. Т.е. если есть некий запрос с большим количеством CTE, которые выполняются последовательно, то искать ошибку надо в каком-то их них. А при этом было бы удобно видеть, что CTE возвращает. Или у вас по-другому?

Итак, что насчет отладчика, который будет показывать, результат конкретного CTE? К примеру, ставите точку прерывания, запускаете отладчик, запускаете код. Код прерывается в точке останова, выводит результат CTE (или вашего запроса), затем код продолжает выполняться до следующей точки прерывания или до конца.

Резюме

Разработка тестов штука медленная, но экономит время на многих этапах. Даже если код идеален, а тесты съели половину времени, не беда. Все окупится при рефакторинге или в будущих доработках.

Доработанное расширение доступно на github.com.

Комментарии (4)


  1. RekGRpth
    28.01.2025 15:07

    А почему бы на основе разработок не сделать патч в апстрим?


    1. vmalyutin Автор
      28.01.2025 15:07

      У меня пока что все наработанное в форке и в отдельной ветке. Вот сегодня хотел смержить в main форка и предложить патч в апстрим. Но вот сижу на работе тесты пишу :). И так каждый день. Навалилось просто много работы.


  1. RPG18
    28.01.2025 15:07

    Тк сервисы пишу на Go, то запускаю PostgreSQL в testcontainers на локальной машине и GitLab Services на CI/CD, в которых проверяю логику работы с СУБД.


    1. vmalyutin Автор
      28.01.2025 15:07

      Большое спасибо за информацию!

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

      Это все было про мои доработки. А вот сам pgTap плюс к этому обеспечивает проверку структуры и производительности.

      В общем, на мой взгляд, testcontainers применим в своих случаях, а pgTap в своих.