Привет, Хабр! Это четвёртая статья из серии про QA собеседования. Уже разобрали тест-дизайн, API и Security и System Design. Теперь — SQL. На собеседованиях SQL задают чаще, чем многие ожидают. Не уровня DBA, но и не SELECT * FROM users. Обычно дают таблицу и просят написать запрос прямо на доске или в Google Docs. Если впадаете в ступор при слове JOIN — эта статья для вас.
Содержание
Зачем QA вообще SQL?
SELECT, WHERE и базовый синтаксис
ORDER BY, LIMIT, DISTINCT
JOIN — главный вопрос собеседования
Агрегатные функции
GROUP BY и HAVING
Подзапросы
Задачи с реальных собеседований
UPDATE и DELETE — с осторожностью
5 ловушек, на которых валятся кандидаты
Чек-лист перед собеседованием
Как практиковаться
Исходные данные для всех примеров
Во всех примерах ниже используем две таблицы. Запомните их — они будут возвращаться снова и снова. Таблица users:
id | name | age | city | created_at | |
|---|---|---|---|---|---|
1 | Test_user | Test_user@mail.com | 30 | Ashgabat | 2025-01-15 |
2 | Maria | maria@mail.com | 25 | Moscow | 2025-03-20 |
3 | John | john@mail.com | 35 | Istanbul | 2025-06-10 |
4 | Anna | anna@mail.com | 22 | Moscow | 2025-09-01 |
5 | Kemal | kemal@mail.com | 28 | Ashgabat | 2026-01-05 |
Таблица orders:
id | user_id | product | amount | status |
|---|---|---|---|---|
1 | 1 | Laptop | 50000 | paid |
2 | 1 | Mouse | 2000 | paid |
3 | 2 | Keyboard | 3000 | cancelled |
4 | 3 | Monitor | 25000 | paid |
5 | 99 | Headphones | 5000 | paid |
Обратите внимание: user_id = 99 не существует в users, а пользователи Anna (4) и Kemal (5) не делали заказов. Это важно для понимания JOIN-ов.
1. Зачем QA вообще SQL?
Потому что тестировать через UI — это смотреть на айсберг сверху. А баги живут под водой — в базе данных. Вот реальная ситуация. Тестировщик создал заказ через UI, проверил — на экране всё красиво: статус «Оплачен», сумма 5000₽. Тест прошёл. А потом в production клиент жалуется, что ему списали 5000₽ дважды. Оказалось, в базе создались две записи в таблице payments — баг в бэкенде. UI показывал только последнюю. Если бы тестировщик заглянул в базу — поймал бы сразу. SQL нужен QA для трёх вещей:
# | Зачем | Пример |
|---|---|---|
1 | Верификация данных | UI показывает «Оплачен» — а в базе статус |
2 | Подготовка тестовых данных | 100 пользователей через UI — день. Один |
3 | Поиск причины бага | «Не видит заказы» — UI, API или данные кривые? Один запрос — и ясно |
2. SELECT — получаем данные
Начнём с того, что знают все. Но я видел людей, которые путаются даже тут, так что пробежимся быстро.
2.1 Получить все данные
SELECT FROM users; Звёздочка — «дай все столбцы». Вернёт всю таблицу целиком (все 5 строк, все 6 столбцов).
2.2 Выбрать конкретные столбцы
SELECT name, email FROM users; Результат:
name | |
|---|---|
Atajan | atajan@mail.com |
Maria | maria@mail.com |
John | john@mail.com |
Anna | anna@mail.com |
Kemal | kemal@mail.com |
На практике SELECT * используют для быстрой проверки. В тест-скриптах лучше указывать конкретные столбцы — быстрее и понятнее.
2.3 WHERE — фильтрация
WHERE — это фильтр. «Дай мне только те строки, где условие истинно.» SELECT * FROM users WHERE city = 'Moscow'; Результат:
id | name | age | city |
|---|---|---|---|
2 | Maria | 25 | Moscow |
4 | Anna | 22 | Moscow |
2.4 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.
2.5 IN — вместо кучи OR
SELECT * FROM users WHERE city IN ('Moscow', 'Istanbul'); Делает то же самое, что city = 'Moscow' OR city = 'Istanbul', но короче и читабельнее. Особенно когда значений 5-10.
2.6 BETWEEN — диапазон
SELECT * FROM users WHERE age BETWEEN 25 AND 35; Результат:
name | age |
|---|---|
Atajan | 30 |
Maria | 25 |
John | 35 |
Kemal | 28 |
BETWEEN включает обе границы: 25 и 35 попадают в результат. Аналог: age >= 25 AND age <= 35.
2.7 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)._= ровно один символ
2.8 IS NULL — проверка на пустоту
SELECT * FROM users WHERE city IS NULL; В нашей таблице у всех есть город, поэтому вернёт 0 строк. Но на реальных данных NULL встречается постоянно — незаполненные поля, удалённые связи.
⚠️ Ловушка на собеседовании: 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; Кто-то обязательно напишет = NULL — и интервьюер это заметит.
3. ORDER BY, LIMIT, DISTINCT
3.1 ORDER BY — сортировка
SELECT * FROM users ORDER BY age DESC; ASC — по возрастанию (по умолчанию), DESC — по убыванию. Результат: John (35), Atajan (30), Kemal (28), Maria (25), Anna (22).
3.2 LIMIT — ограничить количество
SELECT * FROM users ORDER BY age ASC LIMIT 3; Результат: три самых молодых — Anna (22), Maria (25), Kemal (28).
3.3 DISTINCT — уникальные значения
SELECT DISTINCT city FROM users; Результат: Ashgabat, Moscow, Istanbul (3 уникальных города, без дублей). SELECT COUNT(DISTINCT city) FROM users; Результат: 3. Полезно для быстрой проверки: «сколько уникальных значений в столбце?»
4. JOIN — главный вопрос собеседования
JOIN спрашивают в 80% случаев. Не потому что это сложно, а потому что кандидаты путают типы.
4.1 INNER JOIN — только совпадения
SELECT users.name, orders.product, orders.amount FROM users INNER JOIN orders ON users.id = orders.user_id; Результат:
name | product | amount |
|---|---|---|
Test_user | Laptop | 50000 |
Test_user | Mouse | 2000 |
Maria | Keyboard | 3000 |
John | Monitor | 25000 |
Anna и Kemal — не попали (нет заказов). Заказ с user_id = 99 — тоже (нет пользователя).
4.2 LEFT JOIN — все из левой + совпадения из правой
SELECT users.name, orders.product FROM users LEFT JOIN orders ON users.id = orders.user_id; Результат:
name | product |
|---|---|
Test_user | Laptop |
Test_user | Mouse |
Maria | Keyboard |
John | Monitor |
Anna | NULL |
Kemal | NULL |
Anna и Kemal попали с NULL — они есть в users, но заказов нет.
4.3 RIGHT JOIN — все из правой + совпадения из левой
SELECT users.name, orders.product FROM users RIGHT JOIN orders ON users.id = orders.user_id; Результат:
name | product |
|---|---|
Test_user | Laptop |
Test_user | Mouse |
Maria | Keyboard |
John | Monitor |
NULL | Headphones |
Headphones попал с NULL — заказ есть, но user_id = 99 не существует.
4.4 FULL JOIN — всё из обеих таблиц
Комбинация LEFT и RIGHT: все пользователи + все заказы, даже если нет совпадений.
4.5 Шпаргалка по JOIN
Тип | Формула | Простым языком |
|---|---|---|
INNER JOIN | A ∩ B | Только совпадения |
LEFT JOIN | A + (A ∩ B) | Всё из левой таблицы |
RIGHT JOIN | B + (A ∩ B) | Всё из правой таблицы |
FULL JOIN | A ∪ B | Вообще всё |
4.6 Задача с собеседования: «Пользователи без заказов»
Найди пользователей, которые не сделали ни одного заказа.
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 отфильтровывает только их. Эту задачу дают постоянно — выучите наизусть.
5. Агрегатные функции
Функция | Что делает | Пример |
|---|---|---|
| Считает строки |
|
| Сумма |
|
| Среднее |
|
| Максимум |
|
| Минимум |
|
Важный нюанс: COUNT(*) считает все строки. А COUNT(column) — только те, где столбец не NULL. Подробнее — в ловушке #2. Пример: сколько заказов у каждого пользователя? SELECT users.name, COUNT(orders.id) AS order_count FROM users LEFT JOIN orders ON users.id = orders.user_id GROUP BY users.name; Результат:
name | order_count |
|---|---|
Test_user | 2 |
Maria | 1 |
John | 1 |
Anna | 0 |
Kemal | 0 |
6. GROUP BY и HAVING
GROUP BY группирует строки, HAVING фильтрует группы. Главное отличие: WHERE фильтрует строки до группировки, HAVING — после. -- Города с более чем одним пользователем SELECT city, COUNT(*) AS user_count FROM users GROUP BY city HAVING COUNT(*) > 1; Результат:
city | user_count |
|---|---|
Ashgabat | 2 |
Moscow | 2 |
Задача с собеседования: «Кто потратил больше 10 000₽?»
Этот запрос сложнее — тут и JOIN, и WHERE, и GROUP BY, и HAVING. Разберём по шагам: 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; Что происходит пошагово:
FROM users INNER JOIN orders— соединяем таблицы (4 строки, без Anna и Kemal)WHERE status = 'paid'— убираем cancelled-заказ Maria. Осталось 3 строкиGROUP BYusers.name— группируем: Test_user (50000+2000), John (25000)HAVING SUM(amount) > 10000— оба прошли (52000 и 25000 > 10000)SELECT— выводим имя и сумму
Результат:
name | total_spent |
|---|---|
Test_user | 52000 |
John | 25000 |
Обратите внимание: Maria не попала в результат. Но не из-за HAVING — её единственный заказ был cancelled, и WHERE отсёк его ещё до группировки. После GROUP BY у Maria просто нет данных.
7. Подзапросы
Подзапрос — это запрос внутри запроса. Иногда без них не обойтись.
7.1 Скалярный подзапрос
-- Пользователи, чей возраст выше среднего SELECT name, age FROM users WHERE age > (SELECT AVG(age) FROM users); Результат: Test_user (30), John (35). Средний возраст = 28.
7.2 Подзапрос с IN
-- Пользователи с хотя бы одним оплаченным заказом SELECT name FROM users WHERE id IN ( SELECT DISTINCT user_id FROM orders WHERE status = 'paid' ); Результат: Test_user, John.
7.3 Задача с собеседования: «Товар с макс. суммой»
Способ 1: подзапрос SELECT product, amount FROM orders WHERE amount = (SELECT MAX(amount) FROM orders); Внутренний запрос (SELECT MAX(amount) FROM orders) вернёт 50000. Внешний найдёт все заказы с такой суммой. Если два товара стоят 50000 — вернёт оба. Способ 2: ORDER BY + LIMIT SELECT product, amount FROM orders ORDER BY amount DESC LIMIT 1; Сортируем по убыванию, берём первый. Проще, но вернёт только одну строку, даже если максимум не уникален. Результат обоих: Laptop, 50000. На собеседовании лучше показать оба способа и объяснить разницу — это покажет глубину понимания.
8. Задачи с реальных собеседований
Задача 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'; Разбор:
LEFT JOIN— берём всех пользователей, даже без заказовWHEREo.idIS NULL— оставляем только тех, у кого заказов нетu.created_at < NOW() - INTERVAL '1 month'— зарегистрированы больше месяца назад
Кстати: u и o — это алиасы (псевдонимы) таблиц. Вместо users.name пишем u.name. Короче и чище, особенно когда JOIN-ов несколько. Зачем это QA? Тестируем рассылку «Вы давно не заказывали» — убеждаемся, что она уходит именно этим пользователям.
Задача 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; Разбор:
INNER JOIN— соединяем пользователей с заказамиWHERE status = 'paid'— только оплаченныеGROUP BYu.name— группируем по имениSUM(o.amount)— считаем сумму заказов каждогоORDER BY total DESC LIMIT 3— сортируем по убыванию, берём топ-3
Задача 5: «Конверсия по городам» (middle+)
Для каждого города: сколько пользователей, сколько покупателей, процент конверсии.
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; Разбор по частям:
LEFT JOIN— берём всех пользователей (а не только тех, у кого есть заказы)COUNT(DISTINCTu.id)— сколько уникальных пользователей в городеCOUNT(DISTINCT o.user_id)— сколько из них сделали заказ.DISTINCTнужен, чтобы не считать одного покупателя дважды (если у него 2 заказа)ROUND(... * 100.0 / ..., 1)— процент конверсии, округлённый до 1 знака.100.0(а не100) нужен для дробного деления, иначе SQL округлит до целого
Результат:
city | total_users | buyers | conversion_pct |
|---|---|---|---|
Ashgabat | 2 | 1 | 50.0 |
Moscow | 2 | 1 | 50.0 |
Istanbul | 1 | 1 | 100.0 |
Если решите на собеседовании — впечатлите. Тут и LEFT JOIN, и GROUP BY, и два разных COUNT DISTINCT, и ROUND — всё в одном запросе.
9. UPDATE и DELETE — с осторожностью
Главное правило: всегда WHERE. DELETE FROM users без WHERE удалит всех пользователей. На production это катастрофа.
9.1 UPDATE — обновить данные
UPDATE users SET city = 'Istanbul' WHERE id = 1; Обновит город только у пользователя с id = 1. Без WHERE — поменяет город у всех.
9.2 DELETE — удалить строки
DELETE FROM orders WHERE status = 'cancelled' AND created_at < NOW() - INTERVAL '1 year'; Удалит отменённые заказы старше года. Без WHERE — удалит все заказы.
9.3 Золотое правило: SELECT перед DELETE/UPDATE
Перед тем как что-то менять или удалять — сначала посмотрите, что попадёт под условие: -- Шаг 1: смотрим, что будет затронуто SELECT * FROM orders WHERE status = 'cancelled' AND created_at < NOW() - INTERVAL '1 year'; Проверили, убедились, что затронуты именно нужные строки. Теперь можно удалять: -- Шаг 2: удаляем (тот же WHERE) DELETE FROM orders WHERE status = 'cancelled' AND created_at < NOW() - INTERVAL '1 year'; Эту привычку стоит упомянуть на собеседовании — показывает, что вы осторожны с данными.
10. 5 ловушек, на которых валятся кандидаты
Ловушка #1: NULL — это не значение
Представьте анкету. Поле «Город» оставили пустым. Это не значит, что город — пустая строка "". Это значит, что данных нет вообще. Вот что такое NULL. -- ❌ Так не работает (вернёт 0 строк, даже если NULL есть!) SELECT FROM users WHERE city = NULL; -- ✅ Правильно SELECT FROM users WHERE city IS NULL; Почему? Потому что NULL = NULL — это не TRUE. Это тоже NULL. В SQL любая операция с NULL даёт NULL:
Выражение | Результат | Почему |
|---|---|---|
| NULL (не TRUE!) | Неизвестное нельзя сравнить с неизвестным |
| NULL (не TRUE!) | Неизвестное нельзя сравнить ни с чем |
| NULL | Неизвестное + 5 = неизвестное |
| TRUE | IS NULL — специальная проверка |
Ловушка #2: COUNT(*) vs COUNT(column)
Допустим, у 3 из 5 пользователей заполнено поле phone, а у двоих — NULL. SELECT COUNT(*) FROM users; -- → 5 (все строки) SELECT COUNT(phone) FROM users; -- → 3 (только не-NULL) COUNT(*) считает строки. COUNT(phone) считает значения. Разница — те самые NULL. На собеседовании любят спрашивать: «А что вернёт COUNT, если в столбце есть NULL?»
Ловушка #3: GROUP BY — забыли столбец
-- ❌ Ошибка: name не в GROUP BY и не в агрегатной функции SELECT name, city, COUNT(*) FROM users GROUP BY city; SQL не знает, какое name показать — в Москве и Maria, и Anna. Ошибка. -- ✅ Вариант 1: убрать лишний столбец SELECT city, COUNT(*) FROM users GROUP BY city; -- ✅ Вариант 2: добавить в GROUP BY SELECT name, city, COUNT(*) FROM users GROUP BY city, name; Правило простое: всё, что не внутри агрегатной функции (COUNT, SUM...) — должно быть в GROUP BY.
Ловушка #4: WHERE vs HAVING
Это как фильтр на входе в клуб vs фильтр внутри. WHERE работает до группировки (отсеивает строки), HAVING — после (отсеивает группы). -- ❌ Ошибка: агрегатная функция в WHERE SELECT city, COUNT(*) FROM users WHERE COUNT(*) > 1 GROUP BY city; Почему не работает? Потому что в момент WHERE строки ещё не сгруппированы — COUNT не существует. -- ✅ Правильно: агрегат — в HAVING SELECT city, COUNT(*) FROM users GROUP BY city HAVING COUNT(*) > 1; Наглядно: Все строки (5 штук) │ ▼ WHERE ← фильтрует СТРОКИ (age > 18, status = 'paid') │ ▼ GROUP BY ← группирует (по city, по name...) │ ▼ HAVING ← фильтрует ГРУППЫ (COUNT(*) > 1, SUM(amount) > 10000) │ ▼ Результат
Ловушка #5: SQL выполняется не в том порядке, в котором пишется
Вы пишете запрос так: SELECT city, COUNT(*) AS cnt -- 1. пишете первым FROM users -- 2. пишете вторым WHERE age > 18 -- 3. пишете третьим GROUP BY city -- 4. HAVING COUNT(*) > 1 -- 5. ORDER BY cnt DESC; -- 6. пишете последним Но SQL выполняет в другом порядке:
Порядок выполнения | Что делает | Порядок записи |
|---|---|---|
1. FROM | Берёт таблицу | 2-я строка |
2. WHERE | Фильтрует строки | 3-я строка |
3. GROUP BY | Группирует | 4-я строка |
4. HAVING | Фильтрует группы | 5-я строка |
5. SELECT | Выбирает столбцы | 1-я строка (!) |
6. ORDER BY | Сортирует | 6-я строка |
Практическое следствие: в WHERE нельзя использовать алиасы из SELECT — потому что SELECT ещё не выполнился. -- ❌ Ошибка: алиас cnt ещё не существует на этапе WHERE SELECT city, COUNT(*) AS cnt FROM users WHERE cnt > 1 GROUP BY city; -- ✅ Правильно: используем HAVING (выполняется после GROUP BY) SELECT city, COUNT(*) AS cnt FROM users GROUP BY city HAVING COUNT(*) > 1;
11. Чек-лист: что знать перед собеседованием
Junior — минимум, без которого не возьмут
Тема | Проверь себя |
|---|---|
SELECT, WHERE | Напиши запрос с AND, OR, IN, BETWEEN, LIKE — без подсказок |
ORDER BY, LIMIT | Покажи топ-5 самых дорогих заказов |
DISTINCT | Сколько уникальных городов в таблице? |
NULL | Объясни, почему |
INNER JOIN | Соедини users и orders, объясни что получится |
LEFT JOIN | Найди пользователей без заказов |
Middle — то, что отличает от джуна
Тема | Проверь себя |
|---|---|
Агрегатные функции | COUNT, SUM, AVG, MAX, MIN — для чего каждая |
GROUP BY + HAVING | Города с >1 пользователем. Покупатели с суммой >10000₽ |
Подзапросы | Пользователи старше среднего возраста |
WHERE vs HAVING | Объясни разницу и когда что использовать |
COUNT(*) vs COUNT(col) | Что будет если в столбце есть NULL? |
UPDATE, DELETE | Почему сначала SELECT, потом DELETE? |
Порядок выполнения | Почему алиас из SELECT нельзя в WHERE? |
Этого хватит для 90% QA собеседований. Оконные функции, CTE, хранимые процедуры — это уже DBA-территория.
12. Как практиковаться
Ресурс | Что там | Цена |
|---|---|---|
SQLBolt | Интерактивные уроки с нуля. 15 мин/день — за неделю база | Бесплатно |
LeetCode Database | Задачи уровня собеседований. Начните с Easy | Бесплатно |
HackerRank SQL | Задачи с проверкой. Хорошая подборка для начинающих | Бесплатно |
Ваш рабочий проект | Запросы к реальной тестовой БД — лучшая практика | — |
Курс по тестированию с практическими заданиями — бесплатно на annayev.com (English, Русский, Türkçe). Ставьте плюс, если было полезно. Какие SQL-задачи вам давали на собеседованиях? Пишите в комментариях — может, добавлю разбор.
