Короткий ответ
Нужен тест, который явно эмулирует повторную доставку одного и того же сообщения (с тем же ключом идемпотентности) и проверяет, что повторная обработка не приводит к повторному побочному эффекту — например, списание средств или отправка уведомления происходит ровно один раз, в зависимости от того, какую гарантию реализует конкретный обработчик.
Как это работает подробнее
Идемпотентность обычно реализуется через хранение уже обработанных ключей сообщений (идентификатор сообщения, бизнес-ключ операции) в БД или кеше с уникальным ограничением, и обработчик перед выполнением проверяет, не обработано ли уже такое сообщение. Тест должен вызвать обработчик дважды с одинаковым сообщением и проверить состояние системы после каждого вызова.
Сравнение
- Компромисс. Полный тест с реальной БД и конкурентными вызовами даёт высокую уверенность, но дороже в поддержке и медленнее; unit-тест с моком хранилища дешевле, но не ловит гонки на уровне БД (отсутствие или неправильное использование уникального ограничения). Критерий выбора: для критичных операций (платежи, списания) оправдан хотя бы один интеграционный тест с конкурентным вызовом на реальной тестовой БД, а для менее критичных операций может быть достаточно unit-теста, проверяющего логику проверки дубликата.
Пример кода
testing_example_324.py
import threading
def test_payment_handler_is_idempotent_under_concurrent_retry(db_session, payment_handler):
message = {"idempotency_key": "order-42", "amount": 100}
results = []
def process():
results.append(payment_handler.handle(message, session=db_session))
threads = [threading.Thread(target=process) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
charges = db_session.query(Charge).filter_by(idempotency_key="order-42").count()
assert charges == 1 # ровно одно списание, несмотря на два почти одновременных вызоваЧто использовать на практике
- Практика. Важно тестировать не только «повтор подряд», но и конкурентный повтор (два почти одновременных вызова с одним и тем же ключом) — это требует теста с реальной или близкой к реальной БД с уникальным ограничением или блокировкой, так как чисто моковый unit-тест не проверит гонку на уровне СУБД.
Подводные камни
- Риск. Проверять идемпотентность только последовательным повторным вызовом в одном потоке, упуская конкурентный сценарий, который на практике и приводит к дублированию при повторной доставке почти одновременно двумя воркерами. Считать операцию идемпотентной только потому, что код выглядит так, будто проверяет дубликат, без теста, подтверждающего это на реальном хранилище с конкурентным доступом.