Skip to content

toStrictEqual incorrectly rejects equal primitives with locked metatables #2

@christopher-buss

Description

@christopher-buss

toStrictEqual fails for any comparison involving string values. typeEquality calls getmetatable() on primitives, and Roblox Luau strings have a locked metatable — getmetatable("hello") returns "The metatable is locked" instead of nil. This causes typeEquality to short-circuit with false before Object.is is consulted, so two identical strings are treated as unequal.

These fail in jest-roblox but pass in Jest:

-- Fails: "serializes to the same string"
expect({ value = "hello" }).toStrictEqual({ value = "hello" })

-- Works
expect({ value = "hello" }).toEqual({ value = "hello" })

Any toStrictEqual comparison that recurses into string (or other locked-metatable primitive) leaf values will hit this.

Cause

typeEquality in expect/src/utils.lua runs as a custom tester on every recursive pair of values, including leaf primitives:

local function typeEquality(a, b)
	if a == nil or b == nil then
		return nil
	end

	if typeof(a) ~= typeof(b) then
		return false
	end

	-- Path A: both no metatable -> pass through
	if not getmetatable(a) and not getmetatable(b) then
		return nil
	end

	-- Path B: both have metatables with matching __index -> pass through
	if
		getmetatable(a)
		and getmetatable(b)
		and getmetatable(a).__index
		and getmetatable(b).__index
		and getmetatable(a).__index == getmetatable(b).__index
	then
		return nil
	end

	-- Path C: FAIL
	return false
end

When a and b are both strings (e.g. "hello"):

  1. Path A is skipped: getmetatable("hello") returns "The metatable is locked" (truthy), so not getmetatable(a) is false.
  2. Path B fails: getmetatable("hello").__index indexes a string with "__index", which resolves through the string library to nil. Condition fails.
  3. Path C is reached: return false. Two identical strings declared unequal.

Custom testers run before Object.is in eq(), so the correct comparison never happens:

for _, value in ipairs(customTesters) do
	local customTesterResult = value(a, b)
	if customTesterResult ~= nil then
		return customTesterResult -- returns false for strings
	end
end

-- never reached
if Object.is(a, b) then
	return true
end

Object.is("hello", "hello") would return true, but it's never called.

Proposed fix

Skip non-table metatables in typeEquality:

local function typeEquality(a, b)
	if a == nil or b == nil then
		return nil
	end
	if typeof(a) ~= typeof(b) then
		return false
	end

	local metaA = getmetatable(a)
	local metaB = getmetatable(b)

	-- Skip non-table metatables (e.g. "The metatable is locked" on strings)
	if typeof(metaA) ~= "table" and typeof(metaB) ~= "table" then
		return nil
	end

	if not metaA and not metaB then
		return nil
	end

	if
		metaA
		and metaB
		and typeof(metaA) == "table"
		and typeof(metaB) == "table"
		and metaA.__index
		and metaB.__index
		and metaA.__index == metaB.__index
	then
		return nil
	end

	return false
end

Or just bail early on non-tables:

if typeof(a) ~= "table" then
	return nil
end

Repro

local JestGlobals = require(Packages.Dev.JestGlobals)
local describe = JestGlobals.describe
local expect = JestGlobals.expect
local it = JestGlobals.it

describe("toStrictEqual string bug", function()
	it("should pass for identical strings", function()
		expect("hello").toStrictEqual("hello")
	end)

	it("should pass for tables with string values", function()
		expect({ value = "hello" }).toStrictEqual({ value = "hello" })
	end)

	it("should pass for arrays of objects with strings", function()
		expect({
			{ name = "alice" },
			{ name = "bob" },
		}).toStrictEqual({
			{ name = "alice" },
			{ name = "bob" },
		})
	end)
end)

Expected

All 3 tests pass.

Actual

All 3 tests fail with "serializes to the same string".

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions