P
prepair.app
Пройти интервью бесплатно →
← Все статьи
19 августа 2026 г.·5 мин чтения

Ваш тест на идемпотентность, скорее всего, не может упасть

Тест «не объявлять один платёж дважды» проходил. В проде пришли два одинаковых сообщения с разницей в 142 миллисекунды. Тест был не слабым — он структурно не мог поймать этот баг и при этом выглядел доказательством обратного.

У меня был тест с названием «молчит, если план уже такой, какой даёт событие». Он проходил две недели подряд.

Потом на телефон пришли два одинаковых сообщения «платёж получен» с разницей в 142 миллисекунды.

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

Что делал код

Paddle шлёт subscription.created и subscription.activated на одну покупку и повторяет доставку, если не получил 200. То есть две доставки на один платёж — не редкий случай, а обычный вторник.

Я это знал. Я написал дедупликацию:

const { data: profile } = await supabase
  .from('profiles')
  .select('plan')
  .eq('id', userId)
  .single()

const alreadyOnPlan = profile?.plan === plan

await supabase.from('profiles').update({ plan }).eq('id', userId)

if (!alreadyOnPlan) await notifyPayment({ userId, plan, amount })

Прочитать текущий план, сравнить, записать, объявить только если изменилось. Читается правильно. И оно правильное — для одного вызывающего.

Приходят две доставки. Обе читают free. У обеих alreadyOnPlan === false. Обе пишут basic. Обе объявляют.

Весь баг живёт в промежутке между чтением и записью, и этот промежуток ровно такой ширины, как один поход в базу.

Почему тест со мной соглашался

Вот что было написано:

it('молчит, если план уже такой, какой даёт событие', async () => {
  db.setRow('profiles', { id: 'user-1', plan: 'basic' })
  await post(activation())
  expect(notifyPayment).not.toHaveBeenCalled()
})

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

Вопрос настоящий. Но не тот, о котором баг.

Гонка требует, чтобы две вещи пересеклись во времени. await post(...) доводит один обработчик до конца прежде, чем выполнится следующая строка, — то есть две доставки не сосуществуют никогда. Я мог добавить второй вызов, третий, сотый: последовательно они проходили бы вечно, пока прод продолжал слать дубли.

Вот что тут стоит забрать. Мой тест не был плохим тестом на конкурентность. Он был тестом чего-то другого, что я положил в папку «конкурентность», и зелёная галочка приносила активный вред: она сообщала, что случай закрыт.

Сначала заставить его упасть

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

Два обработчика должны пересечься. В JavaScript для этого не нужны потоки — нужно, чтобы первый уступил управление на await, пока второй стартует:

it('объявляет один раз, когда две доставки гонятся друг с другом', async () => {
  db.setRow('profiles', { id: 'user-1', plan: 'free' })

  await Promise.all([
    post(activation()),
    post(activation('subscription.created')),
  ])

  expect(notifyPayment).toHaveBeenCalledTimes(1)
})

Promise.all запускает оба, первый засыпает на своём первом await — на чтении. Второй делает своё чтение по строке, которую ещё никто не переписал. Это и есть продовое переплетение, воспроизведённое детерминированно, в юнит-тесте, без плясок с таймингами.

Запустите это на старом коде — падает: два объявления. Ради этого тесты и существуют.

Чего я не ожидал

Не упал. Он проходил и на сломанном коде тоже.

Мок базы игнорировал то самое условие, на которое я собирался опереться. Он фиксировал, что запись произошла, и возвращал успех; попала ли строка под условие — эту часть он не моделировал. Условная запись и безусловная давали одинаковый результат, и ни один тест на свете не отличил бы их.

Скажу точнее, насколько это плохо. Отсутствующий тест оставляет известную дыру. Тестовый двойник, который тихо упрощает проверяемое, выдаёт уверенные неверные ответы — и выдаёт их ровно там, где ты считал, что закрыто. Это тот же класс ошибки, что и исходный баг, только этажом выше.

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

if (pendingWrite === 'update' && notEquals.length) {
  const current = state.rows[table]
  const blocked = notEquals.some(([col, val]) => current?.[col] === val)
  if (blocked) return { data: [], error: null }
  if (current && writeValues) state.rows[table] = { ...current, ...writeValues }
}

Теперь второй вызывающий в Promise.all видит то, что записал первый. Теперь тест падает на старом коде и проходит на новом — единственное свойство, ради которого тест стоит держать.

Правка

Как только проверка и действие обязаны стать одним шагом, форма ответа определяется однозначно. Условие уезжает из процесса внутрь записи:

async function setPlan(userId: string, plan: Plan): Promise<{ changed: boolean }> {
  const { data, error } = await supabaseAdmin
    .from('profiles')
    .update({ plan })
    .eq('id', userId)
    .neq('plan', plan)          // ← вся правка
    .select('id')

  if (error) throw new Error(`Failed to update plan: ${error.message}`)
  return { changed: Array.isArray(data) ? data.length > 0 : Boolean(data) }
}

Кто был первым, решает Postgres. Обновление, реально попавшее в строку, её и возвращает; проигравший получает пустой список. Объявляет только победитель.

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

Три вопроса, которые я теперь задаю тестам

Может ли этот тест упасть? Не «проходит ли он», а: могу ли я написать такую версию продуктового кода — достаточно правдоподобную, чтобы я мог написать её сам, — которую этот тест не поймает? Если нет, тест декоративный. Удалить правку и посмотреть, покраснеет ли, занимает десять секунд и остаётся единственным способом узнать.

Совпадает ли форма теста с формой бага? Гонкам нужно пересечение. Повторам — повторение. Ошибкам порядка — неправильный порядок. Последовательный тест не может выразить гонку так же, как юнит-тест не может выразить проблему деплоя. Не «вряд ли поймает», а не может выразить.

Моделирует ли мой двойник то свойство, на которое я опираюсь? Я опирался на условную запись. У мока не было условий. Всё, что построено сверху, измеряло мок, а не код. Если правка держится на гарантии базы, двойник обязан эту гарантию реализовать — иначе тест это театр.

Почему такое повторяется

Я пишу этот продукт вместе с AI, и баг хорошо показывает, что от этого меняется, а что нет.

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

А тест был мой. Я написал его, чтобы почувствовать себя защищённым, и это сработало — я чувствовал себя защищённым две недели, пока телефон не звякнул дважды.

Вывод не в том, что AI-код надо внимательнее ревьюить. Он уже и полезнее: проходящий тест — это утверждение, а утверждения о конкурентности, сделанные последовательным кодом, не стоят ничего. Это было верно до всего этого и останется верным после.

тестированиеpostgresвебхукиконкурентность
🦎

Потренируйся до реального собеседования

Кем задаёт настоящие вопросы и честно оценивает каждый ответ.

Пройти интервью бесплатно →