Многопоточность в тестировании: что происходит, когда запросы приходят одновременно
Обычный тест предполагает последовательность: пользователь нажал кнопку, запрос ушёл на сервер, данные обработались, пришёл ответ. Но что произойдёт, если два или десять одинаковых действий выполнятся одновременно?
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-теста.
Хотите узнать больше? Изучите другие статьи из раздела: