Summary
A developer asked how to properly test a utility function (isSafari) that relies on the UAParser class from ua-parser-js. The proposed solution mocks the third-party class by overriding its prototype methods directly in Vitest. While this technically passes tests, it violates fundamental testing principles and TypeScript linting rules. The core issue is that manipulating third-party prototypes in tests creates fragile, non-isolated test environments that can leak state between tests and cause false positives.
Root Cause
The root cause is using prototype pollution to inject behavior into a class method (UAParser.prototype.getBrowser). This approach couples the test implementation to the internal structure of the third-party library.
Specific problems:
- Global State Pollution: Modifying
UAParser.prototype.getBrowserchanges the behavior for all instances ofUAParsercreated anywhere in the test suite, not just the instance used byisSafari. - Test Pollution: If other tests instantiate
UAParserdirectly (or indirectly), they will inherit this mocked behavior, leading to unpredictable failures. - TypeScript ESLint Violation: The linter flags
UAParser.prototype.getBrowseras an unbound method. When you referenceUAParser.prototype.getBrowser, you detach it from its class context, losing the implicitthisbinding. This can lead to runtime errors if the method actually relies onthis(though in this specific case, it’s mocked, but the pattern is risky). - Lack of Isolation:
vi.clearAllMocks()clears function call history but does not reset the prototype override applied viavi.mocked(UAParser.prototype.getBrowser).mockReturnValue(...). This means the mock persists across tests unless manually reset.
Why This Happens in Real Systems
Developers often reach for prototype manipulation for two reasons:
- Legacy Library Constraints: Many older libraries (like
ua-parser-js) export classes as the primary interface. If the library doesn’t provide a clean way to inject dependencies or expose pure functions, developers hack the prototype to avoid rewriting the library or refactoring their app code. - Misunderstanding of Jest/Vitest Mocking: Developers often confuse
vi.mock()(module mocking) withObject.definePropertyor prototype modification. They assume that mocking a class automatically mocks all instance methods without realizing thatvi.mock()often only intercepts the constructor, leaving existing methods unmocked unless explicitly targeted.
Key Takeaway: Never modify the prototype of a third-party library in a test. It breaks the encapsulation of the library and introduces global side effects.
Real-World Impact
If this pattern is adopted in a codebase, the consequences include:
- Flaky Tests: Tests may pass in isolation but fail when run as a suite due to shared prototype state.
- Debugging Nightmares: A failing test in one module might be caused by a prototype mutation in a completely unrelated test file.
- Upgrade Blockers: When the library (
ua-parser-js) updates and changes its internal class structure (e.g., moving a method from the prototype to a static method or using a private field), tests relying on prototype access will break silently or crash, requiring widespread refactoring. - ESLint/CI Failures: Strict linting rules will block CI pipelines, forcing developers to disable linters or ignore warnings, which reduces code quality safety.
Example or Code
The original approach causes the ESLint error because UAParser.prototype.getBrowser is an unbound method.
The problematic pattern:
// Causes ESLint error: @typescript-eslint/unbound-method
vi.mocked(UAParser.prototype.getBrowser).mockReturnValue({...});
Corrected approach (Factory or Instance Proxy):
Instead of modifying the prototype, you should mock the module so that the constructor returns an object where the method is already mocked.
import { UAParser } from "ua-parser-js";
import { isSafari } from "utils/helpers";
import { vi, describe, it, expect, beforeEach } from "vitest";
// Mock the entire module
vi.mock("ua-parser-js", () => {
return {
// Return a factory function or a class that we control
UAParser: vi.fn().mockImplementation(() => {
return {
// Define the method directly on the instance returned by the constructor
getBrowser: vi.fn(),
};
}),
};
});
describe("User Agent Testing - Corrected", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should handle Chrome correctly", () => {
// Access the mock instance created by the constructor
const mockInstance = vi.mocked(new UAParser());
// Assert the method exists and mock it
if (mockInstance.getBrowser) {
mockInstance.getBrowser.mockReturnValue({
name: "Chrome",
version: "120.0.0",
major: "120",
});
}
expect(isSafari()).toBe(false);
});
it("should handle Safari differently", () => {
// Re-instantiate to get a fresh mock instance (avoids prototype pollution)
const mockInstance = vi.mocked(new UAParser());
if (mockInstance.getBrowser) {
mockInstance.getBrowser.mockReturnValue({
name: "Safari",
version: "17.0.0",
major: "17",
});
}
expect(isSafari()).toBe(true);
});
});
How Senior Engineers Fix It
Senior engineers prioritize test isolation and dependency injection. Here is the standard approach:
- Mock the Module, Not the Prototype: Use
vi.mock()to replace the entire module with a factory function. This ensures that every time the code callsnew UAParser(), it receives a fresh object with mocked methods, rather than touching the globalprototype. - Use
mockImplementation: Instead of returning a static mock, usemockImplementationto allow dynamic behavior (e.g., different user agents based on test inputs). - Refactor for Testability (If Possible): If you own the wrapper function (
isSafari), refactor it to accept a dependency injection (pass the parser as an argument) or extract the logic into a pure function that takes a browser name string. This removes the need to mock the library entirely. - Avoid
vi.mockedon Prototypes: Thevi.mockedhelper is designed for type casting module imports, not for attaching spies to object properties.
Best Practice Refactoring:
// helper.ts
export const isSafari = (userAgent: string = navigator.userAgent) => {
const parser = new UAParser(userAgent);
return parser.getBrowser().name === "Safari";
};
// helper.test.ts
it("detects Safari", () => {
// No mocking needed for the library
expect(isSafari("Mozilla/5.0... Safari/17.0")).toBe(true);
});
Why Juniors Miss It
Junior developers often struggle with this because:
- Lack of Understanding of
thisand Prototypes: JavaScript’s prototype chain is a complex concept. Juniors may not realize thatUAParser.prototype.getBrowseris shared across all instances. - “It Works” Mentality: The prototype manipulation does make the specific test pass locally. Without running the full suite or checking linting, the immediate problem appears solved.
- Direct Translation of App Code to Tests: If the application code uses
new UAParser(), the junior developer often looks for the most direct way to intercept that exact call, leading them to prototype manipulation rather than module mocking. - Over-reliance on
vi.mocked: They seevi.mockedas a magic tool to “fix” typing on mocks, applying it indiscriminately to class properties rather than strictly to imported module bindings.