Most engineers eventually hit code that feels impossible to test. Sometimes it’s legacy code, sometimes framework internals, sometimes a global API you don’t control. The shape varies, but the experience is the same:

  • “This component crashes when I try to test it in isolation.”
  • “The database connection is hardcoded — how do I mock that?”
  • “This payment gateway SDK doesn’t have a test mode.”
  • “The authentication middleware fails outside the main app.”
  • “I guess this legacy service just isn’t testable.”

This article is a quick case study showing how you can approach these problems — not by fighting the framework, but by introducing small test seams.


1. The Problem: Next.js App Router + Cypress

Follow Along

Clone the repo: https://github.com/reergymerej/nextjs-router-cypress Start with: git checkout 1-problem Each section references a different branch with the incremental changes.

Here’s the scenario: You’re writing component tests for a dashboard component. The dashboard renders fine, but buried deep inside it is a navigation component that uses Next.js’s useRouter. You can’t easily mock that inner component because Cypress handles imports differently than Jest — you need to test the whole component tree.

In a browser it works perfectly. In Cypress Component Testing?

Follow Along

bun test:run

Cypress test failure showing "Error: invariant expected app router to be mounted" - a common error when trying to test Next.js router components in isolation

This error didn’t come from your component. It came from deep inside the Next.js App Router internals, which expect to be running inside the full Next.js runtime — not in Cypress’ isolated component sandbox.

This is a well-documented limitation when testing Next.js components in isolation. The App Router creates global state that can’t easily be mocked or reset between tests.

This is a heartbreaker when you first realize Cypress doesn’t allow you to mock the way Jest does. See this Cypress discussion for more details.

Attempts to “just mock it” failed for all the usual reasons:

  • Cypress is a real browser, not Jest’s Node environment
  • The App Router is effectively a global singleton
  • By the time your test runs, the router has already been initialized
  • Cypress’ mount/unmount cycle doesn’t align with the App Router’s expectations

This is where most engineers say: “OK, we can’t component-test this.”


2. Why This Happens

This class of problem always looks different on the surface, but the root cause is the same:

A component is tightly coupled to a global dependency with no seam.

Cypress wants dependency injection. Next.js gives you a global router. This fundamental mismatch creates the testing friction.

So the question becomes:

“Where can I create a seam without rewriting everything?”

This is the core of making “untestable” systems testable.


3. The Fix: Introduce a Thin Abstraction Layer

Follow Along

git checkout 2-abstraction

You don’t have to do anything massive, like: rewriting the component, mocking Next.js internals, or forking the router.

3.1 Wrap the router in a small abstraction

This decouples things a bit, opening up a place for a seam. Nothing has changed, really, just how we’re importing the router hook.

Change the component to import from a helper.

// src/components/NavButton.tsx
-import { useRouter } from "next/navigation";
+import { useRouter } from "@/helper";

Add a helper that exports the router hook from Next.

// src/helper.ts
"use client";

import {
  useRouter as useNextRouter,
} from "next/navigation";

export const useRouter = () => {
  return useNextRouter()
};

3.2 Build a Seam

Follow Along

git checkout 3-seam

Now that we have an abstraction around the useRouter call, we can build a test seam.

Definition: “A seam is a place where you can alter behavior in your program without editing in that place.” — Michael Feathers, Working Effectively with Legacy Code

Here we flesh out our abstraction a bit with a singleton router. By default, it uses Next, but we can swap it out with useRouterSeam.

"use client";

import {
  useRouter as useNextRouter,
} from "next/navigation";

export type UseRouter = typeof useNextRouter;
let _useRouter: UseRouter | undefined;

export const useRouterSeam = (useRouter: UseRouter) => {
  _useRouter = useRouter;
};

export const useRouter = () => {
  if (!_useRouter) {
    // assign lazily to support last minute swapping in tests
    _useRouter = useNextRouter;
  }
  return _useRouter();
};

At this point, the tests still fail to run. We haven’t changed any behavior, only opened up the potential.

3.3 In Cypress, inject a test router

Follow Along

git checkout 4-inject

Now that we have a seam, you can change your test to inject a dummy router using it.

import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime';
import Component from './HasNavInside'
import { useRouterSeam as injectRouter, UseRouter } from "@/helper";

export const useRouter: UseRouter = () => {
  return {
    // mocked router methods
  } as AppRouterInstance;
};

injectRouter(useRouter);

describe('<Component />', () => {
  it('renders', () => {
    cy.mount(<Component />)
    cy.get('h2').contains('Content')
  })
})

Follow Along

bun test:run

Cypress runs flawlessly.

Router behavior is fully observable and modifiable. The problem component no longer prevents us from testing the outer component.

Cypress success screenshot


4. Why This Works (The Larger Lesson)

The important point isn’t “how to test Next.js router.” The important point is:

Most code that feels untestable simply has no seam yet.

When a dependency is global, rigid, or framework-owned:

  • Wrap it
  • Own the interface
  • Inject behavior in tests
  • Keep production behavior identical

This pattern works for:

  • legacy codebases
  • brittle singletons
  • old services with weird APIs
  • frameworks with hidden global state
  • components that crash when isolated

It’s the foundation of how you can approach code rejuvenation.


5. Conclusion

This wasn’t a large refactor. The changes were tiny. But the impact was huge: a piece of code that “couldn’t be tested” became boring, reliable, and fully deterministic.

Small seams create large possibilities.

The next time you encounter “untestable” code, look for the missing abstraction layer. Often, one small interface can transform rigid dependencies into flexible, testable systems.