Набор тестов бывает хрупким или живучим, и разница не в количестве. Хрупкий набор краснеет от рефакторинга, который не менял поведение: переименовали метод, переставили поля - половина тестов упала, хотя пользователь ничего не заметил бы. Живучий набор ведёт себя ровно наоборот: он молчит, пока поведение прежнее, и падает только тогда, когда поведение действительно сломали. Такой набор строится не удачей, а несколькими паттернами, которые стоит знать по именам.
Начинается живучесть с одного теста. Три вещи держат его в форме. Первое - никакого общего изменяемого состояния между тестами: у каждого свой свежий мир, иначе порядок запуска начинает влиять на результат и приходит плавающая ложь. Второе - структура arrange-act-assert: сначала данные, потом действие, потом проверка. Третье - никакой логики в самом тесте: ни if, ни цикла, ни расчёта ожидаемого значения тем же способом, что и в коде, - тест должен утверждать конкретный ответ, а не повторять реализацию. И имена: 'возвращает нулевую скидку для пустой корзины' говорит о поведении, 'test1' - ни о чём.
Когда сценариев много, помогает Test Data Builder - фабрика, которая собирает валидный объект с разумными умолчаниями. Тесту не нужен заказ на двадцать полей, ему важны два - остальные пусть проставит фабрика, а на виду останется только то, что важно сценарию. Однотипные случаи с разными входами и выходами удобно свести в таблицу через test.each: одна проверка, строки данных - и границы порога скидки перечислены явно, без копий одинаковых тестов.
Чтобы домен вообще можно было проверять быстро, его отвязывают от внешнего мира - это Ports & Adapters. Домен зависит не от конкретной базы или системных часов, а от интерфейсов: Clock отдаёт время, Repository хранит, Gateway ходит наружу. Это порты. В тесте на их место подставляют fake - простую реализацию в памяти - напрямую, через аргумент или конструктор, без module magic вроде jest.mock. Меньше магии - меньше того, что ломается при переезде на ESM.
Часть кода тестировать тяжело по своей природе - контроллер, обработчик запроса, React-glue между событием и состоянием. Паттерн Humble Object говорит: держите этот слой тонким и глупым. Пусть framework glue только принимает вход и зовёт обычную функцию, а все решения - расчёт скидки, валидация, ветвления - живут в простом модуле, который проверяется как чистый юнит, без поднятия фреймворка. Тяжёлую границу оставляют почти пустой.
Как только у порта появляется больше одной реализации - fake в памяти и настоящий адаптер к Postgres, - возникает риск, что они разойдутся: fake ведёт себя не так, как база, и зелёные тесты врут. Contract Tests закрывают это: один и тот же набор проверок запускается против обеих реализаций. Общий контракт описывают один раз функцией, а потом скармливают ей обе фабрики. Пока обе проходят одинаковые тесты, fake остаётся честной заменой боевому адаптеру.
Цена у этих паттернов одна - дисциплина проектирования: интерфейсы, фабрики и тонкий glue надо завести заранее, что на пустом проекте кажется лишним. Оправданы они там, где код живёт долго и меняется часто - в домене с правилами, а не в разовом скрипте. А как выглядит провал без них, знает каждый: заказ мутируют прямо в общем объекте, соседний тест ловит чужие поля, кто-то дублирует в тесте формулу скидки из кода - и оба зеленеют на одной и той же ошибке. Живучий набор ловит регрессию; хрупкий ловит переименования.
export function repositoryContract(
name: string,
createRepo: () => UserRepository,
) {
describe(name, () => {
test('сохраняет и читает пользователя', async () => {
const repo = createRepo()
await repo.save(user)
await expect(repo.find(user.id)).resolves.toEqual(user)
})
})
}
repositoryContract('memory', () => new InMemoryUserRepository())
repositoryContract('postgres', () => new PostgresUserRepository(pool))