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

Об авторе

Всем привет. Меня зовут Кирилл. За 10 лет в страховой отрасли прошел путь от IT-эникейщика до ТимЛида группы разработки страховых продуктов аквизиции. В этой статье поделюсь простыми приёмами по перешагиванию через очевидные грабли, чтобы движение через Зону Жизненного Опыта, Получаемую в Процессе было менее болезненно.

Приёмы

1. Резервная копия данных

Байка из жизни #1

Решил добавить столбец к табличке в Pervasive на проде. Размер таблицы в топ 5 базы. Запустил консольную утилиту по перестройке файла данных, подождал полчасика и закрыл – думал зависла. Файл данных таблицы, неожиданно (как же ещё) стал невалидным.

Ночь, сервак на другом конце Москвы в БЦ, неработающая ключевая таблица – утром ждет разнос. В судорожных попытках вспомнить порядок действий приходит понимание – есть бэкап! Процесс восстановления пощекотал нервы – «а вдруг не…», но обошлось.

 

После этого случая, выработал для себя 2 железных правила:

 - Делай бекап/снапшот всякий раз перед изменением (даже для мелкого update’а);

 - Проверяй решение на другом инстансе, до деплоя на прод.

Самый простой способ помочь завтрашнему себе – сделать скрипты восстановления данных при их модифицировании (Insert/Update/Delete).

  • Для операции Delete достаточно выгрузить данные из IDE – на основании Select’а формируется датасет и просто экспортируется в файл/кэш как набор Insert-скриптов.

    Важно выгружать все данные по Foreign Keys, явным или нет, в виде отдельных Insert-инструкций. Особенно чувствительно, когда таблицы с данными каскадные либо со специфичной логикой в API/триггерах удаления. Варианты архитектуры, когда в Delete API реализован Insert/update/Post-сервисы стоит рассматривать отдельно – у данных может быть разная критичность.

Как это выглядит в PL\SQL Developer

  • Второй по сложности оператор DML – Insert. Можно просто сохранить список Primary Keys (PK). Или написать запрос, который отберёт созданные записи по специфичным маркерам. При наличии в таблице поля с автором создания (CreatedBy), датой создания, или другим особым полем, задача сильно упрощается.

    На практике, чаще всего, сохраняется запрос создания, по которому можно написать обратный скрипт удаления. На мой взгляд, список PK надежнее – вероятность затереть что-то чужое сводится почти к 0.

  • Третья и самая сложная DML-операция – Update. Здесь возможны различные комбинации способов восстановления. Наиболее сложный случай в практике – восстановить данные по прошествии времени (полгода-год), когда над данными пользователь уже активно поиздевался поработал. В итоге потребовалось формализовать последовательность действий пользователя в виде скрипта. Прогнать скрипт на согласованном массиве данных – части списка для восстановления. И, естественно, проверить с двух сторон: бизнеса и DevTeam.

Подитог

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

Наиболее понятный вариант – накатить снапшот/бэкап при полной остановке инстанса в монопольном режиме работы. Минус этого варианта – ограниченность применения для промышленных решений 24/7/365, так как незапланированные простои даже в минуту порождают лавины алертов на ответственных. Поэтому ради восстановления пары-тройки записей в таблице, городить огород со специализированными схемами/таблицами – занятие на любителя. Порой легче уговорить пользователя завести данные заново.

Из практики

Работу со скриптами отката можно сильно облегчить через архитектуру БД – создать теневые таблицы. Решение спорное, т.к. для каждой таблицы приходится индивидуально отвечать на вопрос «что лучше?»: полные логи или малое потребление дискового пространства.

 

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

До Update

Сам Update

Update Message Set ExternalID = '123' Where ID = 1;
Update Message Set ExternalID = '321' Where ID = 1;
Update Message Set ExternalID = '789' Where ID = 1;

После Update

2. Feature Toggle (FT)

Байка из жизни #2

Команда целый инкремент перепиливает алгоритм расчета премии по страховому продукту. Затронуты как фронт, так и несколько систем бэка. Деплой идет тремя параллельными релизами в разное время в течение дня. День релиза определен, доработки закреплены, влиты в регрессные сборки, релиз-менеджеры финализируют тестирование и готовятся к деплою. Сутки до выкладки… и возникает вопрос, как поведет себя прод в промежутках между выкладками релизных сборок разных систем: фронт доехал, бэк в процессе, а кусок системы расчета тарифов уже вовсю работает. Страшновато. Начинаешь спешно просматривать все pull request’ы по выкладке, выясняя, что в итоге отвалится. В тот раз пронесло, благодаря требованиям к обратной совместимости (обработка null для нового входящего параметра) – неконсистентность кода прошла незаметной.

 

