Матчер - это утверждение, которое связывает реальное значение с ожиданием. Когда тест падает, именно матчер формулирует сообщение об ошибке: он решает, увидит ли инженер точную причину или расплывчатое 'что-то не так'. Поэтому выбор матчера - это не косметика, а часть диагностики. Возьмём сервис оформления заказа: функция payOrder возвращает статус и DTO платежа, и от того, как мы это проверяем, зависит, поймаем ли мы регрессию.
Естественная реакция новичка - взять самый общий матчер и не думать. toBeTruthy проверяет, что значение не пустое; toBeDefined - что оно не undefined. Они читаются как 'просто убедись, что оно есть', и тест сразу зеленеет. Соблазн понятен: цель ощущается как 'получить зелёный', а широкий матчер даёт зелёный быстрее всего и почти на любом значении.
Ломается это ровно там, где нужна защита. expect(status).toBeTruthy() пройдёт и для 'paid', и для 'refunded', и для 'pending' - для любой непустой строки. Тест утверждает, что статус существует, но не то, что он правильный. Если платёж вернёт 'refunded' вместо 'paid', suite останется зелёным, а баг уедет в прод. Широкий матчер проверил присутствие значения, а не обещание системы.
Точность начинается с осознанного выбора. toBe сравнивает через Object.is - это про примитивы и ссылочную идентичность: 'paid' равно 'paid', тот же самый объект. toEqual сравнивает структурно, рекурсивно по полям, и при этом игнорирует поля со значением undefined. toStrictEqual тоже структурный, но строже: он различает undefined-поля, разреженность массивов и тип объекта. Правило простое - берите матчер под то, что вы обещаете: точное значение, форму или форму плюс тип.
Когда DTO большой, а важна лишь его часть, toMatchObject проверяет подмножество полей, не описывая объект целиком. toContainEqual находит в массиве элемент, структурно равный образцу, - удобно для строк корзины. Для чисел с плавающей точкой прямое равенство обманчиво: 0.1 + 0.2 не равно 0.3 из-за двоичного представления, поэтому суммы и доли проверяют через toBeCloseTo с нужной точностью.
Ошибки - тоже контракт. toThrow(DomainError) проверяет класс исключения, а toThrow('Total cannot be negative') - что сообщение содержит нужную подстроку; вместе они фиксируют, что код падает предсказуемо, а не просто 'как-то падает'. Для вызовов зависимостей toHaveBeenCalledWith в паре с expect.objectContaining утверждает, что save получил объект с нужным id, не привязываясь к остальным полям аргумента.
Когда одна и та же проверка со своей диагностикой повторяется во многих тестах, её стоит вынести в доменный матчер через expect.extend. Матчер получает реальное значение и ожидание, возвращает pass и функцию message, которая строит понятное сообщение об ошибке. Выгода не в краткости вызова, а в диагностике: падение сразу говорит 'ожидали статус paid у заказа 42, получили refunded'. Цена - это код, который надо поддерживать, поэтому свой матчер оправдан только когда он улучшает читаемость ошибок в масштабе.
Показательный провал выглядит так: suite из сотен toBeTruthy месяцами горит зелёным и не ловит ни одной подмены статуса, потому что каждый такой тест проверяет лишь 'значение непустое'. Точный матчер переворачивает картину: когда он падает, сообщение само называет нарушенное обещание - какое поле, какое значение ожидалось и что пришло. Точные матчеры дают точные ошибки, и именно это превращает падение теста из загадки в готовый диагноз.
expect(status).toBe('paid')
expect(dto).toEqual({ id: '42', role: 'editor' })
expect(dto).toMatchObject({ role: 'editor' })
expect(0.1 + 0.2).toBeCloseTo(0.3)
expect(items).toContainEqual({ sku: 'A', qty: 2 })
expect(fn).toThrow(DomainError)
expect(fn).toThrow('Total cannot be negative')
expect(save).toHaveBeenCalledWith(expect.objectContaining({ id: '42' }))expect.extend({
toHaveStatus(order, expected) {
const pass = order.status === expected
return {
pass,
message: () =>
`expected order ${order.id} to have status ${expected}, got ${order.status}`,
}
},
})
expect(order).toHaveStatus('paid')