Форма которая возвращает HTTP 200 и показывает сообщение об успехе может не записать данные в базу, если обработчик ошибок молча проглотил нарушение ограничения. Прямые SQL-запросы это ловят: они проверяют что нужная строка существует в нужной таблице с нужными значениями, минуя UI и API полностью. Здесь разобрано пять паттернов запросов которые покрывают большинство QA-сценариев, как быстро создавать и удалять тестовые данные, и как запускать проверки базы данных прямо из Playwright-тестов.
Зачем SQL нужен QA-инженеру
Каждое веб-приложение устроено одинаково: фронтенд отображает данные, API обрабатывает запросы, база данных хранит состояние. Автоматизированные тесты через UI проверяют первый слой. API-тесты проверяют второй. Третий слой (попали ли нужные данные в нужную таблицу с нужными значениями) остаётся невидимым без прямого взгляда.
Этот пробел регулярно проявляется в реальной работе. Отправка формы возвращает HTTP 200, UI показывает сообщение об успехе, а запись в базу молча не прошла из-за нарушения ограничения которое проглотил обработчик ошибок. Обновление статуса визуально отображается в UI потому что фронтенд обновил локальное состояние, но API-вызов который должен был записать данные в бэкенд так и не отправился. Скрипт сида для тестовых данных выполнился, тест запустился, и оказалось что данные вставились не в ту базу.
SQL даёт три конкретных возможности: проверить что действия привели к ожидаемому изменению данных, настроить тестовые данные быстрее чем любой UI-сценарий, и диагностировать падения которые интерфейс приложения скрывает.
Для этого не нужны глубокие знания баз данных. Пять паттернов запросов покрывают подавляющее большинство QA-задач на практике.
Пять запросов которые закрывают 80% QA-работы
SELECT с WHERE получает строки по условию:-- Найти конкретного пользователя по email
SELECT id, email, role, is_active
FROM users
WHERE email = 'testuser@example.com';
-- Все заказы конкретного клиента
SELECT id, status, total_amount, created_at
FROM orders
WHERE customer_id = 42;
-- Заказы за последние 24 часа
SELECT id, customer_id, status, total_amount
FROM orders
WHERE created_at > NOW() - INTERVAL '24 hours';-- Заказы с email клиента
SELECT orders.id, orders.status, orders.total_amount, users.email
FROM orders
JOIN users ON orders.customer_id = users.id
WHERE orders.id = 1001;-- Количество заказов по статусу
SELECT status, COUNT(*) AS total
FROM orders
GROUP BY status;INSERT INTO users (email, password_hash, role, created_at)
VALUES ('qa_test_user@test.com', 'hashed_value', 'customer', NOW());UPDATE orders
SET status = 'cancelled'
WHERE id = 1001;UPDATE orders SET status = 'cancelled' без фильтра отменит все заказы в таблице. Всегда пиши WHERE сначала, потом добавляй изменение.Проверка результатов: что UI не показывает
После тестового действия самая прямая проверка: запрос который смотрит на ожидаемое состояние в базе. Если запрос вернул нужную строку, операция успешно прошла до конца. Если вернул ничего, что-то сломалось между действием пользователя и сохранённым состоянием, даже если UI выглядел успешным.
После регистрации пользователя:
-- Создался ли пользователь с правильными дефолтами?
SELECT id, email, role, is_active, email_verified
FROM users
WHERE email = 'newuser@test.com';Ожидаешь одну строку с role = 'customer', is_active = true и email_verified = false. Любое отклонение: баг который стоит найти до продакшна.
После оформления заказа:
-- Правильно ли записан статус и сумма заказа?
SELECT
o.id,
o.status,
o.total_amount,
o.created_at,
u.email AS customer_email
FROM orders o
JOIN users u ON o.customer_id = u.id
WHERE u.email = 'testuser@test.com'
ORDER BY o.created_at DESC
LIMIT 1;Ожидаешь status = 'pending' и total_amount совпадающий с тем что показал UI. Если сумма отличается на копейку, нашёл баг округления. Если статус не 'pending', нашёл баг перехода состояний.
Проверка количества записей:
-- Ровно три позиции в заказе?
SELECT COUNT(*) AS item_count
FROM order_items
WHERE order_id = 1001;
-- Нет ли дублирующих записей пользователя?
SELECT COUNT(*) AS duplicate_count
FROM users
WHERE email = 'testuser@test.com';В автоматизированном тесте сравниваешь возвращённое число с ожидаемым. В ручном тестировании запускаешь в SQL-клиенте и смотришь на результат.
Создание тестовых данных через INSERT и их очистка
Создавать тестовые данные через UI медленно. Тест которому нужен пользователь с пятью завершёнными заказами, двумя ожидающими и одним отменённым может занять десять минут на подготовку вручную. С SQL это секунды.
-- Создаём тестового пользователя
INSERT INTO users (email, password_hash, role, is_active, created_at)
VALUES (
'qa_load_test_user@test.com',
'$2b$10$placeholder_hash_value',
'customer',
true,
NOW()
);
-- Получаем ID нового пользователя для следующих вставок
SELECT id FROM users WHERE email = 'qa_load_test_user@test.com';
-- Создаём заказы для пользователя (предположим id = 99)
INSERT INTO orders (customer_id, status, total_amount, created_at)
VALUES
(99, 'completed', 89.99, NOW() - INTERVAL '10 days'),
(99, 'completed', 124.50, NOW() - INTERVAL '7 days'),
(99, 'completed', 45.00, NOW() - INTERVAL '4 days'),
(99, 'pending', 67.25, NOW() - INTERVAL '1 day'),
(99, 'pending', 210.00, NOW()),
(99, 'cancelled', 30.00, NOW() - INTERVAL '5 days');База в нужном состоянии без единого клика в UI. Тест теперь может проверять поведения которые зависят от истории заказов: скидки за лояльность, ограничения по количеству заказов, статистику в дашборде.
Очистка не менее важна. Тестовые данные оставленные в общей базе вызывают загрязнение тестов: следующий тест находит неожиданные записи, счётчики возвращают неверные значения, и час уходит на отладку того что никогда не было сломано.
-- Очистка после тестов с единым соглашением об именовании
DELETE FROM orders WHERE customer_id IN (
SELECT id FROM users WHERE email LIKE '%@test.com'
);
DELETE FROM users WHERE email LIKE '%@test.com';Соглашение об именовании важно: используй постоянный домен вроде @test.com или префикс qa_ для всех тестовых данных. Это делает запросы очистки безопасными и предсказуемыми.
SELECT * FROM users WHERE email LIKE '%@test.com' перед DELETE FROM users WHERE email LIKE '%@test.com' занимает пять секунд и может предотвратить очень неприятный день.JOIN для QA: поиск осиротевших записей и проверка связей
Реляционные базы данных обеспечивают связи через внешние ключи, но баги случаются на границах между таблицами. Позиция заказа может ссылаться на удалённый продукт. Запись сессии может указывать на несуществующего пользователя. Такие осиротевшие записи вызывают скрытые сбои которые сложно отследить без прямого взгляда на данные.
LEFT JOIN обнаруживает эти пробелы. В отличие от обычного JOIN который возвращает только строки где обе стороны имеют совпадение, LEFT JOIN возвращает все строки из левой таблицы и заполняет NULL для колонок из правой таблицы где совпадения нет:
-- Позиции заказа без соответствующего продукта (осиротевшие записи)
SELECT
order_items.id AS item_id,
order_items.order_id,
order_items.product_id,
products.name AS product_name
FROM order_items
LEFT JOIN products ON order_items.product_id = products.id
WHERE products.id IS NULL;Любая строка в результате: осиротевшая позиция заказа, ссылающаяся на несуществующий продукт. В здоровой базе этот запрос возвращает ноль строк.
-- Заказы без клиента (не должно быть возможно, но стоит проверить)
SELECT orders.id, orders.customer_id, orders.total_amount
FROM orders
LEFT JOIN users ON orders.customer_id = users.id
WHERE users.id IS NULL;
-- Проверить что у каждого заказа есть хотя бы одна позиция
SELECT
o.id AS order_id,
o.status,
COUNT(oi.id) AS item_count
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, o.status
HAVING COUNT(oi.id) = 0;Запускай эти запросы после сценариев с удалением, архивированием или массовыми операциями. Каскадное удаление легко настроить неправильно, и осиротевшая запись которую должны были очистить может вызвать ошибки приложения через несколько дней.
Агрегатные запросы: проверка расчётов в UI
Когда приложение показывает итог, среднее, счётчик или сводку, это можно проверить прямо против базы. Если UI показывает «Выручка: $4 827.50» за прошлый месяц, база должна согласиться.
-- Проверить общую выручку за период
SELECT
SUM(total_amount) AS total_revenue,
COUNT(*) AS order_count,
AVG(total_amount) AS average_order_value
FROM orders
WHERE status = 'completed'
AND created_at >= '2026-05-01'
AND created_at < '2026-06-01';
-- Проверить разбивку выручки по продуктам
SELECT
p.name AS product_name,
SUM(oi.quantity) AS units_sold,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN orders o ON oi.order_id = o.id
WHERE o.status = 'completed'
GROUP BY p.id, p.name
ORDER BY revenue DESC;Расхождение между расчётом UI и базы означает одно из трёх: UI вычисляет неправильно, запрос питающий UI содержит баг, или тестовые данные в неожиданном состоянии. Все три варианта стоит находить.
-- Проверить что статус остатков совпадает с отображаемым на странице продукта
SELECT
id,
name,
stock_quantity,
CASE WHEN stock_quantity = 0 THEN 'out_of_stock'
WHEN stock_quantity < 10 THEN 'low_stock'
ELSE 'in_stock'
END AS computed_status
FROM products
WHERE id IN (101, 102, 103);Колонка computed_status позволяет сравнить то что считает SQL с тем что показывает UI. Если UI показывает «В наличии» а запрос возвращает 'low_stock', нашёл либо баг отображения, либо несоответствие бизнес-логики.
Подключение к базе из теста
Для ручной проверки при отладке SQL-клиент вроде TablePlus или DBeaver: самый быстрый путь. Подключился к базе, запустил запрос, прочитал результат. Но для автоматизированных тест-сьютов проверка базы должна происходить внутри самого теста.
Из командной строки через psql
# Выполнить один запрос и получить результат
psql $DATABASE_URL -c "SELECT COUNT(*) FROM orders WHERE status = 'pending';"
# Выполнить файл с запросами
psql $DATABASE_URL -f verify_order_state.sqlИз Playwright-теста через node-postgres
import { test, expect } from '@playwright/test';
import { Client } from 'pg'; // npm install pg
test('order is saved to database after checkout', async ({ page }) => {
const testEmail = 'qa_checkout_test@test.com';
// Выполняем UI-действие
await page.goto('/checkout');
// ... заполняем корзину, вводим доставку, отправляем заказ ...
// Проверяем результат в базе данных
const db = new Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
try {
const result = await db.query(
`SELECT o.id, o.status, o.total_amount
FROM orders o
JOIN users u ON o.customer_id = u.id
WHERE u.email = $1
ORDER BY o.created_at DESC
LIMIT 1`,
[testEmail]
);
expect(result.rows).toHaveLength(1);
expect(result.rows[0].status).toBe('pending');
expect(parseFloat(result.rows[0].total_amount)).toBeCloseTo(89.99, 2);
} finally {
await db.end();
}
});Плейсхолдер $1 важен. Никогда не склеивай значения контролируемые пользователем в SQL-строку: используй параметризованные запросы для любых значений из тестового ввода или внешних данных. Помимо предотвращения SQL-инъекций в тестовых утилитах, это корректно обрабатывает спецсимволы в email или именах.
.env.test и никогда не коммить её. В коде теста используй process.env.DATABASE_URL, а сам .env.test добавь в .gitignore. CI-пайплайн должен передавать значение через переменные окружения, не через зафиксированные файлы.Практичный паттерн для крупных тест-сьютов: создать хелперы для работы с базой рядом с Page Object:
// helpers/db.ts
import { Client } from 'pg';
export async function getLatestOrderForUser(email: string) {
const db = new Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
try {
const result = await db.query(
`SELECT o.* FROM orders o
JOIN users u ON o.customer_id = u.id
WHERE u.email = $1
ORDER BY o.created_at DESC LIMIT 1`,
[email]
);
return result.rows[0] ?? null;
} finally {
await db.end();
}
}
export async function cleanupTestUser(email: string) {
const db = new Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
try {
await db.query(
`DELETE FROM orders WHERE customer_id IN
(SELECT id FROM users WHERE email = $1)`,
[email]
);
await db.query('DELETE FROM users WHERE email = $1', [email]);
} finally {
await db.end();
}
}В тесте:
import { getLatestOrderForUser, cleanupTestUser } from '../helpers/db';
test.afterEach(async () => {
await cleanupTestUser('qa_checkout_test@test.com');
});
test('order status is pending after checkout', async ({ page }) => {
// ... UI-действия ...
const order = await getLatestOrderForUser('qa_checkout_test@test.com');
expect(order).not.toBeNull();
expect(order.status).toBe('pending');
});SQL остаётся вне тела теста, ассерты читаются чётко.
Правила безопасности при работе с базой в тестах
Всегда используй WHERE при UPDATE и DELETE. Это стоит повторить. Самая опасная ошибка в SQL: запустить изменяющий запрос без фильтра. Перед выполнением любого UPDATE или DELETE прочитай его вслух и убедись что WHERE есть и корректен. Запускай SELECT перед DELETE при очистке. Если собираешься удалить тестовые данные, сначала запусти SELECT-версию запроса чтобы увидеть что именно будет удалено. Если результат выглядит правильно, меняй SELECT на DELETE и запускай. Знай к какому окружению подключён. Большинство SQL-клиентов показывают текущее соединение в заголовке или вкладке. Перед любым запросом на запись убедись что работаешь с базой разработки или staging, а не с продакшном. Чёткое соглашение об именовании баз (myapp_dev, myapp_staging, myapp_prod) упрощает проверку с первого взгляда.
Используй транзакции для многошаговой настройки данных. Когда сид вставляет данные в несколько таблиц, оберни всё в транзакцию чтобы частичные сбои не оставляли базу в сломанном состоянии:
BEGIN;
INSERT INTO users (email, role, is_active)
VALUES ('qa_transaction_test@test.com', 'customer', true);
INSERT INTO orders (customer_id, status, total_amount)
VALUES (LASTVAL(), 'pending', 99.00);
-- Если что-то выше упало, обе вставки откатятся
COMMIT;FAQ
Нужно ли понимать дизайн баз данных чтобы писать QA SQL
Нет. Нужно понимать таблицы которые использует приложение: как они называются, какие у них колонки, как они связаны. Это берётся из чтения схемы (большинство SQL-клиентов показывают её в боковой панели), вопросов к разработчику или ORM-моделей в кодовой базе. Знать почему схема спроектирована именно так необязательно.
В чём разница между INNER JOIN и LEFT JOIN
INNER JOIN (или просто JOIN) возвращает строки только там где обе таблицы имеют совпадающую запись. LEFT JOIN возвращает все строки из левой таблицы и ставит NULL в колонки правой таблицы там где совпадения нет. Для QA-работы: используй JOIN когда нужны объединённые данные, LEFT JOIN когда ищешь отсутствующие связи.
Тестировать против отдельной тестовой базы или общей базы разработки
Отдельная тестовая база лучше. Когда несколько разработчиков и тестовых пайплайнов используют одну базу, тестовые данные из разных прогонов перемешиваются, и ассерты на количество или состояние становятся ненадёжными. Настрой отдельную базу с той же схемой и минимальными базовыми данными которой тесты владеют полностью.
Запрос возвращает ноль строк, но данные точно там. Что не так
Четыре частые причины: значение в фильтре отличается по регистру от данных в базе (WHERE email = 'Test@example.com' не совпадёт с test@example.com в чувствительной к регистру коллации); в колонке есть пробелы в начале или конце значения; подключился не к той базе или схеме; данные вставлены внутри незафиксированной транзакции которую другое соединение ещё не видит.
Как узнать какие таблицы есть в базе
В PostgreSQL: SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';. В MySQL: SHOW TABLES;. Большинство GUI-клиентов также показывают схему в виде дерева слева.