Don't Mock Your Framework: Writing Tests You Won't Regret

Don't Mock Your Framework: Writing Tests You Won't Regret
Interior view of the typical Digital Test Equipment unit (via computerhistory.org).

I love a good test. No, scratch that, I love a laconic test! There's an art to it: the eloquent test name, the crisp setup that establishes context, the succinct action that triggers only what needs testing, and finally a few pithy assertions. A truly great test is verbal economy in executable form, a dozen sharp lines that speak volumes without wasting a character.

You can imagine my heartbreak when I open a test file and spot an ugly blemish staring back at me from the very top:

vi.mock('@remix-run/react', () => {
  useLoaderData: vi.fn(),
});

Mocking a framework dependency? How unsightly! This test suite may as well be mocking me.

Mocking your framework might seem expedient today, but tomorrow it will cause pain in the form of brittle tests, high upgrade friction, and hidden integration issues.

Where It All Started: "Don't Mock What You Don't Own"

The principle "Don't mock what you don't own" originated from the London School of Test-Driven Development (TDD), particularly in Steve Freeman and Nat Pryce's excellent book Growing Object-Oriented Software, Guided by Tests (2009). This guideline advises against mocking interfaces or types that you don't control. This covers any external code like third-party libraries.

Google's Testing Blog later gave this concept a URL in a 2020 "Testing on the Toilet" article titled Don't Mock Types You Don't Own which warned:

The expectations of an API hardcoded in a mock can be wrong or get out of date. This may require time-consuming work to manually update your tests when upgrading the library version.

In theory, this advice seems rather obvious. Yet when deadlines approach and test suites need completing, mocking a framework dependency becomes irresistibly tempting. The promise is alluring: isolate your code, control all inputs, test only what you wrote. Experience tells a different story: what begins as a clever shortcut inevitably becomes tomorrow's maintenance nightmare.

Fast forward to today's TypeScript landscape, and this principle becomes not just good advice but essential survival wisdom for maintaining modern codebases and products.

Why You Shouldn't Mock Your Framework (React, Remix, Next.js)

When you mock React, Remix, or similar frameworks, you're creating problems for your future self:

Problem 1: Brittle Tests Due to Framework Updates

Imagine you're testing a React component that uses hooks. You might be tempted to mock useState to control their behavior in tests. Your tests pass, you ship the code, and all seems well. Then some other dependency releases an update with internal implementation changes, suddenly your tests are broken, but your actual code still works fine! What changed? The other dependency's update relied on useState under the hood, the same API your test mocked out.

Frameworks aren't just libraries, they're ecosystems where many dependencies coexist and interact. When you mock out framework internals, you're not just tampering with one API; you're potentially disrupting an entire habitat of interdependent modules that expect the framework to behave consistently.

Problem 2: Framework Coupling Increases Upgrade Friction

When you mock framework APIs, you are hardcoding assumptions about their behavior. This increases your tests coupling on the framework because you are coupling to both the API interface and the internal algorithm details. This creates a maintenance burden during upgrades:

  1. You must painstakingly update all your mocks to match the new API
  2. You lose the ability to detect actual regressions during upgrades
  3. The time spent fixing mock-related test failures distracts from addressing real issues

For example, when Remix updated its API to support the V2 data APIs, many tests that mocked loaders and actions needed significant rewrites—not because the application logic changed, but because the tests were coupled to the framework's implementation details.

Problem 3: Library Mocks Hide Integration Issues

This principle isn't just for UI frameworks, it applies to all external dependencies, just as Steve Freeman and Nat Pryce predicted. Whether it's validation libraries like Zod, state managers, or any other third-party code, the same caution applies: mock what you own, respect what you don't.

A Better Way: What You Actually Own

So what's the alternative? Instead of mocking frameworks directly, focus on what you own:

1. Create Thin Adapters Around Libraries

Wrap external dependencies in your own abstractions that match your domain language, this is a core principle of hexagonal architecture. These adapters not only decouple your code from third-party implementations but also create perfect test seams where mocking becomes both safe and effective:

// Instead of directly using Remix's useLoaderData in components
// Create a domain-specific adapter

