Ваш тест на ідемпотентність, найімовірніше, не може впасти
Тест «не оголошувати один платіж двічі» проходив. У проді прийшли два однакові повідомлення з різницею в 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-код треба уважніше рев'ювити. Він вужчий і корисніший: тест, що проходить, — це твердження, а твердження про конкурентність, зроблені послідовним кодом, не варті нічого. Це було правдою до всього цього й лишиться правдою після.