How to test third-party libraries in Vitest without using prototype?

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.getBrowser changes the behavior for all instances of UAParser created anywhere in the test suite, not just the instance used by isSafari.
  • Test Pollution: If other tests instantiate UAParser directly (or indirectly), they will inherit this mocked behavior, leading to unpredictable failures.
  • TypeScript ESLint Violation: The linter flags UAParser.prototype.getBrowser as an unbound method. When you reference UAParser.prototype.getBrowser, you detach it from its class context, losing the implicit this binding. This can lead to runtime errors if the method actually relies on this (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 via vi.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:

  1. 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.
  2. Misunderstanding of Jest/Vitest Mocking: Developers often confuse vi.mock() (module mocking) with Object.defineProperty or prototype modification. They assume that mocking a class automatically mocks all instance methods without realizing that vi.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:

  1. 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 calls new UAParser(), it receives a fresh object with mocked methods, rather than touching the global prototype.
  2. Use mockImplementation: Instead of returning a static mock, use mockImplementation to allow dynamic behavior (e.g., different user agents based on test inputs).
  3. 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.
  4. Avoid vi.mocked on Prototypes: The vi.mocked helper 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:

  1. Lack of Understanding of this and Prototypes: JavaScript’s prototype chain is a complex concept. Juniors may not realize that UAParser.prototype.getBrowser is shared across all instances.
  2. “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.
  3. 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.
  4. Over-reliance on vi.mocked: They see vi.mocked as a magic tool to “fix” typing on mocks, applying it indiscriminately to class properties rather than strictly to imported module bindings.