SQL для QA: что реально спрашивают на собеседованиях

Минимум SQL, который должен знать каждый тестировщик — с реальными задачами и ловушками


SQL для QA

Привет, Хабр! Это продолжение серии про QA собеседования. Уже разобрали тест-дизайн, API и Security и System Design. Теперь — SQL.

Честно: на собеседованиях SQL задают чаще, чем многие ожидают. Не уровня DBA, но и не SELECT * FROM users. Обычно дают таблицу и просят написать запрос прямо на доске или в Google Docs. Если впадаете в ступор при слове JOIN — эта статья для вас.


Зачем QA вообще SQL?

Потому что тестировать через UI — это смотреть на айсберг сверху. А баги живут под водой — в базе данных.

Вот реальная ситуация. Тестировщик создал заказ через UI, проверил — на экране всё красиво: статус «Оплачен», сумма 5000₽. Отлично, тест прошёл. А потом в production клиент жалуется, что ему списали 5000₽ дважды. Оказалось, в базе создались две записи в таблице payments — баг в бэкенде. UI показывал только последнюю. Если бы тестировщик после создания заказа заглянул в базу — поймал бы сразу.

SQL нужен QA для трёх вещей:

  1. Верификация данных. UI показывает одно — в базе может быть другое. SELECT после каждого действия — привычка хорошего тестировщика.
  2. Подготовка тестовых данных. Нужно 100 пользователей для нагрузочного теста? Руками через UI — день работы. Один INSERT — секунда.
  3. Поиск причины бага. «Пользователь не видит свои заказы» — это проблема в UI, в API, или данные в базе кривые? Один запрос — и понятно, куда копать.

SELECT — получаем данные

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

Допустим, у нас таблица users:

idnameemailagecitycreated_at
1Atajanatajan@mail.com30Ashgabat2025-01-15
2Mariamaria@mail.com25Moscow2025-03-20
3Johnjohn@mail.com35Istanbul2025-06-10
4Annaanna@mail.com22Moscow2025-09-01
5Kemalkemal@mail.com28Ashgabat2026-01-05

Получить все данные

SELECT * FROM users;

Звёздочка * — «дай все столбцы». Вернёт всю таблицу целиком (все 5 строк, все 6 столбцов).

Выбрать конкретные столбцы

SELECT name, email FROM users;
nameemail
Atajanatajan@mail.com
Mariamaria@mail.com
Johnjohn@mail.com
Annaanna@mail.com
Kemalkemal@mail.com

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

WHERE — фильтрация

WHERE — это фильтр. «Дай мне только те строки, где условие истинно.»

SELECT * FROM users WHERE city = 'Moscow';
idnameagecity
2Maria25Moscow
4Anna22Moscow

AND, OR — комбинация условий

-- Москва И старше 23
SELECT * FROM users WHERE city = 'Moscow' AND age > 23;

Результат: только Maria (25 лет). Anna (22) не прошла по возрасту.

-- Москва ИЛИ Стамбул
SELECT * FROM users WHERE city = 'Moscow' OR city = 'Istanbul';

Результат: Maria, John, Anna.

IN — вместо кучи OR

SELECT * FROM users WHERE city IN ('Moscow', 'Istanbul');

Делает то же самое, что city = 'Moscow' OR city = 'Istanbul', но короче и читабельнее. Особенно когда значений 5-10.

BETWEEN — диапазон

SELECT * FROM users WHERE age BETWEEN 25 AND 35;
nameage
Atajan30
Maria25
John35
Kemal28

BETWEEN включает обе границы: 25 и 35 попадают в результат. Аналог: age >= 25 AND age <= 35.

LIKE — поиск по шаблону

SELECT * FROM users WHERE email LIKE '%@mail.com';

% — любое количество любых символов. %@mail.com — «что угодно, заканчивающееся на @mail.com». Вернёт всех пятерых.

Другие примеры:

  • LIKE 'A%' — начинается на A (Atajan, Anna)
  • LIKE '%an%' — содержит «an» (Atajan, Anna)
  • LIKE '_ohn' — 4 символа, заканчивается на «ohn» (John). _ = ровно один символ

IS NULL — проверка на пустоту

SELECT * FROM users WHERE city IS NULL;

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

Ловушка на собеседовании: WHERE city = NULLне работает! NULL — это не значение, а отсутствие значения. Его нельзя сравнить через =. Любое сравнение с NULL даёт NULL (не TRUE и не FALSE).

-- Неправильно (вернёт 0 строк, даже если NULL есть)
SELECT * FROM users WHERE city = NULL;

-- Правильно
SELECT * FROM users WHERE city IS NULL;
SELECT * FROM users WHERE city IS NOT NULL;

ORDER BY, LIMIT, DISTINCT

ORDER BY — сортировка

SELECT * FROM users ORDER BY age DESC;

ASC — по возрастанию (по умолчанию), DESC — по убыванию. Результат: John (35), Atajan (30), Kemal (28), Maria (25), Anna (22).

LIMIT — ограничить количество

SELECT * FROM users ORDER BY age ASC LIMIT 3;

Результат: три самых молодых — Anna (22), Maria (25), Kemal (28).

DISTINCT — уникальные значения

SELECT DISTINCT city FROM users;

Результат: Ashgabat, Moscow, Istanbul (3 уникальных города, без дублей).

SELECT COUNT(DISTINCT city) FROM users;

Результат: 3. Полезно для быстрой проверки: «сколько уникальных значений в столбце?»


JOIN — главный вопрос собеседования

JOIN спрашивают в 80% случаев. Не потому что это сложно, а потому что кандидаты путают типы.

Добавим вторую таблицу orders:

iduser_idproductamountstatus
11Laptop50000paid
21Mouse2000paid
32Keyboard3000cancelled
43Monitor25000paid
599Headphones5000paid

Обратите внимание: user_id = 99 не существует в таблице users, а пользователи Anna (4) и Kemal (5) не делали заказов.

INNER JOIN — только совпадения

SELECT users.name, orders.product, orders.amount
FROM users
INNER JOIN orders ON users.id = orders.user_id;
nameproductamount
AtajanLaptop50000
AtajanMouse2000
MariaKeyboard3000
JohnMonitor25000

Anna и Kemal — не попали (нет заказов). Заказ с user_id = 99 — тоже не попал (нет такого пользователя).

LEFT JOIN — все из левой + совпадения из правой

SELECT users.name, orders.product
FROM users
LEFT JOIN orders ON users.id = orders.user_id;
nameproduct
AtajanLaptop
AtajanMouse
MariaKeyboard
JohnMonitor
AnnaNULL
KemalNULL

Anna и Kemal попали с NULL — они есть в users, но заказов нет.

RIGHT JOIN — все из правой + совпадения из левой

SELECT users.name, orders.product
FROM users
RIGHT JOIN orders ON users.id = orders.user_id;
nameproduct
AtajanLaptop
AtajanMouse
MariaKeyboard
JohnMonitor
NULLHeadphones

Headphones попал с NULL — заказ есть, но user_id = 99 не существует.

FULL JOIN — всё из обеих таблиц

Комбинация LEFT и RIGHT: все пользователи + все заказы, даже если нет совпадений.

Шпаргалка

INNER JOIN  =  A ∩ B         (только совпадения)
LEFT JOIN   =  A + (A ∩ B)   (всё из левой)
RIGHT JOIN  =  B + (A ∩ B)   (всё из правой)
FULL JOIN   =  A ∪ B         (вообще всё)

Задача с собеседования: «Найди пользователей, которые не сделали ни одного заказа.»

SELECT users.name
FROM users
LEFT JOIN orders ON users.id = orders.user_id
WHERE orders.id IS NULL;

Результат: Anna, Kemal. LEFT JOIN даёт NULL для тех, у кого нет заказов, а WHERE отфильтровывает только их. Эту задачу дают постоянно — выучите наизусть.


Агрегатные функции

ФункцияЧто делаетПример
COUNT()Считает строкиSELECT COUNT(*) FROM users; → 5
SUM()СуммаSELECT SUM(amount) FROM orders;
AVG()СреднееSELECT AVG(age) FROM users;
MAX()МаксимумSELECT MAX(amount) FROM orders;
MIN()МинимумSELECT MIN(age) FROM users;

Ловушка: COUNT(*) считает все строки, включая NULL. COUNT(column) — только не-NULL значения. На собеседовании могут спросить разницу.

-- Сколько заказов у каждого пользователя
SELECT users.name, COUNT(orders.id) AS order_count
FROM users
LEFT JOIN orders ON users.id = orders.user_id
GROUP BY users.name;
nameorder_count
Atajan2
Maria1
John1
Anna0
Kemal0

GROUP BY и HAVING

GROUP BY группирует строки, HAVING фильтрует группы. Главное отличие от WHERE: WHERE фильтрует строки до группировки, HAVING — после.

-- Города с более чем одним пользователем
SELECT city, COUNT(*) AS user_count
FROM users
GROUP BY city
HAVING COUNT(*) > 1;
cityuser_count
Ashgabat2
Moscow2

Задача с собеседования: «Найди пользователей, которые потратили больше 10 000₽.»

SELECT users.name, SUM(orders.amount) AS total_spent
FROM users
INNER JOIN orders ON users.id = orders.user_id
WHERE orders.status = 'paid'
GROUP BY users.name
HAVING SUM(orders.amount) > 10000;
nametotal_spent
Atajan52000
John25000

Обратите внимание: Maria отфильтровалась, потому что её заказ cancelled, а WHERE отсёк его до группировки.


Подзапросы

Подзапрос — это запрос внутри запроса. Иногда без них не обойтись.

-- Пользователи, чей возраст выше среднего
SELECT name, age
FROM users
WHERE age > (SELECT AVG(age) FROM users);
nameage
Atajan30
John35

Средний возраст = 28. Atajan (30) и John (35) — выше.

-- Пользователи, у которых есть хотя бы один оплаченный заказ
SELECT name
FROM users
WHERE id IN (
    SELECT DISTINCT user_id
    FROM orders
    WHERE status = 'paid'
);

Результат: Atajan, John.

Задача с собеседования: «Найди товар с максимальной суммой заказа.»

-- Способ 1: подзапрос
SELECT product, amount
FROM orders
WHERE amount = (SELECT MAX(amount) FROM orders);

-- Способ 2: ORDER BY + LIMIT
SELECT product, amount
FROM orders
ORDER BY amount DESC
LIMIT 1;

Оба вернут: Laptop, 50000. Но если максимум не уникален — первый способ вернёт все записи, второй — только одну.


Задачи с реальных собеседований

Задача 1: «Дублирующиеся email»

Найдите email, которые встречаются больше одного раза.

SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;

Зачем это QA? Проверяем, что система правильно блокирует регистрацию с дублем email. Если запрос возвращает строки — баг.

Задача 2: «Второй по величине заказ»

Найдите сумму второго по величине заказа.

SELECT DISTINCT amount
FROM orders
ORDER BY amount DESC
LIMIT 1 OFFSET 1;

Результат: 25000 (после Laptop за 50000). OFFSET 1 пропускает первую строку.

Задача 3: «Пользователи без заказов за последний месяц»

Найдите пользователей, которые зарегистрированы больше месяца назад, но не сделали ни одного заказа.

SELECT u.name, u.email, u.created_at
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL
  AND u.created_at < NOW() - INTERVAL '1 month';

Зачем это QA? Например, тестируем email-рассылку «Вы давно не заказывали» — нужно убедиться, что она уходит именно этим пользователям.

Задача 4: «Топ-3 покупателя»

Найдите 3 покупателей с наибольшей суммой оплаченных заказов.

SELECT u.name, SUM(o.amount) AS total
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.status = 'paid'
GROUP BY u.name
ORDER BY total DESC
LIMIT 3;

Задача 5: «Конверсия по городам»

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

SELECT
    u.city,
    COUNT(DISTINCT u.id) AS total_users,
    COUNT(DISTINCT o.user_id) AS buyers,
    ROUND(
        COUNT(DISTINCT o.user_id) * 100.0 / COUNT(DISTINCT u.id), 1
    ) AS conversion_pct
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.city;
citytotal_usersbuyersconversion_pct
Ashgabat2150.0
Moscow2150.0
Istanbul11100.0

