Зачем тесты: контракты, инварианты и тест как документация
Сцена · договор с поставщиком
Представь, что ты подписываешь договор с поставщиком. В договоре три строчки: что ты обязан подать на вход, что он обязан вернуть, и что считается нарушением. Если ты привёз не то, виноват ты. Если он отгрузил брак при правильной заявке, виноват он. Третий случай, когда непонятно, кто виноват, бывает только если договор написан небрежно.
Функция в коде это такой же договор. У неё есть предусловие (что вызывающий обязан подать), постусловие (что функция обязана вернуть) и зона нарушения (что считается отказом). Тест это и есть подписанный экземпляр договора: он фиксирует обещание так, что обещание нельзя втихую переписать.
Большинство людей думает про тесты как про “проверить, что работает”. Это самая мелкая из причин. В этом уроке мы разберём две причины посильнее: тест как страховка при изменениях и тест как документация, которая не врёт.
Карта урока · что заберёшь
- научишься записывать контракт функции тремя частями: предусловие, постусловие, нарушение;
- поймёшь, что такое инвариант и почему “тест зелёный” слабее, чем “инвариант сохранён”;
- увидишь, как один и тот же тест работает страховкой при рефакторинге и документацией для читателя;
- заведёшь первый доменный модуль сквозного проекта Kassa и опишешь его контракт в тесте.
Раздел 1 · контракт функции
Идея словами
Контракт отвечает на три вопроса. Что я обязан подать, чтобы иметь право звать функцию. Что функция обязана вернуть, если я подал правильное. Что произойдёт, если я нарушу уговор.
Возьмём сквозной пример курса, кассу. В ней есть платёж: списать сумму со счёта. Контракт платежа:
- предусловие: сумма платежа строго положительна и не превышает остаток на счёте;
- постусловие: остаток уменьшен ровно на сумму платежа, в истории операций появилась одна новая запись;
- нарушение: если сумма неположительна или превышает остаток, платёж отвергается ошибкой, счёт не меняется.
Заметь: контракт описан словами про поведение, а не про то, как функция устроена внутри. Это важно, к этому вернёмся в уроке про то, что именно тестировать.
Картинка · поиграй с контрактом
Сверху можно выбрать одну из реализаций applyPayment: правильную или с подсаженной ошибкой. Снизу видно, какие тесты домена краснеют. Подсунь “Не списал” или “Забыл историю” и посмотри, какая именно часть контракта рушится. Это и есть наглядная связь “тест, который покраснел” и “нарушенная строка договора”.
Шаг 1 · красный тест на постусловие
Начинаем с падающего теста. Это первый закон жанра: сначала тест, который не проходит, потом код, который его удовлетворяет. Тест читается как одна строка контракта.
import { describe, expect, it } from 'vitest';
import { applyPayment } from '~/index.ts';
import { makeAccount, makePayment } from './support/factories.ts';
describe('applyPayment', () => {
it('списывает сумму и сохраняет инвариант баланса', () => {
// arrange
const account = makeAccount({ balance: 100 });
// act
const next = applyPayment(account, makePayment(30));
// assert
expect(next.balance).toBe(70);
expect(next.history).toHaveLength(1);
});
});
Пока applyPayment не написан, тест красный. Имя теста это и есть постусловие словами: “списывает сумму и сохраняет инвариант баланса”. Не “вызывает subtractBalance”, а “что снаружи видно после вызова”.
Шаг 2 · зелёный, минимальная реализация
Пишем ровно столько кода, чтобы тест позеленел. Функция чистая: не лезет в сеть, не читает часы, на один вход даёт один выход, исходный счёт не трогает.
export const applyPayment = (account: Account, payment: Payment): Account => {
const nextBalance = (account.balance - payment.amount) as Money;
return {
...account,
balance: nextBalance,
history: [...account.history, { amount: payment.amount, balanceAfter: nextBalance }],
};
};
Тест зелёный. Но контракт ещё не закрыт: мы не описали нарушение.
Шаг 3 · красный тест на нарушение, потом охрана
Добавляем тест на третью строку договора: платёж сверх остатка обязан быть отвергнут.
it('отвергает платёж, превышающий остаток', () => {
const account = makeAccount({ balance: 100 });
const act = () => applyPayment(account, makePayment(150));
expect(act).toThrow(InvalidPayment);
});
Сейчас он красный: наша реализация молча уводит баланс в минус. Чиним добавлением охраны на входе.
export const applyPayment = (account: Account, payment: Payment): Account => {
const violation = validatePayment(account, payment)[0];
if (violation !== undefined) {
throw new InvalidPayment(violation);
}
const nextBalance = (account.balance - payment.amount) as Money;
return {
...account,
balance: nextBalance,
history: [...account.history, { amount: payment.amount, balanceAfter: nextBalance }],
};
};
Три части контракта, три зелёных теста. Обрати внимание на ошибку: мы проверяем toThrow(InvalidPayment), тип ошибки, а не текст сообщения. Текст можно переписать без правки теста, а вот смысл нарушения зафиксирован. К ошибкам как значениям вернёмся в уроке про раннер и дубли зависимостей.
Когда код пишет агент
Попроси агента “напиши applyPayment”, и он почти всегда закроет постусловие и забудет нарушение. Happy-path соблазнителен: код выглядит готовым, тест зелёный. Контракт, записанный заранее тремя частями, это твой чек-лист на приёмке: пока тест на нарушение красный, работа не закончена, кто бы её ни писал.
Что взять с собой
- контракт это три вопроса: что подать, что вернётся, что считается отказом;
- тест на нарушение так же обязателен, как тест на happy-path;
- имя теста описывает поведение, а не внутренний вызов.
Раздел 2 · инвариант
Идея словами
Инвариант это правило, которое держится всегда, после любой операции. У кассы инвариант простой: остаток равен начальному остатку минус сумма всех проведённых платежей, и остаток никогда не уходит в минус.
Разница между постусловием и инвариантом тонкая, но важная. Постусловие проверяет один вызов: “после этого платежа баланс стал 70”. Инвариант проверяет свойство, которое обязано пережить любую цепочку вызовов: “после скольких угодно платежей остаток всё ещё сходится с историей”. Зелёный тест на один вызов слабее, чем сохранённый инвариант, потому что баг часто прячется не в одной операции, а в накоплении.
Шаг 1 · тест на инвариант через цепочку
it('держит инвариант: остаток равен начальному минус сумма платежей', () => {
const account = makeAccount({ balance: 100 });
const afterFirst = applyPayment(account, makePayment(30));
const afterSecond = applyPayment(afterFirst, makePayment(20));
const paid = afterSecond.history.reduce((sum, op) => sum + op.amount, 0);
expect(afterSecond.balance).toBe(afterSecond.openingBalance - paid);
});
Тест не проверяет конкретное число 50. Он проверяет связь: остаток обязан сходиться с историей. Такой тест переживёт смену сумм и количества операций, потому что говорит про свойство, а не про частный случай. Гонять такие инварианты на тысячах случайных входов (property-based тесты) научимся в уроке про виды тестов, но идея уже здесь.
Когда код пишет агент
Агент охотно пишет тесты на конкретные числа: подал 30, ждёт 70. Это полезно, но это фотография одного кадра. Инвариант, записанный явно, ловит классы багов, которые агент не предусмотрел: переполнение, накопление ошибки округления, потерянную операцию. Сформулировать инвариант это работа человека, который понимает домен.
Что взять с собой
- инвариант держится после любой операции, постусловие после одной;
- тест на инвариант формулируется как связь величин, а не как конкретное число;
- сохранённый инвариант сильнее зелёного теста на частный случай.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.