Итогом стало принятие на уровне команды парочки подходов:

- После выкладки крупных изменений, проверяем сами на проде, до анонсирования бизнесу;

- Декомпозиция Features на User Story таким образом, чтобы можно было их деплоить независимо (привет CI/CD);

- Обязательно наличие FT (сейчас наличие FT является НФТ для большей части доработок со стороны Компании).

Байка из жизни #3

В другой раз выкатывали изменения UI внутреннего страхового продукта. Допилили, залили, включились в релиз, и на регрессном стеде смежная команда находит баг (да-да, это случается, и это нормально) в одной из проверок UI. Анализ дефекта показывает, что не учли крупный бизнес-кейс – его доработка требует качественного тестирования и спокойной головы при написании кода. Хорошо, что доработку проверки зафичетоглили под отдельным кодом FT, т.е. она может быть отключена независимо. Подключаем бизнес и принимаем решение, что проверка некритична и можем запускаться без неё. FT отключается, задача тех долга на исправление закидывается в бэклог ближайшего спринта, дефект закрывается и релиз выходит без сбоев. Танцуют Все!

 

Что вынес для себя:

- Несколько кодов FT для одной стори хоть и усложняет обслуживание техдолга, но экономит нервы и время при ловле багов4

- Если UI поддерживает FT – используй.

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

Сразу уточню, описание дано для Release Toggles. Их срок жизни – не дольше 2-3 релизных циклов (2 статья из Уголовного кодекса разработчика достаточно ярко подсвечивают столь жесткое требование). Необходимы для уменьшения количества внепланов/критических релизов. Польза в том, чтобы даже при полном отказе прода, его можно было легко (в идеале мгновенно, в процессе деплоя) реанимировать, отключив новый код.

Долгоживущие FT A/B тестирования, несут в себе другую ценность и на спокойствие разработчика при выкладке кода особо не влияют.

Базовые принципы:

  • Выключенный FT код не меняет, то есть курсор выполнения проходит ровно все те же блоки в том же порядке, что и без FT;

  • FT не используются повторно. На проде точно, а другие инстансы – на совести разработчика;

4 минута выступления Doug Sven - как не надо делать

Knightmare: A DevOps Предостерегающая история

Feature Toggles: The Good, The Bad, and The Ugly with Andy Davies

4 минута видео

  • FT обязательно выпиливать после подтверждения отсутствия критических отказов. Мелкий косяк через 3-4 месяца активного (!) использования кода – явление настолько тривиальное, что ради него откатывать все решение нерационально.

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

3.Тесты сходимости/модульные тесты

Байка из жизни #4

Перепала как-то задача переписать запрос Oracle онлайн-канала – периодически оптимизатор выбирает план с full scan по датасету в 3-4m записей. Это был не первый подход к снаряду. Очевидные вещи в виде разбиения на запросы с union уже были применены. Нужен индекс. Так как таблица большая и с активными I/O операциями, DBA просят обоснования, что создание индекаса действительно поможет.

Просят – пожалуйста. Сделано 2 версии запроса, обернуты в FT и влиты в тестовый стенд. Дальше, через RunStats анализ используемых ресурсов, потом прогон на совпадение результатов по нескольким сотням примеров. Сошлись и это хорошо.

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

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

Самый приятный бонус данного подхода – один раз настраиваешь тесты (при разработке), и потом не паришься, когда нужно допилить функционал (протестировать руками даже 20 кейсов 3-4 раза подряд желания вызывает мало)

За этот подход спасибо коллегам. Крайне полезен при рефакторинге легаси. Суть – взять данные в базе (список договоров) и прогнать по максимуму через обе реализации метода, старую и новую. Так как enterprise-база кишит тестовыми примерами разной степени кривизны, при выборке в несколько тысяч-десятков тысяч примеров, шансов пропустить критический косяк очень мало. Добавив еще и сравнением производительности, получается хорошая серебряная пуля при разговоре с архитекторами.

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

