В некоторых технологиях и языках программирования юнит-тестирование — уже давно неотъемлемая часть написания кода. Оно интегрировано в разработку и доступно «из коробки» в виде фреймворков, как, например, JUnit для Java, xUnit/nUnit для C# и т. д. Но в Oracle культура юнит-тестирования мало распространена. В статье я расскажу, как и зачем мы внедрили автотесты при разработке на Oracle и для чего их используем.

На одном из проектов мы автоматизируем деятельность крупнейшего российского ритейлера. Большая часть софта написана на PL/SQL, процедурном расширении SQL, которое используется в Oracle. Помимо стандартной транзакционной логики СУБД, в нем много интеграций с внешними системами по SOAP и REST. Как мы выстраиваем эти интеграции, можно почитать тут.

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

Мы решили, что юнит-тестирование поможет снизить количество багов и вообще уменьшить масштабы ручного труда.

Какой фреймворк мы выбрали

В качестве эксперимента мы решили автоматизировать тестирование одной из небольших подсистем с помощью open source фреймворка utPLSQL и в случае успеха распространить практику на остальную кодовую базу.

Чем хорош фреймворк для нас:

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

  • большое сообщество пользователей, распространенность, периодическая актуализация и исправление ошибок в новых версиях;

  • исчерпывающая документация;

  • обширный функционал, полностью покрывающий наши потребности.

По сути, альтернатив этому фреймворку для работы с Oracle нет. Некоторые разработчики пишут свои скрипты для тестирования, но довольно затруднительно использовать их на проекте с большой кодовой базой.

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

Как работает фреймворк на проекте

Общий принцип

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

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

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

Схема обмена данными при тестировании

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

После тестирования версия загружается в аналогичную систему заказчика с доступом к боевым серверам. Для тестирования, помимо «железных серверов», на которых развернуты инсталляции, у нас налажена работа с так называемыми TimeMachine-клонами. Если коротко, этот механизм — микс из GitLab, OpenNebula, Ceph и других инструментов, который позволяет быстро по кнопке или по расписанию поднимать инсталляции нужных версий на виртуальных серверах, используя user-friendly интерфейс GitLab. Эти инсталляции изначально использовались для ручного тестирования, но после внедрения юнит-тестирования на них еще прогоняются все автотесты с отбивкой и алертами об успешном или неуспешном выполнении. Мы также сохраняем историю запуска тестов. Это позволяет выявить, в какой момент и при каких правках был сломан тот или иной алгоритм в коде.

Пример бизнес-процесса

При реализации такого бизнес-процесса мы обычно применяем автотесты. Дано: загрузка и обработка заказа из интернет-магазина.

  1. Загружаем заказ покупателя на доставку из внешней системы.

  2. Обрабатываем заказ на нашей стороне, проверяем, что такого заказа еще нет, проверяем данные покупателя.

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

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

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

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

Как тестирование реализовано в коде

Масштаб теста — один пакет (назовем его ut_order), по одной процедуре для каждого сценария, процедуры beforeeach/aftereach для подготовки и обнуления тестовых данных и сброса кэша.

При большой вариативности сценариев бизнес-процесса количество и объем юнит-тестов будут весьма существенны. Если покрывать тестированием каждую задействованную функцию и процедуру, можно потратить очень много времени, при этом так или иначе все равно придется «склеивать» их в имитации процесса. Добавим сюда необходимость актуализировать и адаптировать функции и процедуры под постоянно меняющиеся бизнес-требования. Что касается самой методологии тестирования, все эти потребности покрываются встроенными механизмами фреймворка — annotations и expectations.

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

Разберем реализацию тестирования в одном из сценариев бизнес-процесса.

Тестовый пакет, в котором собраны тест-кейсы:

CREATE OR REPLACE PACKAGE ut_order IS
    --%suitepath(orders)
    --%suite(Tests: deliv_orders)
    --%beforeeach(before_order_test)
    --%aftereach(after_order_test)
    --%rollback(manual)

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

Тестовая процедура в пакете, помеченная аннотацией:

--%test(Полный выкуп, оплата курьеру, без услуги доставки)
    PROCEDURE full_agency_contr_no_delivery;

Разного рода генераторы номеров заказов, накладных и прочего у нас вынесены в отдельный вспомогательный пакет. Кладем в mock-таблицу препарированный ранее запрос, выставляем системный параметр, от которого зависит, будут ли пакеты с логикой ссылаться на табличку или же формировать настоящий запрос.

-- Кладем в тестовую таблицу сгенерированный запрос
INSERT INTO t_test_response
          (uuid, process, response)
        VALUES
          (l_ext_response_guid, 'ext_full_data_response', l_ext_response);
        pk_param.set_prm(a_param => 'testing_response',
                         a_value => l_ext_response_guid,
                         a_level => 'Session');

