Assertion Engine Mechanics: First-Principles Comparison of Vitest Matchers
V8 memory identity, prototype traversal, asymmetric matching, and why matcher selection prevents production bugs.
Part 5 in Series — Catch up on the previous article: Vitest Configuration Mechanics: Merging vite.config.ts, test.include, and Environment Drivers (Part 4) before diving into this post.
Every test assertion boils down to a comparison between an actual runtime state and an expected target state. When an assertion passes or fails, Vitest evaluates expressions through its internal assertion wrapper built on Chai’s comparison engine and V8 execution primitives.
Selecting the wrong matcher is a major source of false-positive unit tests in JavaScript and TypeScript. A test can pass cleanly while completely ignoring broken class prototypes, missing payload properties, or post-invocation object mutations.
1. How Vitest’s expect() Works Under the Hood
When you execute expect(actual), Vitest does not run an inline check. It instantiates an assertion wrapper object that stores context in internal state.
// Conceptual internal structure of Vitest's expect() wrapper
function expect<T>(actual: T): Assertion<T> {
const state = getMatcherState(); // Retrieves current test state, timeout, and flags
return new Assertion(actual, state);
}
When you chain a matcher like .toBe(expected), Vitest looks up the registered matcher function in its registry:
// Vitest internal matcher invocation signature
function toBe(this: MatcherState, received: unknown, expected: unknown) {
const pass = Object.is(received, expected);
return {
pass,
message: () => pass
? `expected ${this.utils.printReceived(received)} not to be ${this.utils.printExpected(expected)}`
: `expected ${this.utils.printReceived(received)} to be ${this.utils.printExpected(expected)} (using Object.is)`
};
}
The matcher receives three critical execution flags:
received: The value passed toexpect(actual).expected: The value passed to.toBe(expected).this(MatcherState): Holds execution context such asthis.isNot(triggered by.not),this.utils(diff formatters), and custom assertion error stack formatting.
2. Identity vs Structural Equality: toBe vs toEqual vs toStrictEqual
Understanding the exact boundary between memory identity and structural recursion is essential for writing accurate assertions.
+-----------------------------------------------------------------------------------+
| MATCHER MATCHING MATRIX |
+-------------------+----------------+------------------+---------------------------+
| Feature | toBe | toEqual | toStrictEqual |
+-------------------+----------------+------------------+---------------------------+
| Mechanism | Object.is | Recursive eql | Recursive eql + Strict |
| V8 Heap Pointer | Required | Ignored | Ignored |
| Class Prototypes | Checked (ptr) | IGNORED | Checked (constructor) |
| Undefined Keys | Checked (ptr) | IGNORED | Checked (key presence) |
| Array Sparse Holes| Checked (ptr) | IGNORED | Checked (0 in arr check) |
| Primary Use Case | Primitives/Refs| JSON/DTO Payloads| Domain Models/Classes |
+-------------------+----------------+------------------+---------------------------+
toBe(expected): V8 Heap Memory Identity
toBe evaluates Object.is(received, expected). In the V8 C++ runtime, this compares the raw 64-bit tagged pointer stored in CPU registers for objects, or the primitive bit representation for scalar values.
import { test, expect } from 'vitest';
test('toBe memory identity mechanics', () => {
const primitiveNum = 42;
expect(primitiveNum).toBe(42); // PASS: Scalar value equality
const userA = { id: 1, role: 'admin' };
const userB = { id: 1, role: 'admin' };
// FAIL: userA and userB occupy distinct heap memory addresses
// expect(userA).toBe(userB);
const refToUserA = userA;
expect(refToUserA).toBe(userA); // PASS: Identical V8 heap memory pointer
});
Why Object.is differs from ===:
Object.is(+0, -0)returnsfalse, whereas+0 === -0returnstrue.Object.is(NaN, NaN)returnstrue, whereasNaN === NaNreturnsfalse.
toEqual(expected): Deep Structural Recursion (The Prototype Trap)
toEqual performs recursive key-by-key comparison using Chai’s deep equality engine. It walks object properties, array indices, Map keys, and Set items.
However, toEqual ignores object prototypes, constructor functions, and explicit undefined key presence.
class UserDomainModel {
constructor(public id: number, public name: string) {}
getRole(): string {
return 'admin';
}
}
test('toEqual prototype pitfall', () => {
const actualInstance = new UserDomainModel(101, 'Adetayo');
const plainDTO = { id: 101, name: 'Adetayo' };
// DANGER: PASSES! toEqual ignores that actualInstance has UserDomainModel prototype
// and plainDTO has Object.prototype.
expect(actualInstance).toEqual(plainDTO);
});
The undefined Property Trap in toEqual:
test('toEqual undefined key pitfall', () => {
const payloadWithUndefined = { id: 1, metadata: undefined };
const payloadWithoutKey = { id: 1 };
// DANGER: PASSES! toEqual treats absent keys and keys with value undefined as identical.
expect(payloadWithUndefined).toEqual(payloadWithoutKey);
});
If downstream production code uses Object.keys(payload) or 'metadata' in payload, runtime logic will behave differently for these two objects, yet toEqual lets this bug pass unnoticed.
toStrictEqual(expected): Strict Structural & Prototype Integrity
toStrictEqual extends structural equality by checking three mandatory invariants:
- Constructor & Prototype Matching:
Object.getPrototypeOf(a) === Object.getPrototypeOf(b). - Explicit Key Presence: Checks
Object.prototype.hasOwnPropertyfor every key.{ a: 1, b: undefined }will NOT match{ a: 1 }. - Array Sparse Slots: Differentiates index holes
[1, , 3]from[1, undefined, 3].
test('toStrictEqual catches prototype and shape mismatches', () => {
const actualInstance = new UserDomainModel(101, 'Adetayo');
const plainDTO = { id: 101, name: 'Adetayo' };
// FAILS cleanly: Serialized plain objects do not match Class instances
// expect(actualInstance).toStrictEqual(plainDTO);
const payloadWithUndefined = { id: 1, metadata: undefined };
const payloadWithoutKey = { id: 1 };
// FAILS cleanly: Explicit key 'metadata' presence is validated
// expect(payloadWithUndefined).toStrictEqual(payloadWithoutKey);
});
First Principle Rule:
- Use
toBefor primitive values, enums, or verifying reference identity. - Use
toEqualfor raw JSON response payloads and DTOs where prototypes do not exist. - Use
toStrictEqualfor class instances, domain entities, ORM models, and configuration objects.
3. Collection Traversal: toContain vs toContainEqual
When checking whether an item exists inside an Array, Set, or string, selecting the wrong matcher causes either unexpected test failures or O(N^2) performance hits.
toContain(item)
Calls Array.prototype.includes or Set.prototype.has using SameValueZero equality.
test('toContain uses identity reference lookups', () => {
const roles = ['ADMIN', 'EDITOR', 'VIEWER'];
expect(roles).toContain('ADMIN'); // PASS: Primitive string lookup
const userObjects = [{ id: 1 }, { id: 2 }];
// FAIL: { id: 1 } creates a new object in memory.
// expect(userObjects).toContain({ id: 1 });
});
toContainEqual(item)
Iterates over elements and executes toEqual structural comparison against each item.
test('toContainEqual performs deep structural search', () => {
const userObjects = [{ id: 1, name: 'Tayo' }, { id: 2, name: 'Alex' }];
// PASS: Recursively checks each object in the array until a structural match is found
expect(userObjects).toContainEqual({ id: 1, name: 'Tayo' });
});
4. Partial Matching: toMatchObject vs expect.objectContaining
When validating large objects (such as API payloads with 50 fields), asserting the full object creates brittle tests. Vitest provides two mechanisms for partial matching.
const apiResponse = {
status: 200,
data: {
user: { id: 42, username: 'unkletayo', role: 'ENGINEER' },
session: { token: 'xyz_123', expiresAt: 1780000000 }
},
server: 'eu-west-1'
};
// 1. Direct partial object assertion using toMatchObject
test('toMatchObject for standalone object subset matching', () => {
expect(apiResponse).toMatchObject({
status: 200,
data: {
user: { username: 'unkletayo' }
}
});
});
// 2. Asymmetric matcher embedded inside function call or parent assertion
test('expect.objectContaining for inline asymmetric matching', () => {
const dispatchEvent = (payload: typeof apiResponse) => {};
// Embedded asymmetric matcher
expect(apiResponse.data).toEqual({
user: expect.objectContaining({ id: 42, role: 'ENGINEER' }),
session: expect.any(Object)
});
});
Mechanical Difference:
toMatchObjectis an assertion matcher invoked at the root (expect(actual).toMatchObject(...)).expect.objectContainingis an asymmetric matcher generator that returns a placeholder object. It can be embedded deep insidetoEqual,toHaveBeenCalledWith, or array matching patterns.
5. Function Exception Handling: toThrow vs toThrowError
A common mistake when testing exception boundaries is passing the result of an executed function instead of a function reference.
function parseConfig(rawInput: string) {
if (!rawInput) {
throw new TypeError('Configuration string cannot be empty');
}
return JSON.parse(rawInput);
}
test('toThrow execution boundary mechanics', () => {
// CRITICAL BUG: This executes parseConfig("") BEFORE expect() runs!
// The error is thrown on the main thread and crashes the test process without running assertion checks.
// expect(parseConfig("")).toThrow();
// CORRECT: Wrap execution in an unexecuted closure function reference
expect(() => parseConfig("")).toThrow(TypeError);
expect(() => parseConfig("")).toThrow('cannot be empty');
expect(() => parseConfig("")).toThrow(/empty/);
});
6. Spy Verification Pitfall: Mutation After Invocation
When using vi.spyOn() or vi.fn(), Vitest records arguments passed to the spy function in an internal mock.calls array.
However, JavaScript passes objects by reference. If the code under test mutates an argument after calling the spy, toHaveBeenCalledWith evaluates against the mutated state, not the state at the exact moment of call!
import { test, expect, vi } from 'vitest';
test('spy argument mutation pitfall', () => {
const auditLogger = { logEvent: vi.fn() };
const payload = { status: 'PENDING', timestamp: Date.now() };
// 1. Code under test invokes spy
auditLogger.logEvent(payload);
// 2. Code under test mutates payload in place AFTER the call
payload.status = 'COMPLETED';
// DANGER: This test FAILS!
// auditLogger.logEvent was called when status was 'PENDING', but mock.calls[0][0]
// holds a reference to payload, which now reads 'COMPLETED'.
// expect(auditLogger.logEvent).toHaveBeenCalledWith({
// status: 'PENDING',
// timestamp: expect.any(Number)
// });
// SOLUTION: Assert immediately, or deep clone in spy mock implementations
});
7. First-Principles Matcher Summary Reference
+------------------------------------------------------------------------------------------------+
| MATCHER SELECTION DECISION MATRIX |
+-------------------------+--------------------+-------------------------+-----------------------+
| Intent | Recommended | Avoid | Root Cause |
+-------------------------+--------------------+-------------------------+-----------------------+
| Primitive scalar check | toBe(42) | toEqual(42) | Unnecessary recursion |
| Object reference match | toBe(instance) | toEqual(instance) | Misses reference diff |
| Plain JSON / DTO match | toEqual(dto) | toBe(dto) | Fails on heap address |
| Domain Class / Entity | toStrictEqual(obj) | toEqual(obj) | Ignores prototype |
| Array primitive check | toContain('val') | toContainEqual('val') | O(N^2) overhead |
| Array object search | toContainEqual(obj)| toContain('val') | Heap ref mismatch |
| Partial payload subset | toMatchObject(sub) | toEqual(full) | Brittle tests |
| Embedded partial match | objectContaining() | toMatchObject() nested | Syntax error |
| Exception testing | toThrow(() => fn) | toThrow(fn()) | Uncaught throw crash |
+-------------------------+--------------------+-------------------------+-----------------------+
Part 6: Mocking Mechanics: vi.fn, vi.spyOn, and Module Hoisting Mechanics
Continue to Part 6 →