Примеры кода
declare
  vTime1  number := 0;
  vTime2  number := 0;

  procedure prc(pID in number, pRowID IN ROWID)
  is
   
    function find(pSeconds out number) return number
    is
      vResult NUMBER;
    begin
      pSeconds := dbms_utility.get_time/100;
      -- Тестируемый метод
      vResult  := TestPack.TestMethod;
      pSeconds := (dbms_utility.get_time/100 - pSeconds);
      return vResult;
    end;
    
    procedure TestCase(pCase in varchar2)
    is
      vResult1 number;
      vResult2 number;
      vSeconds1 number;
      vSeconds2 number;
      vDiff VARCHAR2(100);
    begin
      SAVEPOINT SP;
      ftoggle.Disable('FT_12345');
      vResult1 := find(vSeconds1);
      vTime1 := vTime1 + vSeconds1;
      ftoggle.Enable('FT_12345');
      vResult2 := find(vSeconds2);
      vTime2 := vTime2 + vSeconds2;
      RollBack To SP;
      if nvl(vResult1, -1) <> nvl(vResult2, -1) then
        vDiff := ';[' || pCase || '] ' || pAgrISN || ': ' || vResult1 || '<>' || vResult2;
      end if;
      Update TMP_Table t 
        Set T.ERR = T.ERR||NVL2(T.ERR||vDiff, ';', Null)||vDiff, 
            T.STATUS     = Decode(pCase, '1', Nvl(T.STATUS,0)     + vSeconds1*1000000 + vSeconds2, T.STATUS))
      Where T.RowID = pRowID;
    end;
    
  begin
    GetTestData(pID);
    TestCase('1');
    TestCase('2');
    TestCase('3');
  end;

begin
  for vCur in (select to_number(t.fio) As ID, T.RowID from TMP_Table t Where T.Status Is Null)
  loop
    prc(vCur.ID, vCur.RowID);
    Update TMP_Table t Set T.ERR = 'Compare' Where T.RowID = vCur.Rowid And T.ERR Is Null;
  end loop;
end;

Логичное развитие тестов сходимости – модульные тесты (Unit tests, UT). Когда проверки присутствуют в базе одновременно с продовым кодом (за теорией – к Roy Osherove).

Польза UT не однозначна на старте проекта. Однако при развитии решения, благодаря UT стоимость рефакторинга снижается (см. техдолг). Наиболее очевидный аспект – меньше времени тратится на ретест при изменении кода.

Вопросы, как писать UT на Oracle достойны отдельной статьи. Здесь же приведу пару примеров реализации в формате тестопригодного дизайна на Oracle.

Пример UT#1

Проверяемый метод

  -- тип для расчета периодов
  TYPE TPERIOD IS RECORD (
    pBeg DATE,
    pEnd DATE
  );
  TYPE TPERIODTABLE IS TABLE OF TPERIOD;

  /** 
  * ==========================================================================<br/>
  * Расчет периодов многолетнего страхования <br/>
  * ==========================================================================<br/>
  * @param pBegin - начало периода
  * @param pEnd   - окончание периода
  * @return       - таблица с периодами
  */ 
  
  FUNCTION CalcPeriods(pBegin IN DATE, pEnd IN DATE) RETURN TPERIODTABLE
  AS
    vResult TPERIODTABLE := TPERIODTABLE();
    vPeriod TPERIOD;
    vBegin  DATE   := TRUNC(pBegin);
    vEnd    DATE   := TRUNC(pEnd);
    vMonths NUMBER := GREATEST(months_between(vEnd, vBegin), 1);
    -- Необходимо корректировать 1 день в феврале високосного года
    NeedCorrectLeapYear BOOLEAN := (TO_CHAR(vBegin,'dd.mm') = '28.02') AND (TO_CHAR(vBegin,'mm') = TO_CHAR(vBegin + 1,'mm'));
  BEGIN
    --Договор может закончиться в том же месяце, но через несколько дней. Добавим месяц.
    IF TRUNC(vMonths) < vMonths THEN
      vMonths := TRUNC(vMonths)+1;
    END IF;
    FOR cMonth IN 1..vMonths
    LOOP
      IF MOD(cMonth - 1, 12) = 0 THEN
        vPeriod.pbeg := add_months(vBegin, cMonth - 1);
        vPeriod.pend := add_months(vPeriod.pbeg, 12) - 1;
        IF vPeriod.pend > pend THEN
          vPeriod.pend := pend;
        END IF;
        IF (NeedCorrectLeapYear AND (TO_CHAR(vPeriod.pend,'dd.mm') = '28.02'))
        THEN
          vPeriod.pend := vPeriod.pend - 1;
        END IF;
        vResult.extend;
        vResult(vResult.last) := vPeriod;
      END IF;
    END LOOP;

    RETURN vResult;
  END CalcPeriods;

Тесты

  /**
  * ========================================================================<br/>
  * Получить параметры вызова метода CalcPeriods по-умолчанию<br/>
  * ========================================================================<br/>
  * @param pDateBeg   - Дата начала периода
  * @param pAddMonths - Длительность периода в месяцах
  * @return           - Запись типа TPERIOD с данными периода
  * ! Важно - Процедура использует Oracle Add_months, поведение которой для 29.02 специфично
  */
  FUNCTION GetDefaultValues (pDateBeg   IN DATE    DEFAULT TRUNC(SYSDATE),
                             pAddMonths IN INTEGER DEFAULT 12) RETURN TPERIOD
  IS
    vResult TPERIOD;
  BEGIN
    vResult.pBeg := pDateBeg;
    vResult.pEnd := Add_months(vResult.pBeg, pAddMonths) - 1;
    RETURN vResult;
  END GetDefaultValues;

  /**
  * ========================================================================<br/>
  * Сформировать CLOB по периоду<br/>
  * ========================================================================<br/>
  * @param pPeriod - Тип с информацией по периоду
  * @return        - Структурированный CLOB с данными
  */
  FUNCTION getPeriodClob(pPeriod IN TPERIOD
    ) RETURN CLOB
  IS
  BEGIN
    RETURN TO_CLOB('<PERIOD>'||
                   '<DATEBEGIN>'||TO_CHAR(pPeriod.pbeg, 'DD.MM.YYYY')||'</DATEBEGIN>'||
                   '<DATEEND>'||TO_CHAR(pPeriod.pend, 'DD.MM.YYYY')||'</DATEEND>'||
                   '</PERIOD>');
  END getPeriodClob;

  /**
  * ========================================================================<br/>
  * Сформировать CLOB по периоду (reload)<br/>
  * ========================================================================<br/>
  * @param pBeg - Дата начала периода
  * @param pBeg - Дата окончания периода
  * @return     - Структурированный CLOB с данными
  */
  FUNCTION getPeriodClob(pBeg IN DATE, pEnd IN DATE) RETURN CLOB
  IS
    vPeriod TPERIOD;
  BEGIN
    vPeriod.pBeg := pBeg;
    vPeriod.pEnd := pEnd;
    RETURN getPeriodClob(vPeriod);
  END getPeriodClob;

  /**
  * ========================================================================<br/>
  * Тест разбиения периода на 1 год<br/>
  * ========================================================================<br/>
  */
  PROCEDURE PER_1YEAR
  IS
    vDefPeriod TPERIOD;
    vPeriods   TPERIODTABLE;
    vRes       XMLType;
    vResOK     XMLType;
  BEGIN
    vDefPeriod := GetDefaultValues(pDateBeg => TO_DATE('01.01.2020', 'DD.MM.YYYY'));
    vResOK     := XMLTYPE('<PERIODS>'||getPeriodClob(pPeriod => vDefPeriod)||'</PERIODS>');
    vPeriods   := CalcPeriods  (pBegin => vDefPeriod.pbeg, pEnd => vDefPeriod.pend,);
    vRes       := getPeriodsXML(pPeriods => vPeriods);
    
    UNITTEST.CheckTestResult('PER_1YEAR', vRes, vResOK);
  END PER_1YEAR;

  /**
  * ========================================================================<br/>
  * Тест разбиения периода на 2 года<br/>
  * ========================================================================<br/>
  */
  PROCEDURE PER_2YEAR
  IS
    vDefPeriod TPERIOD;
    vPeriods   TPERIODTABLE;
    vOkPeriods TPERIODTABLE;
    vRes       XMLType;
    vResOK     XMLType;
  BEGIN
    vDefPeriod := GetDefaultValues(pDateBeg => TO_DATE('01.01.2020', 'DD.MM.YYYY'), pAddMonths => 24);
    -- Определить даты интервалов
    Select T.dBeg, T.dEnd Bulk Collect Into vOkPeriods
    From (
      Select vDefPeriod.pbeg As dBeg, TO_DATE('31.12.2020', 'DD.MM.YYYY') As dEnd From Dual
      Union All
      Select TO_DATE('01.01.2021', 'DD.MM.YYYY'), vDefPeriod.pEnd From Dual
      ) T;
    vResOK   := getPeriodsXML(pPeriods => vOkPeriods);
    vPeriods := CalcPeriods  (pBegin => vDefPeriod.pbeg, pEnd => vDefPeriod.pend);
    vRes     := getPeriodsXML(pPeriods => vPeriods);
    UNITTEST.CheckTestResult('PER_2YEAR', vRes, vResOK);
  END PER_2YEAR;

  /**
  * ========================================================================<br/>
  * Тест разбиения периода на 400 дней<br/>
  * ========================================================================<br/>
  */
  PROCEDURE PER_400DAYS
  IS
    vDefPeriod TPERIOD;
    vPeriods   TPERIODTABLE;
    vOkPeriods TPERIODTABLE;
    vRes       XMLType;
    vResOK     XMLType;
  BEGIN
    vDefPeriod := GetDefaultValues(pDateBeg => TO_DATE('17.01.2020', 'DD.MM.YYYY'), pAddMonths => 12);
    vDefPeriod.pend := vDefPeriod.pbeg + 400;
    -- Определить даты интервалов
    Select T.dBeg, T.dEnd Bulk Collect Into vOkPeriods
    From (
      Select vDefPeriod.pbeg As dBeg, TO_DATE('16.01.2021', 'DD.MM.YYYY') As dEnd From Dual
      Union All
      Select TO_DATE('17.01.2021', 'DD.MM.YYYY'), vDefPeriod.pEnd From Dual
      ) T;
    vResOK   := getPeriodsXML(pPeriods => vOkPeriods);
    vPeriods := CalcPeriods  (pBegin => vDefPeriod.pbeg, pEnd => vDefPeriod.pend);
    vRes     := getPeriodsXML(pPeriods => vPeriods);
    UNITTEST.CheckTestResult('PER_400DAYS', vRes, vResOK);
  END PER_400DAYS;

  /**
  * ========================================================================<br/>
  * Тест разбиения периода на 4 года(40 месяцев)<br/>
  * ========================================================================<br/>
  */
  PROCEDURE PER_40MONTHS
  IS
    vDefPeriod TPERIOD;
    vPeriods   TPERIODTABLE;
    vOkPeriods TPERIODTABLE;
    vRes       XMLType;
    vResOK     XMLType;
  BEGIN
    vDefPeriod := GetDefaultValues(pDateBeg => TO_DATE('01.01.2020', 'DD.MM.YYYY'), pAddMonths => 40);

    -- Определить даты интервалов
    Select T.dBeg, T.dEnd Bulk Collect Into vOkPeriods
    From (
      Select vDefPeriod.pbeg As dBeg, TO_DATE('31.12.2020', 'DD.MM.YYYY') As dEnd From Dual
      Union All
      Select TO_DATE('01.01.2021', 'DD.MM.YYYY'), TO_DATE('31.12.2021', 'DD.MM.YYYY') From Dual
      Union All
      Select TO_DATE('01.01.2022', 'DD.MM.YYYY'), TO_DATE('31.12.2022', 'DD.MM.YYYY') From Dual
      Union All
      Select TO_DATE('01.01.2023', 'DD.MM.YYYY'), vDefPeriod.pEnd From Dual
      ) T;
    vResOK := getPeriodsXML(pPeriods => vOkPeriods);
    vPeriods := CalcPeriods  (pBegin => vDefPeriod.pbeg, pEnd => vDefPeriod.pend);
    vRes     := getPeriodsXML(pPeriods => vPeriods);
    UNITTEST.CheckTestResult('PER_40MONTHS', vRes, vResOK);
  END PER_40MONTHS;

  /**
  * ========================================================================<br/>
  * Тест разбиения периода на 3 года c 27.02 пред високосного года<br/>
  * ========================================================================<br/>
  */
  PROCEDURE PER_3YEAR_2702
  IS
    vDefPeriod TPERIOD;
    vPeriods TPERIODTABLE;
    vOkPeriods TPERIODTABLE;
    
    vRes   XMLType;
    vResOK XMLType;
  BEGIN
    vDefPeriod := GetDefaultValues(pDateBeg => TO_DATE('27.02.2023', 'DD.MM.YYYY'), pAddMonths => 36);

    -- Определить даты интервалов
    Select T.dBeg, T.dEnd Bulk Collect Into vOkPeriods
    From (
      Select vDefPeriod.pbeg As dBeg, TO_DATE('26.02.2024', 'DD.MM.YYYY') As dEnd From Dual
      Union All
      Select TO_DATE('27.02.2024', 'DD.MM.YYYY'), TO_DATE('26.02.2025', 'DD.MM.YYYY') From Dual
      Union All
      Select TO_DATE('27.02.2025', 'DD.MM.YYYY'), vDefPeriod.pEnd From Dual
      ) T;
    vResOK   := getPeriodsXML(pPeriods => vOkPeriods);
    vPeriods := CalcPeriods  (pBegin => vDefPeriod.pbeg, pEnd => vDefPeriod.pend);
    vRes     := getPeriodsXML(pPeriods => vPeriods);
    UNITTEST.CheckTestResult('PER_3YEAR_2702', vRes, vResOK);
  END PER_3YEAR_2702;

  /**
  * ========================================================================<br/>
  * Тест разбиения периода на 3 года c 28.02 пред високосного года<br/>
  * ========================================================================<br/>
  */
  PROCEDURE PER_3YEAR_2802
  IS
    vDefPeriod TPERIOD;
    vPeriods   TPERIODTABLE;
    vOkPeriods TPERIODTABLE;
    vRes       XMLType;
    vResOK     XMLType;
  BEGIN
    vDefPeriod := GetDefaultValues(pDateBeg => TO_DATE('28.02.2023', 'DD.MM.YYYY'), pAddMonths => 36);
    -- Определить даты интервалов
    Select T.dBeg, T.dEnd Bulk Collect Into vOkPeriods
    From (
      Select vDefPeriod.pbeg As dBeg, TO_DATE('28.02.2024', 'DD.MM.YYYY') As dEnd From Dual
      Union All
      Select TO_DATE('29.02.2024', 'DD.MM.YYYY'), TO_DATE('27.02.2025', 'DD.MM.YYYY') From Dual
      Union All
      Select TO_DATE('28.02.2025', 'DD.MM.YYYY'), vDefPeriod.pEnd From Dual
      ) T;
    vResOK   := getPeriodsXML(pPeriods => vOkPeriods);
    vPeriods := CalcPeriods  (pBegin => vDefPeriod.pbeg, pEnd => vDefPeriod.pend);
    vRes     := getPeriodsXML(pPeriods => vPeriods);
    UNITTEST.CheckTestResult('PER_3YEAR_2802', vRes, vResOK);
  END PER_3YEAR_2802;

