A precise matcher gives a precise error. When a test fails, the message either names the divergence at once or forces you into the debugger - and the difference is almost always the choice of matcher. Vitest ships a rich set: toBe and toEqual for equality, toThrow for exceptions, toHaveBeenCalledWith for interaction, asymmetric matchers like expect.objectContaining for partial checks. Choosing for the task is half of a good test.
The basic distinction is between toBe, toEqual and toStrictEqual. toBe compares via Object.is and suits primitives and references: numbers, strings, booleans, the same object. toEqual compares by value recursively and ignores fields whose value is undefined. toStrictEqual is stricter: it distinguishes undefined fields, array sparseness and object type, so it is needed where the shape of the result is part of the contract.
Exceptions are checked with toThrow. You can pass it an error class - toThrow(DomainError) - or a substring of the message - toThrow('Total cannot be negative'); it is better to check both, so the test does not pass on an unrelated exception with similar text. For floating-point numbers there is toBeCloseTo: 0.1 + 0.2 does not equal 0.3 bit for bit, and toBeCloseTo compares to a given precision.
For partial checks toMatchObject is handy - it checks a subset of an object's fields without describing it in full, and toContainEqual finds an array element equal by value. In mock arguments their role is played by asymmetric matchers: expect.objectContaining({ id: '42' }) inside toHaveBeenCalledWith checks only the important fields of a call without binding to the rest.
Broad matchers like toBeTruthy and toBeDefined are a common source of weak tests. 'something truthy' passes for true, for a random non-empty string, and for an error object alike. Check the specific promise: not 'the value is defined' but 'the status equals paid'. The narrower the check, the smaller the gap through which wrong behavior slips.
The error message is part of the test's contract, not a side effect. A broad matcher prints 'expected true', and from it you cannot tell what broke. A precise one prints 'expected paid, received pending' and shows both the expectation and the fact at once. A test that explains the divergence in one line on failure saves far more time than it saved by using a generic matcher.
When the same domain check repeats across many tests, it is extracted into a custom matcher via expect.extend. The matcher toBePaidOrder reads better than a chain of fields and gives a single clear message on failure. It is justified when the check is genuinely frequent and improves diagnostics; for one or two tests, defining your own matcher is not worth it. Keep it next to the tests, in a shared setup, so it is available to the whole suite.
The typical failure is taking toEqual where toStrictEqual is needed and missing an extra undefined field or a wrong type that matter for the contract. The second most common is toBeTruthy instead of a specific value: the test is green but checks almost nothing. Start with the most precise matcher that expresses the promise, and loosen it only deliberately.
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()