Статьи

Многопоточность в тестировании: что происходит, когда запросы приходят одновременно

Обычный тест предполагает последовательность: пользователь нажал кнопку, запрос ушёл на сервер, данные обработались, пришёл ответ. Но что произойдёт, если два или десять одинаковых действий выполнятся одновременно?

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

Поэтому при тестировании многопоточных сценариев нужно проверять не только «правильно ли работает операция», но и «что произойдёт, если несколько пользователей выполнят её одновременно?»

Race condition: когда два покупателя забирают один товар

Представим склад интернет-магазина. В наличии остался один смартфон. Два пользователя практически одновременно нажимают «Купить». Система должна работать так: один получает подтверждение заказа, второй — сообщение, что товара больше нет.

Но если проверка и изменение остатка выполняются неатомарно, может произойти следующее:
Это race condition — состояние гонки. Результат зависит от того, в какой момент разные потоки успели выполнить свои операции.
Обычный функциональный тест такую ошибку не найдёт: один пользователь покупает товар, всё работает. Поэтому тестировщику нужно создать конкуренцию.

Например, можно отправить 20 одинаковых запросов на покупку практически одновременно:
Если успешными окажутся два или больше запросов, либо остаток станет отрицательным, система неправильно обрабатывает конкурентные операции.
И здесь важно запускать такой тест многократно. Race condition может проявиться не при каждом запуске, поэтому один зелёный результат ещё ничего не доказывает.

Deadlock: когда склад сам себя заблокировал

Представим другой сценарий. На складе есть две операции: упаковка заказа и перемещение товара. Обе работают с двумя ресурсами, товаром и ячейкой склада.

Поток A начал упаковку и заблокировал товар, который сейчас собирается отправить. Затем ему понадобилась ячейка склада, чтобы обновить её состояние. В это же время поток B начал перемещение и заблокировал ячейку. Затем ему понадобился тот же товар.

Получается:
A не может продолжить, пока B не освободит ячейку. B не может продолжить, пока A не освободит товар.
Обе операции зависают.
Это deadlock — взаимная блокировка. Для пользователя результат может выглядеть просто как бесконечная загрузка или зависший заказ.
В тестах такие ситуации важно ловить с помощью таймаутов. Если операция должна завершиться за несколько секунд, тест не должен ждать её бесконечно.

Shared state: когда два сотрудника одновременно меняют остаток

Ещё одна проблема возникает, когда несколько потоков работают с одним общим состоянием.
Вернёмся на склад. Пусть в наличии 10 единиц товара, а два сотрудника одновременно обрабатывают заказы и каждый должен уменьшить остаток на 1.

Упрощённо операция может выглядеть так:
Если оба потока прочитают 10 до того, как кто-то запишет результат:
Два товара фактически проданы, а система показывает, что осталось 9.
Это пример lost update, потерянного изменения.
Такой сценарий можно превратить в тест: создать товар с определённым количеством, отправить несколько параллельных запросов на его покупку и после завершения проверить итоговый остаток.

Как проверить это инструментами

На практике тестировщику не нужно вручную открывать 20 браузеров и нажимать кнопку одновременно. Для API конкурентные сценарии можно автоматизировать.

Например, простой Python-тест может запустить несколько потоков, которые одновременно отправляют запрос на покупку одного товара:
Идея здесь важнее конкретного фреймворка: создаём несколько параллельных вызовов, ждём их завершения и проверяем общее состояние системы.

Для API такую проверку можно реализовать в Python, Java или инструментах нагрузочного тестирования. Например, в JMeter или k6 можно создать большое количество виртуальных пользователей и отправить запросы одновременно.
При этом важно проверять не только HTTP-коды. После конкурентных операций стоит убедиться, что база данных и бизнес-состояние остались корректными: не появились дубликаты, товар не ушёл в отрицательный остаток, заказ не создался дважды.

Идемпотентность: что будет, если запрос придёт дважды?

Конкурентные запросы тесно связаны с ещё одним важным понятием — идемпотентностью.

Представим кнопку «Оплатить». Пользователь нажал её два раза или браузер повторил запрос из-за проблем с сетью. Если каждый запрос создаёт новое списание, с банковской карты могут уйти деньги дважды.
Для таких операций часто используют Idempotency Key — уникальный ключ операции.

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

Это отличный объект для тестирования:
А вот запрос с новым ключом:
уже должен рассматриваться как новая операция.

При тестировании конкурентных запросов полезно проверить оба сценария: что происходит при нескольких одинаковых запросах с одним Idempotency Key и что происходит при запросах с разными ключами.

И многопоточность бывает на фронте

Многопоточность часто ассоциируется с backend, API и базами данных, но начинающему тестировщику совсем не обязательно уходить так далеко.

Один из самых знакомых UI-сценариев — двойной клик по кнопке.
Пользователь нажимает «Оплатить». Запрос отправляется, но интерфейс ещё не успел показать загрузку или заблокировать кнопку. Пользователь нажимает ещё раз.

В результате:
Если backend не защищён от повторной операции, можно получить два платежа.
Поэтому такой сценарий стоит проверять и на уровне UI: быстро нажать кнопку несколько раз и убедиться, что операция создаётся один раз, кнопка блокируется или повторный запрос корректно обрабатывается сервером.

То же самое относится к кнопкам «Отправить», «Создать заказ», «Забронировать», «Получить бонус» и другим действиям, которые нельзя выполнять повторно.

Важно понимать: проблема здесь не обязательно в настоящих потоках внутри браузера. Для тестировщика важен сам принцип конкурирующих или повторных операций и то, насколько система защищена от их последствий.

Как составить хороший тест на concurrency

Начать можно с простого вопроса: «Какие операции в системе могут одновременно изменить один и тот же ресурс?»

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

Например, если на складе было 10 товаров и успешно оформлено 7 заказов, в конце должно остаться ровно 3. Если два запроса получили успешный ответ на последний товар, а в базе появилось два заказа — тест обнаружил проблему.

Почему такие тесты бывают flaky

Concurrency-баги часто становятся причиной flaky-тестов:
Это не повод сразу добавлять sleep(2) и надеяться на лучшее. Фиксированная задержка может лишь изменить вероятность возникновения проблемы.
Гораздо полезнее контролировать момент запуска операций, повторять сценарий и сохранять логи с временными метками и идентификаторами запросов. Тогда при редком падении можно восстановить, какие операции выполнялись параллельно.

В итоге тестирование multithreading сводится не к тому, чтобы заставить тест «потыкать всё одновременно». Главная задача — найти места, где несколько пользователей или процессов могут одновременно работать с одним ресурсом, и проверить, что система сохраняет корректное состояние.

Последний товар, один промокод, один платёж, один заказ — всё, за что могут одновременно бороться несколько операций, потенциально заслуживает concurrency-теста.


Хотите узнать больше? Изучите другие статьи из раздела:
Основы и старт в IT