Точный матчер даёт точную ошибку. Когда тест падает, сообщение либо сразу называет расхождение, либо заставляет лезть в отладчик - и разница почти всегда в выборе матчера. Vitest несёт богатый набор: toBe и toEqual для равенства, toThrow для исключений, toHaveBeenCalledWith для взаимодействия, асимметричные матчеры вроде expect.objectContaining для частичных проверок. Выбор под задачу - половина хорошего теста.
Базовое различие - между toBe, toEqual и toStrictEqual. toBe сравнивает через Object.is и годится для примитивов и ссылок: чисел, строк, булевых, одного и того же объекта. toEqual сравнивает по значению рекурсивно и при этом игнорирует поля со значением undefined. toStrictEqual строже: он различает undefined-поля, разреженность массивов и тип объекта, поэтому нужен там, где форма результата - часть контракта.
Исключения проверяют через toThrow. Ему можно передать класс ошибки - toThrow(DomainError) - или подстроку сообщения - toThrow('Total cannot be negative'); лучше проверять и то, и другое, чтобы тест не проходил на постороннем исключении с похожим текстом. Для чисел с плавающей точкой есть toBeCloseTo: 0.1 + 0.2 не равно 0.3 побитово, и toBeCloseTo сравнивает с заданной точностью.
Для частичных проверок удобны toMatchObject - проверяет подмножество полей объекта, не требуя описывать его целиком, и toContainEqual - ищет в массиве элемент, равный по значению. В аргументах мока их роль играют асимметричные матчеры: expect.objectContaining({ id: '42' }) внутри toHaveBeenCalledWith проверяет только важные поля вызова, не привязываясь к остальным.
Широкие матчеры вроде toBeTruthy и toBeDefined - частый источник слабых тестов. 'что-то истинное' проходит и для true, и для случайной непустой строки, и для объекта ошибки. Проверяйте конкретное обещание: не 'значение определено', а 'статус равен paid'. Чем уже проверка, тем меньше зазор, в который проскакивает неверное поведение.
Сообщение об ошибке - часть контракта теста, а не побочный эффект. Широкий матчер печатает 'expected true', и по нему непонятно, что сломалось. Точный печатает 'expected paid, received pending' и сразу показывает и ожидание, и факт. Тест, который при падении объясняет расхождение одной строкой, экономит куда больше времени, чем сэкономил на написании общего матчера.
Когда одна и та же доменная проверка повторяется во многих тестах, её выносят в кастомный матчер через expect.extend. Матчер toBePaidOrder читается лучше цепочки полей и даёт единое понятное сообщение при падении. Это оправдано, когда проверка действительно частая и улучшает диагностику; ради одного-двух тестов заводить собственный матчер не стоит. Держите его рядом с тестами, в общем setup, чтобы он был доступен всему suite.
Типичный провал - взять toEqual там, где нужен toStrictEqual, и пропустить лишнее undefined-поле или неверный тип, которые для контракта значимы. Второй по частоте - 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(save).toHaveBeenCalledWith(expect.objectContaining({ id: '42' }))expect.extend({
toBePaidOrder(received) {
const pass = received?.status === 'paid'
return {
pass,
message: () => `expected order to be paid, got ${received?.status}`,
}
},
})
expect(order).toBePaidOrder()