Коротко:
- Слой базы данных - самостоятельный объект тестирования: миграции, триггеры и хранимые процедуры ломаются независимо от приложения.
- Миграции проверяют на изолированной копии данных до деплоя: схема, целостность, откат.
- Триггеры часто дают побочные эффекты, которые не видны в API-тестах - их нужно проверять напрямую через SQL.
- Хранимые процедуры тестируют как функции: входные данные, граничные значения, обработка ошибок.
- Самая частая причина потери данных при миграции - не баг в коде, а отсутствие отката и проверки целостности до запуска.
- Автотесты для БД пишут на том же уровне, что и unit-тесты: быстро, изолированно, с фиксированными данными.
Разработчик добавляет колонку в таблицу заказов. Миграция проходит за секунду. Деплой успешен. Через час служба поддержки сообщает: у части пользователей пропала история покупок. Оказывается, миграция изменила порядок JOIN-условия в триггере, который пересчитывал агрегаты. Никто этот триггер не трогал, никто его не проверял.
Такие сценарии встречаются чаще, чем кажется. Большинство команд хорошо покрыты на уровне API и UI, но слой базы данных остается серой зоной. Между тем именно здесь живут самые дорогие баги - те, которые незаметно портят или уничтожают данные.
Эта статья про database testing QA на уровне БД: как устроить проверку миграций перед выкаткой, как писать автотесты для триггеров и процедур, как не потерять данные при структурных изменениях схемы.
Почему слой БД - отдельный объект тестирования
Когда говорят об автотестах, обычно имеют в виду проверки на уровне кода или HTTP-запросов. БД при этом воспринимается как хранилище, которое «само работает». Это ошибочное допущение.
В базе данных есть своя логика: триггеры выполняют действия при изменении строк, хранимые процедуры инкапсулируют сложные операции, ограничения (constraints) защищают целостность, а миграции меняют схему под работающим приложением. Всё это - код, который можно и нужно проверять.
Ключевое отличие от тестирования на уровне приложения: ошибка в БД часто не вызывает исключения сразу. Триггер может записать неверное значение, хранимая процедура - вернуть некорректный результат, а миграция - тихо сломать индекс. Проблема всплывет позже, когда данные уже повреждены.
Тестирование миграций: что проверять до деплоя
Миграция - это изменение схемы или данных в БД. Добавление колонки, переименование таблицы, изменение типа поля, перенос данных из одной структуры в другую. Любое из этих изменений может сломать работающее приложение или привести к потере данных.
Что проверяется в миграции
Применимость на реальных данных. Миграция может нормально работать на пустой тестовой базе и падать на продакшн-данных. Типичный пример: ALTER TABLE добавляет NOT NULL колонку без DEFAULT значения, а в таблице уже есть миллионы строк. На пустой БД всё хорошо, на живой - ошибка.
Корректность преобразований данных. Если миграция переносит данные (data migration), нужно убедиться, что ни одна строка не потеряна, типы приведены правильно, NULL-значения обработаны.
Откат (rollback). Каждая миграция должна иметь обратный шаг. Перед деплоем проверяйте, что откат выполняется без ошибок и возвращает схему в исходное состояние.
Производительность на объеме. Длинные ALTER TABLE на больших таблицах блокируют запись. Это не функциональная ошибка, но критический операционный риск.
Как организовать проверку
Оптимальная схема - прогонять миграции на снэпшоте продакшн-данных (анонимизированном) в отдельном окружении. Это дает уверенность, что на реальных объемах всё пройдет штатно.
Минимальный набор шагов перед выкаткой:
- Запустить миграцию на тестовой БД с репрезентативными данными
- Проверить, что количество строк в затронутых таблицах совпадает до и после
- Проверить целостность ключей (foreign key, unique constraints)
- Запустить rollback и убедиться, что схема вернулась к исходному состоянию
- Проверить, что критичные запросы приложения работают после миграции
Частая ошибка: тестировать миграцию только на схеме без данных. Многие проблемы проявляются исключительно на реальных объемах и конкретных комбинациях значений.
Инструменты для управления миграциями
| Инструмент | Для чего подходит | Особенности |
|---|---|---|
| Flyway | Java-проекты, версионированные миграции | Поддерживает validate и repair, хранит историю в таблице flyway_schema_history |
| Liquibase | Любой стек, XML/YAML/SQL форматы | Поддерживает rollback, diff между схемами, changelog |
| Alembic | Python/SQLAlchemy | Генерирует миграции из моделей, поддерживает ветвление |
| golang-migrate | Go-проекты | Простой CLI, поддерживает несколько драйверов БД |
Проверка триггеров: где прячутся побочные эффекты
Триггер - процедура, которая автоматически выполняется при INSERT, UPDATE или DELETE в таблице. Проблема в том, что триггеры часто не покрыты никакими тестами: разработчик написал логику, убедился, что она «работает в консоли», и забыл. При следующем изменении схемы триггер ломается, но исключения нет - просто данные пишутся неверно.
Что тестировать в триггерах
Основной сценарий. Вставьте строку или измените значение - убедитесь, что триггер отработал ожидаемо. Например, если триггер обновляет счетчик в таблице-агрегате, проверьте, что счетчик изменился правильно.
Граничные значения. NULL в триггере - частый источник молчаливых ошибок. Что происходит, если поле, на которое реагирует триггер, равно NULL? Что если строка вставляется с минимальным набором обязательных полей?
Каскадные эффекты. Триггер может вызвать изменения в других таблицах, которые, в свою очередь, активируют другие триггеры. Такие цепочки легко приводят к неожиданному поведению или бесконечной рекурсии.
Поведение при откате транзакции. Триггеры выполняются внутри транзакции. Если транзакция откатывается, изменения триггера тоже должны откатиться. Это не всегда очевидно в системах, где триггер пишет в лог-таблицу с автокоммитом.
Пример автотеста для триггера (PostgreSQL + pgTAP)
pgTAP - фреймворк для тестирования прямо внутри PostgreSQL. Тест выглядит как SQL-скрипт:
SELECT plan(2);
-- Вставляем заказ
INSERT INTO orders (user_id, amount) VALUES (1, 500);
-- Проверяем, что триггер обновил баланс
SELECT is(
(SELECT total_spent FROM user_stats WHERE user_id = 1),
500,
'Триггер обновил total_spent после вставки заказа'
);
-- Удаляем заказ
DELETE FROM orders WHERE user_id = 1;
SELECT is(
(SELECT total_spent FROM user_stats WHERE user_id = 1),
0,
'Триггер обнулил total_spent после удаления заказа'
);
SELECT finish();Тесты запускаются в транзакции, которая откатывается после завершения - данные не меняются, изоляция гарантирована.
Альтернативный подход: тесты на уровне приложения
Если команда не хочет писать SQL-тесты, триггеры можно покрыть через интеграционные тесты на уровне репозитория. Логика та же: создать объект через ORM, проверить побочный эффект запросом к БД. Этот подход медленнее, но удобнее для команд без глубокого SQL-опыта.
Тестирование хранимых процедур
Хранимые процедуры - это функции, которые живут прямо в базе данных. Они могут принимать параметры, выполнять сложную логику, возвращать результирующие наборы или изменять данные. В старых корпоративных системах в процедурах сосредоточена вся бизнес-логика. В современных проектах их используют реже, но они всё ещё встречаются - особенно для батч-операций, отчётности и сложных агрегаций.
Структура тестирования хранимых процедур
Подход похож на unit-тестирование функций в коде:
- Arrange: подготовить данные в нужном состоянии
- Act: вызвать процедуру с конкретными параметрами
- Assert: проверить результат или изменения в БД
Что проверять обязательно:
- Корректный результат при валидных входных данных
- Поведение при пустом результирующем наборе
- Обработку NULL-параметров
- Граничные значения: нулевой ID, максимальное число, пустая строка
- Поведение при нарушении ограничений (например, попытка вставить дубликат)
- Откат изменений при ошибке внутри процедуры
Пример: процедура calculate_monthly_bonus(employee_id INT) рассчитывает бонус сотрудника. Нужно проверить: корректный расчет для активного сотрудника, возврат 0 для уволенного, обработку случая, когда employee_id не существует, и поведение при NULL-параметре.
Изоляция тестов
Главное правило при тестировании SQL-логики: каждый тест должен работать с предсказуемым состоянием данных и не оставлять следов для следующего теста. Для этого используют транзакционную изоляцию: открыть транзакцию, выполнить тест, откатить транзакцию. Данные в БД не меняются, тесты не зависят от порядка запуска.
Если процедура использует COMMIT внутри (что само по себе является архитектурным запахом), тестовую базу придется сбрасывать между запусками - это медленнее, но решаемо через Docker-контейнер с чистым состоянием.
Автотесты для БД: как организовать на практике
Автотесты для слоя данных можно выстроить несколькими способами, и выбор зависит от стека и размера команды.
pgTAP (PostgreSQL)
Фреймворк для тестирования прямо в PostgreSQL. Тесты пишутся на SQL, выполняются внутри базы, результат возвращается в TAP-формате. Официальная документация: pgtap.org. Подходит для команд, которые хотят держать тесты рядом со схемой.
utPLSQL (Oracle, PostgreSQL)
Аналог JUnit для PL/SQL и PL/pgSQL. Тесты описываются как пакеты процедур с аннотациями. Хорошо интегрируется в CI/CD через командную строку.
Интеграционные тесты через ORM
Если проект на Python, Java или Node.js, тесты для слоя данных пишут с использованием тестового фреймворка языка (pytest, JUnit, Jest) и реальной тестовой БД. Паттерн такой:
- Поднять тестовую БД (Docker Compose, Testcontainers)
- Применить миграции
- Запустить тест: создать данные, вызвать действие, проверить состояние БД
- Сбросить данные между тестами (транзакция или truncate)
Testcontainers - популярный инструмент для этого паттерна: он поднимает реальный контейнер с БД на время теста и уничтожает его после. Документация: testcontainers.com.
Когда использовать каждый подход
| Подход | Когда подходит | Ограничения |
|---|---|---|
| pgTAP / utPLSQL | Много логики в БД, нужны быстрые изолированные проверки схемы и функций | Требует SQL-экспертизы у команды |
| Интеграционные тесты через ORM | Основная логика в приложении, БД проверяется как часть интеграции | Медленнее, сложнее изолировать от приложения |
| Ручная проверка SQL-скриптами | Разовые изменения схемы, нет CI/CD для БД | Не воспроизводимо, не масштабируется |
Миграции без потери данных: стратегии безопасного деплоя
Потеря данных при миграции почти всегда вызвана не одной ошибкой, а цепочкой упущений. Вот наиболее надежные подходы к безопасному изменению схемы.
Expand-contract (двухфазная миграция)
Вместо прямого переименования или удаления колонки используют двухфазный подход:
- Expand: добавить новую структуру рядом со старой (новая колонка, новая таблица). Приложение начинает писать в обе.
- Migrate: перенести существующие данные из старой структуры в новую.
- Contract: когда все данные перенесены и приложение больше не читает старое, удалить старую структуру.
Это занимает несколько деплоев, но гарантирует, что данные не теряются и откат возможен на любом шаге.
Резервная копия перед деструктивными изменениями
Любая миграция, которая изменяет тип данных, удаляет колонку или таблицу, должна сопровождаться снятием резервной копии непосредственно перед выкаткой. Это звучит очевидно, но на практике часто пропускается.
Dry-run на продакшн-копии
Прогнать миграцию на полной копии продакшн-базы (анонимизированной) до реального деплоя. Это выявляет проблемы с объемом данных, заблокированными таблицами и нарушением ограничений.
Хорошая практика: после каждой миграции запускать набор smoke-запросов: SELECT COUNT(*) по ключевым таблицам, проверку нескольких critical-записей, выполнение основных JOIN-запросов приложения. Это занимает минуту, но сразу показывает, всё ли на месте.
Типичные ошибки при работе со слоем данных
Большинство проблем, которые приводят к инцидентам, повторяются из проекта в проект.
| Ошибка | Последствие | Как избежать |
|---|---|---|
| Миграция без проверки на реальных данных | Падение при деплое или тихое повреждение данных | Прогонять на копии продакшн-данных |
| Нет rollback-шага | Невозможность откатить изменения при ошибке | Писать down-миграцию для каждого изменения |
| Триггеры без тестов | Побочные эффекты при изменении схемы остаются незамеченными | Покрывать каждый триггер минимум двумя сценариями |
| Хранимая процедура с COMMIT внутри | Невозможность откатить транзакцию целиком | Избегать COMMIT в процедурах, управлять транзакциями снаружи |
| Прямое изменение типа колонки с данными | Потеря данных, которые не конвертируются | Использовать expand-contract, проверять конвертацию на выборке |
| Тестирование только на схеме без данных | Проблемы с ограничениями всплывают на продакшне | Всегда использовать репрезентативный датасет |
Чеклист перед деплоем изменений в БД
- Миграция протестирована на копии продакшн-данных (или репрезентативном датасете)
- Написан rollback-шаг и проверен отдельно
- Количество строк в затронутых таблицах совпадает до и после
- Проверена целостность внешних ключей и уникальных ограничений
- Все триггеры, связанные с изменяемыми таблицами, прогнаны в тестах
- Хранимые процедуры, использующие изменяемые поля, проверены на граничных значениях
- Smoke-запросы по критичным таблицам выполнены успешно
- Снята резервная копия перед деструктивными изменениями
- Проверено время выполнения миграции - нет блокировок на длинных таблицах
- Изменения задокументированы в changelog или комментарии к миграции