Пример UT#2

Проверяемый метод

  /**
  * ========================================================================<br/>
  * Сохранение внешних ссылок на объекты <br/>
  * ========================================================================<br/>
  * @param pList      - CLOB с данными в формате JSON со списком GUID
  * @return           - Результат обработки (1 - нет ошибок, -1 - ошибка)
  */
  FUNCTION SaveExternalLink(pJSON IN CLOB) RETURN NUMBER
  IS
    vResult NUMBER(1) := 1;
  BEGIN
    IF pJSON IS NULL THEN 
      RETURN vResult;
    END IF;
    BEGIN
      Insert Into Message M
        (ClassName, InsNumber, ExternalID)
      Select Distinct J.ClassName, J.InsNumber, J.ExternalID
      From JSON_Table(pJSON, '$.objects[*]'
                        Columns(ClassName    Varchar2(32)   path '$.name',
                                InsNumber    Number         path '$.insNumber',
                                ExternalID   Varchar2(4000) path '$.externalID')) J
      Where J.ClassName Is Not Null 
        And J.InsNumber Is Not Null 
        And J.GUID      Is Not Null
        And Not Exists
          (Select Null From Message Me
           Where ME.ExternalID = J.ExternalID Or
             (J.ClassName Is Not Null And 
              ME.ClassName = J.ClassName And
              ME.InsNumber = J.InsNumber)
          );
    EXCEPTION
      WHEN OTHERS THEN
        vResult := -1;
    END;
    RETURN vResult;
  END SaveExternalLink;

Тесты

  /**
  * ========================================================================<br/>
  * Сформировать CLOB по объекту <br/>
  * ========================================================================<br/>
  * @param pClassName  - Класс объекта
  * @param pInsNumber  - Номер заявки
  * @param pExternalID - Значение внешней ссылки
  * @return            - Структурированный CLOB с данными
  */
  FUNCTION getObjectClob(pClassName IN VARCHAR2, 
                         pInsNumber IN VARCHAR2, 
                         pExternalID      IN VARCHAR2) RETURN CLOB
  IS
  BEGIN
    RETURN TO_CLOB('<OBJECT>'||
                   '<CLASSNAME>'||pClassName||'</CLASSNAME>'||
                   '<INSNUMBER>'||pInsNumber||'</INSNUMBER>'||
                   '<EXTERNALID>'||pExternalID||'</EXTERNALID>'||
                   '</OBJECT>');
  END getObjectClob;

  /**
  * ========================================================================<br/>
  * Сформировать XML со списком внеших ссылок по номеру заявки <br/>
  * ========================================================================<br/>
  * @param pInsNumber - Список номеров заявок
  * @return           - XML с данными
  */
  FUNCTION getObjectsXMLByInsNum(pInsNumber IN TNUM) RETURN XMLType
  IS
    vResult CLOB;
  BEGIN
    IF pInsNumber.Count > 0 THEN
      FOR vCur IN (Select M.ClassName, M.InsNumber, M.ExternalID
                   From Message M, Table(pInsNumber) R
                   Where M.InsNumber = R.column_value
                   Order By ClassName, InsNumber)
      LOOP
        vResult := vResult || getObjectClob(vCur.ClassName, vCur.InsNumber, vCur.ExternalID);
      END LOOP;
    END IF;

    RETURN XMLTYPE('<OBJECTS>'||vResult||'</OBJECTS>');
  END getObjectsXMLByInsNum;


  /**
  * ========================================================================<br/>
  * Сформировать XML со списком внеших ссылок по номеру заявки <br/>
  * ========================================================================<br/>
  * @param pXML - XML с данными
  * @return     - CLOB формата JSON с данными
  */
  FUNCTION ConvertXMLToJSONClob(pXML IN XMLTYPE) RETURN CLOB
  IS
    vResult CLOB;
  BEGIN
    IF pXML IS NULL THEN
      RETURN vResult;
    END IF;
    Select JSON_Object(Key 'objects' Value 
      JSON_ArrayAgg(
               JSON_Object(Key 'className' Value X.ClassName,
                           Key 'insNumber' Value X.InsNumber,
                           Key 'externalID' Value X.ExternalID)
               Order by X.InsNumber, X.ClassName
               Returning VarChar2(32000)))
      Into vResult
      From XMLTable('/OBJECTS/OBJECT' Passing pXML Columns
                      ClassName    Path 'CLASSNAME',
                      InsNumber    Path 'INSNUMBER',
                      ExternalID   Path 'EXTERNALID')X;
    RETURN vResult;
  END ConvertXMLToJSONClob;

  /**
  * ========================================================================<br/>
  * Параметрическое сохранение списка ExternalID на основании XML (OK) <br/>
  * ========================================================================<br/>
  * @param pTarget      - CLOB с данными для проверки
  * @param pList        - CLOB с данными для загрузки
  * @param pInsNumber   - Номер проверяемой заявки
  * @param pTestCaption - Название теста
  */  
  PROCEDURE SaveExternalID_ByXML(pTarget       IN CLOB,
                                 pList         IN CLOB,
                                 pInsNumber    IN TNUM,
                                 pTestCaption  IN VARCHAR2)
  IS
    vRes         XMLType;
    vResOK       XMLType := XMLTYPE('<OBJECTS>'||pTarget||'</OBJECTS>');
    vList        CLOB    := ConvertXMLToJSONClob(XMLTYPE('<OBJECTS>'||pList||'</OBJECTS>'));
    vResult      NUMBER;
  BEGIN
    SAVEPOINT SP;
    BEGIN
      vResult:= SaveExternalLink(pJSON => vList);
      vRes   := getObjectsXMLByInsNum(pInsNumber => pInsNumber);
    EXCEPTION
      WHEN OTHERS THEN
        vRes := XMLType('<ERR>' || RegExp_replace(SQLERRM, '(.)([\(|\[].*)', '\1') || '</ERR>');
    END;
    ROLLBACK TO SP;
    UNITTEST.CheckTestResult(pTestCaption, vRes, vResOK);
  END SaveExternalID_ByXML;

  /**
  * ========================================================================<br/>
  * Параметрическое сохранение списка ExternalID на основании XML (OK) <br/>
  * ========================================================================<br/>
  * @param pList        - CLOB с данными для загрузки
  * @param pErrCode     - Код ожидаемой ошибки
  * @param pTestCaption - Название теста
  */  
  PROCEDURE SaveExternalID_ByXML_Raise(pList        IN CLOB,
                                       pErrCode     IN VARCHAR2,
                                       pTestCaption IN VARCHAR2
    )
  IS
    vRes         XMLType := XMLTYPE('<OBJECTS>NOT OK</OBJECTS>');
    vResOK       XMLType := XMLTYPE('<OBJECTS>Correct error</OBJECTS>');
    vList        CLOB    := ConvertXMLToJSONClob(XMLTYPE('<OBJECTS>'||pList||'</OBJECTS>'));
    vResult      NUMBER;
  BEGIN
    SAVEPOINT SP;
    BEGIN
      vResult:= SaveExternalLink(pJSON => vList);
    EXCEPTION
      WHEN OTHERS THEN
        IF INSTR(SQLERRM, pErrCode) > 0 THEN
          vRes := vResOK;
        ELSE
          vRes := XMLType('<ERR>' || RegExp_replace(SQLERRM, '(.)([\(|\[].*)', '\1') || '</ERR>');
        END IF;
    END;
    ROLLBACK TO SP;
    UNITTEST.CheckTestResult(pTestCaption, vRes, vResOK);
  END SaveExternalID_ByXML_Raise;

  /**
  * ========================================================================<br/>
  * Сохранение NULL-значения (OK) <br/>
  * ========================================================================<br/>
  */  
  PROCEDURE SaveExternalID_NullList_OK
  IS
    vInsNumber NUMBER;
  BEGIN
    SaveExternalID_ByXML(pTarget      => NULL,
                         pList        => NULL,
                         pInsNumber   => TNum(vInsNumber),
                         pTestCaption => 'Сохранение NULL-значения (OK)');
  END SaveExternalID_NullList_OK;

  /**
  * ========================================================================<br/>
  * Сохранение пустого списка ExternalID (OK) <br/>
  * ========================================================================<br/>
  */  
  PROCEDURE SaveExternalID_EmptyList_OK
  IS
    vInsNumber NUMBER;
  BEGIN
    SaveExternalID_ByXML(pTarget      => NULL,
                         pList        => NULL,
                         pInsNumber   => TNum(vInsNumber),
                         pTestCaption => 'Сохранение пустого списка (OK)');
  END SaveExternalID_EmptyList_OK;

  /**
  * ========================================================================<br/>
  * Сохранение списка ExternalID из 1 элемента по заявке (тип не заполнен) (OK)  <br/>
  * ========================================================================<br/>
  */  
  PROCEDURE SaveExternalID_1Elem_EmptyClass_OK
  IS
    vInsNumber NUMBER;
    vList      CLOB   := getObjectClob(NULL, 12345, 'e65f9824-90f2-7da9-99f0-a23418a3e980');
  BEGIN
    SaveExternalID_ByXML(pTarget      => NULL,
                         pList        => vList,
                         pInsNumber   => TNum(vInsNumber), 
                         pTestCaption => 'Сохранение списка ExternalID из 1 элемента по заявке (тип не заполнен)(OK)');
  END SaveExternalID_1Elem_EmptyClass_OK;

  /**
  * ========================================================================<br/>
  * Сохранение списка ExternalID из 1 элемента по заявке (некорректный тип) (ERR)  <br/>
  * ========================================================================<br/>
  */  
  PROCEDURE SaveExternalID_1Elem_IncClass_ERR
  IS
    vInsNumber NUMBER := 12345;
    vList      CLOB   := getObjectClob('TESTERR', vInsNumber, 'e65f9824-90f2-7da9-99f0-a23418a3e980');
  BEGIN
    SaveExternalID_ByXML_Raise(pList        => vList,
                               pErrCode     => 'ERROR_CODE_1', 
                               pTestCaption => 'Сохранение списка ExternalID из 1 элемента по заявке (некорректный тип)(ERR)');
  END SaveExternalID_1Elem_IncClass_ERR;

  /**
  * ========================================================================<br/>
  * Сохранение списка ExternalID из 1 элемента с некорректным номером заявки (OK)<br/>
  * ========================================================================<br/>
  */  
  PROCEDURE SaveExternalID_1Elem_IncIN_OK
  IS
    vInsNumber NUMBER;
    vList      CLOB   := getObjectClob('TYPE_1', '12345_SS', 'e65f9824-90f2-7da9-99f0-a23418a3e980');
  BEGIN
    SaveExternalID_ByXML(pTarget      => NULL,
                         pList        => vList,
                         pInsNumber   => TNum(vInsNumber), 
                         pTestCaption => 'Сохранение списка ExternalID из 1 элемента с некорректным номером заявки (OK)');
  END SaveExternalID_1Elem_IncIN_OK;

Резюме

Как видно, предлагаемые практики не требуют больших усилий в части использования, но очень эффективны. Критерий их применения – «как быстро смогу починить упавший из-за моих доработок прод». Буду признателен в комментариях за описания других техник, минимизирующих ошибку разработчика.