Это уже middle+ уровень. Если решите на собеседовании — впечатлите.


UPDATE и DELETE — с осторожностью

На собеседовании иногда просят написать UPDATE или DELETE. Главное правило: всегда WHERE. DELETE FROM users без WHERE удалит всех пользователей. На production это катастрофа.

-- Обновить город пользователя
UPDATE users SET city = 'Istanbul' WHERE id = 1;

-- Удалить отменённые заказы старше года
DELETE FROM orders
WHERE status = 'cancelled'
  AND created_at < NOW() - INTERVAL '1 year';

Совет с собеседования: перед DELETE/UPDATE сначала напишите SELECT с тем же WHERE — убедитесь, что затронете именно нужные строки.

-- Сначала проверяем
SELECT * FROM orders
WHERE status = 'cancelled'
  AND created_at < NOW() - INTERVAL '1 year';

-- Убедились — удаляем
DELETE FROM orders
WHERE status = 'cancelled'
  AND created_at < NOW() - INTERVAL '1 year';

5 ловушек, на которых валятся кандидаты

1. NULL — не значение, а отсутствие значения.

-- Неправильно
SELECT * FROM users WHERE city = NULL;

-- Правильно
SELECT * FROM users WHERE city IS NULL;

NULL = NULL — это не TRUE. Это NULL. Любая операция с NULL даёт NULL.

2. COUNT(*) vs COUNT(column).

COUNT(*) — считает все строки. COUNT(city) — только те, где city не NULL.

3. GROUP BY — все неагрегированные столбцы.

-- Ошибка: name не в GROUP BY и не в агрегатной функции
SELECT name, city, COUNT(*)
FROM users
GROUP BY city;

-- Правильно
SELECT city, COUNT(*)
FROM users
GROUP BY city;

4. WHERE vs HAVING.

WHERE фильтрует строки до группировки. HAVING — после. Нельзя использовать агрегатные функции в WHERE.

-- Ошибка
SELECT city, COUNT(*) FROM users WHERE COUNT(*) > 1 GROUP BY city;

-- Правильно
SELECT city, COUNT(*) FROM users GROUP BY city HAVING COUNT(*) > 1;

5. Порядок выполнения SQL — не тот, что вы пишете.

Пишете: SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY

Выполняется: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY

Поэтому в WHERE нельзя использовать алиасы из SELECT — они ещё не существуют.


Чек-лист: что знать перед собеседованием

  • SELECT, WHERE, AND/OR, IN, BETWEEN, LIKE, IS NULL — базовый синтаксис
  • ORDER BY, LIMIT, OFFSET — сортировка и пагинация
  • DISTINCT — уникальные значения
  • JOIN — INNER, LEFT, RIGHT, FULL (и когда какой)
  • «Пользователи без заказов» — LEFT JOIN + WHERE IS NULL
  • COUNT, SUM, AVG, MAX, MIN — агрегатные функции
  • GROUP BY + HAVING — группировка и фильтрация групп
  • Подзапросы — WHERE IN (SELECT …) и скалярные
  • UPDATE и DELETE — всегда с WHERE, сначала SELECT
  • NULL — IS NULL, не = NULL
  • COUNT(*) vs COUNT(column)
  • Порядок выполнения SQL

Этого хватит для 90% QA собеседований. Не нужно знать оконные функции, CTE или хранимые процедуры — это уже для DBA.


Как практиковаться

  1. SQLBolt — интерактивные уроки с нуля. Бесплатно. 15 минут в день — за неделю освоите базу.
  2. LeetCode (Database) — задачи уровня собеседований. Начните с Easy.
  3. HackerRank SQL — задачи с проверкой. Хорошая подборка для начинающих.
  4. Ваш рабочий проект. Если есть доступ к тестовой БД — пишите запросы к реальным данным. Это лучшая практика.

Курс по тестированию с практическими заданиями — бесплатно на annayev.com (English, Русский, Türkçe).

Ставьте плюс, если было полезно. Какие SQL-задачи вам давали на собеседованиях? Пишите в комментариях.