// Your adapter
export const getUserProfile = () => {
  const data = useLoaderData<typeof loader>();
  return {
    username: data.user.username,
    displayName: data.user.displayName,
    isVerified: data.user.emailVerified && data.user.phoneVerified,
    permissions: mapPermissions(data.user.roles)
  };
};

// In tests, you can mock your own function
import * as profile from '../user/profile';
vi.spyOn(profile, 'getUserProfile').mockReturnValue({
  username: 'testuser',
  displayName: 'Test User',
  isVerified: true,
  permissions: ['read:content', 'edit:profile']
});

By mocking your adapter instead of Remix's useLoaderData directly, you're working with interfaces you control, creating tests that bend without breaking when the framework evolves. Your components can also remain focused on presentation rather than data transformation logic.

2. Use Real Implementations in Integration Tests

I love mocks as much as the next dev to ensure my tests run fast and the behavior under test remains isolated. However, when your code integrates directly with a framework there is no replacement for integration tests that exercise the real thing.

test('should show an error message for invalid emails', async () => {
  // Testing Library's render method integrates directly with React
  render(<UserForm />);
  
  // Interact with actual components
  fireEvent.change(screen.getByLabelText('Email'), { 
    target: { value: 'invalid-email' } 
  });
  fireEvent.click(screen.getByRole('button', { name: /submit/i }));
  
  // Test actual validation behavior
  await screen.findByText('Please enter a valid email address');
});

3. Use Official Testing Utilities

Many frameworks provide official testing utilities that are designed to work with the framework:

// Using React Router's MemoryRouter instead of mocking
test('navigation works correctly', () => {
  render(
    <MemoryRouter initialEntries={['/start']}>
      <App />
    </MemoryRouter>
  );
  
  // Test navigation without mocking router internals
  fireEvent.click(screen.getByText('Go to Dashboard'));
  expect(screen.getByRole('heading')).toHaveTextContent('Dashboard');
});

These utilities are maintained alongside the framework and are designed to be used in tests without exposing internal implementation details. This means you can rely on them to be a stable foundation that will continue to work as the framework updates and evolves.

Beyond reliability, using these official utilities signals to other developers that your tests follow community standards, making your codebase more approachable for newcomers and easier to maintain as your team grows.

The Exception That Proves the Rule: Disabling Non-Essential Behavior

While "Don't Mock What You Don't Own" is generally good advice, there is one exception where I've found it's ok to bend the rules a bit. I typically feel comfortable mocking a 3rd party dependency only if I am completely disabling behavior that isn't a part of the behavior I'm testing.

My go-to example of this is Popper.js. Popper.js is a UI library for making floating elements like tooltips, popovers or drop-downs. Because of the nature of floating elements popper.js often makes some relatively expensive layout calculations when the component is rendered, but many integration tests of UI components that contain informational tooltips aren't actually testing the behavior of the tooltip.

In cases like this, I'll disable Popper.js in the test suite because it can provide a significant speedup to unit test execution time.

// Disable Popper.js because we're not testing 
// tooltip positioning
vi.mock('@popperjs/core');

This simple change reduced our test suite execution time from 15 seconds to 11 seconds on my local machine. More importantly, on our congested CI workers during peak hours, it decreased test timeouts by nearly 40% by eliminating calculations irrelevant to what we were actually testing.

So why is mocking Popper.js an acceptable exception to our rule? Because we're completely disabling functionality peripheral to our test's purpose. We're not testing tooltip positioning - we're testing the component that happens to use tooltips. By removing these expensive layout calculations, we dramatically improve test reliability without compromising correctness.

However, remember to document this exception clearly in your test setup and consider having at least one integration test that uses the real implementation to ensure it works as expected.

Conclusion

We began with the heartbreak of discovering an unsightly mock blemishing our test file. Now you have the antidote: draw clear boundaries, respect what others own, and mock only what's truly yours. The next time you open a test file, it won't mock you with its fragility, it will greet you with the same elegance that makes testing an art form. After all, test files should be collections of wit and wisdom, not monuments to the framework APIs we once thought we understood.

Your future self will thank you for making your test suite a sharper tool. After all, the beauty of a great test isn't just in what it verifies, but in how gracefully it evolves alongside your code.

Subscribe to Laconic Wit

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe