I love end-to-end (e2e) tests in Playwright or Cypress. They are the best way to test a codebase from a user's perspective. But they do have downsides. They can be a pain to set up, hard to maintain, and potentially take minutes to run. 

Software engineering is all about fast feedback loops. I've been using a new approach where I mount Next.js pages in react-testing-library and run fake e2e tests.

There is no headless browser involved. This significantly increases the speed of the feedback loop. You can also do this using Vue Testing Library.

Here’s the full example. We’ll go through it line by line below.

setupMockApi([
  {
    url: "/todo/9eb4948387",
    handler: (req, res, ctx) => {
      return res(ctx.status(200), ctx.json(mockResponse));
    },
  }
])

vi.mock("next/router", () => ({
  useRouter: vi.fn(),
}));

// create a spy on a function
const pushMock = vi.fn();

beforeEach(() => {
  pushMock.mockReset();
});

describe("Todo Detail Page", () => {
    test("Should be able to remove a todo", async () => {
      useRouter.mockReturnValue({
        query: { id: "9eb4948387" }, // id is used on the data fetch
        push: pushMock,
      });
    
      const { findByRole, queryByRole } = renderComponentWithWrapper(
        <TodoDetailPage />
      );
    
      const deleteButton = await findByRole("button", { name: "actions.delete" });
      await userEvent.click(deleteButton);
    
      const confirmDeleteButton = await findByRole("button", {
        name: "actions.confirm",
      });
      await userEvent.click(confirmDeleteButton);
    
      expect(pushMock).not.toHaveBeenCalled();
      expect(queryByRole("alertdialog")).not.toBeInTheDocument();
    });
})

I've created a setupMockApi function for convenience. I use msw to mock API calls. This allows me to return the values that I want.

// defaults to get
// with a default base URL

setupMockApi([
  {
    url: `/images `,
    method: "post",
    handler: (req, res, ctx) => {
      return res(ctx.status(200), ctx.json(imagesPostResult));
    },
  },
  {
    url: `/images`,
    handler: (req, res, ctx) => {
      return res(ctx.status(200), ctx.json(imagesGetResult));
    },
  },
]);

Next, I mock the router. I created a pushMock object to use instead of push. In the test, this is defined as a parameter to useRouter. I also reset the pushMock object before it’s used.

const pushMock = vi.fn();

beforeEach(() => {
  pushMock.mockReset();
});

// in a test
useRouter.mockReturnValue({
  query: { id: "3def30ab-4048-4d72-9014-60215444a96d" },
  push: pushMock,
});

// another test
// assert if push was called with a specific route
expect(pushMock).toHaveBeenCalledWith("/specific/url")

The next line is where some magic happens. Create your own method that wraps a page with the required Providers (as needed by tanstack-query, next-auth, and/or custom providers).

export const Wrapper = ({ children }) => {
  const queryClient = useMemo(() => {
    return new QueryClient({
      defaultOptions: {
        queries: {
          retry: false,
        },
      },
      logger: {
        log: console.log,
        warn: console.warn,
        error: process.env.NODE_ENV === "test" ? () => {} : console.error,
      },
    });
  }, []);

  return (
    <QueryClientProvider client={queryClient}>
      <SessionProvider session={session}> // mock next-auth session
        {children}
      </SessionProvider>
    </QueryClientProvider>
  );
};

const renderComponentWithWrapper = (
  Component,
  options
) =>
  render(Component, {
    wrapper: Wrapper,
    ...options,
  });


// Usage in a specific test
const { findByRole } = renderComponentWithWrapper(
    <SomePage />
);
// describe test scenarios

Writing queries using `findBy[...]` is key. Use userEvent for (you guessed it) user-triggered events. React-testing-library has a great resource regarding writing queries

Tips for testing end-to-end without a headless browser

Here are some pointers on setting up tests like this. First, configure pageExtensions in next.config.js to only load pages that end with page.tsx. This allows you to collocate tests with their respective pages.

// next.config.js
pageExtensions: ["page.tsx"],

// folder structure example
/src
    /pages
      /index.page.tsx
      /index.test.ts

Set up translations in your global test setup file. Prioritize testing using translation keys, the default fallback for most translation systems. This decouples the translations from the tests.

const button = await findByRole("button", { name: "action.confirm" });

Debugging can be tricky at moments; using .debug() can greatly help you. This method outputs the current HTML at a specific point in a test.

Finally, this example is in Next.js using Vitest, but this approach can be used with pretty much every front-end framework or testing suite, such as Jest.

Happy experimenting!