Adetayo Akinsanya unkletayo.dev

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:

  1. received: The value passed to expect(actual).
  2. expected: The value passed to .toBe(expected).
  3. this (MatcherState): Holds execution context such as this.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) returns false, whereas +0 === -0 returns true.
  • Object.is(NaN, NaN) returns true, whereas NaN === NaN returns false.

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:

  1. Constructor & Prototype Matching: Object.getPrototypeOf(a) === Object.getPrototypeOf(b).
  2. Explicit Key Presence: Checks Object.prototype.hasOwnProperty for every key. { a: 1, b: undefined } will NOT match { a: 1 }.
  3. 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 toBe for primitive values, enums, or verifying reference identity.
  • Use toEqual for raw JSON response payloads and DTOs where prototypes do not exist.
  • Use toStrictEqual for 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:

  • toMatchObject is an assertion matcher invoked at the root (expect(actual).toMatchObject(...)).
  • expect.objectContaining is an asymmetric matcher generator that returns a placeholder object. It can be embedded deep inside toEqual, 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  |
+-------------------------+--------------------+-------------------------+-----------------------+
Up Next in Series →

Part 6: Mocking Mechanics: vi.fn, vi.spyOn, and Module Hoisting Mechanics

Continue to Part 6 →