-- Создаем заказ, проверяем его атрибутику
l_order_num := pk_order_api.load_order(l_ext_response); 
    SELECT *
      INTO lt_deliv_order
      FROM t_deliv_order o
     WHERE o.num = l_order_num;
ut.expect(lt_deliv_order.id_deliv_order,'Заказ не создан').to_be_not_null();
ut.expect(lt_deliv_order.key_state,'Состояние заказа отличается от переданного').to_equal('shp_deliv_order_in_transit');
ut.expect(lt_deliv_order.num,'Номер заказа отличается от переданного').to_equal(l_order_num);

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

-- отправляем запрос 
    IF pk_test_support.is_autotest_mode() THEN
        -- autotest mode
        l_resp := pk_test_support.get_last_test_response_clob();
    END IF;

Проверяем все остальные созданные документы (корзины, накладные, документы оплаты и пр.).

-- Проверяем созданную корзину клиента
SELECT *
  INTO lt_basket
  FROM t_basket b
 WHERE b.id_deliv_order = lt_deliv_order.id_deliv_order;  
ut.expect(lt_basket.key_state,'Корзина не выдана').to_equal('shp_basket_unloaded');
ut.expect(TO_NUMBER(pk_basket.get_summa('shp_abstract_basket', lt_basket.id_basket, 'paid_summa_done', 'N')), 'Сумма оплаты по корзине не соответствует сумме, переданной курьеру').to_equal(pljson(l_req_codes_json.get(1)).get('togetCost').num);

Как запускаются автотесты

Как уже было сказано, запуск происходит не вручную в базе данных, а через настроенный механизм с интерфейсом в GitLab CI. Его возможности позволяют настроить интервальный запуск (Schedules) тестов, который включает в себя несколько шагов:

  1. Клонируем инсталляцию с эталонного стенда с последней «боевой» (отданной заказчику) версией на борту.

  2. Обновляем стенд до текущей рабочей версии из системы управления версиями.

  3. Создаем (пересоздаем) объекты, необходимые для работы фреймворка, — пользователя, таблицы, пакеты, процедуры и т. п.

  4. Прогоняем скрипт с пакетами, в которых реализованы юнит-тесты. Актуальные версии пакетов хранятся и выкачиваются из Git, то есть программисту достаточно добавить в репозиторий новый пакет или внести правки в существующий — и при запуске всегда будет использоваться актуальная версия.

Для запуска тестов используется штатный функционал фреймворка (команда ut.run()). Он же генерирует лог выполнения. Схематично весь этот процесс изображен на рисунке ниже.

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

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

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

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

Заключение

В целом стоит отметить, что наша модель работы с юнит-тестами получилась весьма эффективной и позволяет оперативно отслеживать возникновение ошибок и багов. По результатам внедрения автотестов, общий среднегодовой показатель cost of bugs для конкретного продукта снизился с 26,22% до 19,14%, по покрытым тестами бизнес-процессам проблемы стали возникать гораздо реже, чем раньше.

Сейчас, когда основные процессы запуска и мониторинга отлажены и данная модель признана командой успешной, перед нами стоит главная задача — повышать покрытие остальных процессов и продуктов. Надеюсь, наш опыт поможет и другим командам наладить процессы юнит-тестирования в СУБД Oracle, ведь на рынке остается много продуктов, в которых так или иначе задействован хранимый код PL/SQL и нет внятной стратегии, как наладить его автоматическое тестирование.

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


  1. mrhawk555
    09.12.2021 11:19

    Доброе утро!

    1. Сколько по времени сейчас работают автотесты? И сколько по времени работает весь pipeline?

    2. Решался ли вопрос с запуском автотестов в параллели или сейчас автотесты работают в одном потоке?

    3. Кто пишет автотесты?


    1. Pankin_AE Автор
      09.12.2021 12:23

      Добрый день!
      1. Весь pipeline с поднятием инфраструктуры и прогоном автотестов работает в пределах 20 минут, запуск только тестов (на данный момент их около 40) отрабатывает около 5-6 минут;
      2. Пока все работает в 1 потоке, но спасибо за идею, надо будет подумать насчет распараллеливания. Тут стоит отметить, что сейчас весь процесс запускаются не по событию (например, commit в мастер), а по расписанию (ночью), поэтому время, которое тратится на выполнение, не так критично;
      3. Сами тесты пишут разработчики, кейсы для них (бизнес-процессы системы) придумываются и обсуждаются совместно с аналитиками, инженерами и тестировщиками.