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вебхукиконкурентність
🦎

Потренуйся до справжньої співбесіди

Кем ставить справжні питання і чесно оцінює кожну відповідь.

Пройти інтервʼю безкоштовно →