Component Testing Next.js with Cypress

February 16, 2023

By Mike Plummer

Cypress Component Testing allows you to build and test individual components using popular front-end frameworks, and one of the most popular frameworks in use today is Next.js. Front-end libraries like React and Vue are designed to structure your application into small, reusable components, but a framework like Next.js provides a set of capabilities that layers on top of those components which can blur the lines between what is and what isn't a "component behavior".

This can make it difficult to know the best way to test a Next.js application. In this post, we’ll describe how Next.js works with Cypress Component Testing and outline best practices for structuring your app for efficient and effective testing.

What is a Component?

For the purposes of Component Testing, a "component" is defined as a JavaScript function that has an established lifecycle, accepts parameters, and outputs HTML, CSS, and reactive behaviors. By isolating to a single component we can structure tests that validate behavior at much lower levels than could be done with a high-level e2e test, and do so with minimal dependencies. Since they are designed to focus on small targeted behaviors, component tests are easier to write, simpler to maintain, and faster to execute which makes them ideal to add to an iterative development workflow.

How does Next.js blur the lines?

Next.js enhances certain components with added capabilities, which may lead to confusion about what is actually considered to be part of that component. This is one of the neat aspects of the framework as it layers on top of familiar React code, allowing developers to avoid learning a new syntax or writing an extensive boilerplate. However, even though this additional logic lives alongside components, often in the same file, it usually describes build-time or runtime additions or wrappers that lie outside the bounds of a "component".

Data Hooks

In a Next.js application, a Page Component is a basic building block. This is a combination of a React component with a set of wrappers that allow the Next.js build and runtime process to supply static and dynamic data as props.

Data hooks such as getServerSideProps and getStaticProps are defined within the same file as a Page Component, leading to the natural desire to test those hooks using a component test. Unfortunately, this is a somewhat deceptive structure because these functions aren't really part of the component itself. Take the following example of a simple Page Component:

// /pages/post/index.js

// Section One
export default function BlogPostPage({ blogPost, userProfile }) {
  return (
    <div>Content Here</div>
  )
}

// Section Two
export async function getStaticPaths() {
  return ['/post/1', '/post/2', '/post/3']
}

// Section Three
export async function getStaticProps({ params }) {
  const blogPost = await getPostById(params.postId)
  return {
    props: {
      blogPost
    }
  }
}

// Section Four
export async function getServerSideProps(ctx) {
  const userProfile = await getUserProfile(ctx)
  return {
    props: {
      userProfile
    }
  }
}

In this example, the actual “component” is composed by Section One — by our definition, it is a function that accepts inputs and generates HTML and CSS as outputs.

Section Two is a build-time data accessor that tells the build process how many instances of the component to render and at what URL paths they should be hosted at.

Section Three is a build-time data wrapper that retrieves a prop value for the component.

Section Four is a similar data wrapper that retrieves another prop value for the component at runtime.

These sections (Two, Three, and Four) are not part of a traditional component test as they lie outside the bounds of the component itself, even though they are defined in the same file as the component.

How do we test data hooks?

Since Next.js data hooks are only invoked by the build process or when executing on a Next.js server, they will not be executed when mounting a Page Component in a component test. To validate data hooks there are two primary approaches:

  1. Execute an e2e test against a running Next.js instance to validate that expected data is rendered when accessing a given page. This will likely work well for most use cases, but it can be challenging to handle all branches and permutations if you have complex logic flows in your data hooks.
  2. Extract as much logic as possible from your data hooks into standalone JavaScript utility functions. These functions can be tested independently and much more quickly than with an e2e test. They can also easily be mocked and structured to handle all the necessary permutations. In fact, it’s possible to test these sorts of functions within Cypress by creating tests that only use standard assertions without visiting a page or mounting a component.

It is important to remember that Next.js already has its own comprehensive suite of tests, so there’s no need to write your own tests to validate that hooks fire in expected ways. Instead, focus your tests on what those hooks are doing, rather than validating that they are firing.

Helper Components

Next.js provides a set of special components to optimize common use cases. While these are excellent features, many of them rely on server-side or global-state behaviors that exist outside the bounds of your component which makes them difficult to test within a properly-structured component test.

next/image

The special Image component optimizes images at build and runtime by delivering a best-sized image based on the format and resolution supported by each client. However, this optimization relies on a server-side endpoint which means there are dependencies outside the bounds of a traditional component.

Creating a component test that involves the Image component can result in "missing" images as the server endpoint isn’t available during the test. This can be worked around in several ways.

One option is to use cy.intercept to handle calls to the image endpoint and return real or placeholder image data. This avoids making changes to your production code and allows you to display the expected image data in your tests. Unfortunately, this approach does require a bit of extra setup to create an appropriate intercept handler. The advantage of this approach is that you can dynamically adjust the image type and size based on the viewport, making it easy to validate a variety of user agents and screen sizes.

// Grab image file that this component relies on
cy.readFile('assets/cypress.png', null).then((img) => {
  // Intercept requests to Next.js backend image endpoint
  cy.intercept('_next/image*', {
    statusCode: 200,
    headers: { 'Content-Type': 'image/png' },
    body: img.buffer,
  })
  cy.mount(<MyComponent />)
})

Another option is to apply the unoptimized prop on your Image component instances. This can be done globally by overriding the Image component in your tests so that Next.js doesn't try to optimize images when testing but still does in production. Another option is to customize your next.config.js file. This approach is a good compromise since it eliminates additional logic for each image and is a "one-and-done" fix. However, it requires you to host your images and source them in a specific way.

import * as NextImage from 'next/image'

const OriginalNextImage = NextImage.default
Object.defineProperty(NextImage, 'default', {
  configurable: true,
  // Add `unoptimized` to any use of `next/image` in our tests
  value: props => <OriginalNextImage {...props} unoptimized  />,
});

cy.mount(<MyComponent />)

Examples of each approach can be found here.

next/script

The Script component in Next.js optimizes the loading of external JavaScript files such as analytics and advertising scripts. These scripts also break the traditional definition of a component test as they attempt to load dependencies outside of your component and add them to the page-level head or body element, potentially introducing undesirable global effects for low-level tests and adding external dependencies to an isolated test suite. To address this, you can add a custom cy.intercept to capture requests for external resources, and either stub a response or return a locally-hosted version. In this way, your tests will still function even if cut off from the dependencies, as might happen on a secured CI system.

An example of handling Script loading can be found here.

One of the biggest performance gains of a Single Page Application (SPA) is the ability to modify the component(s) rendered based on the runtime address or location without reloading the entire page, thanks to the Router. Components have many ways of interfacing with the Router, such as the Link component for optimizing navigation elements (buttons, hyperlinks, etc.) or the useRouter hook for programmatic navigation and redirection. However, these all integrate with a global navigation system so they cannot be easily tested within a component test. Attempting to use the Router in a component test may result in a  “NextRouter was not mounted” error like this:

Cypress error showing message "NextRouter was not mounted"

In component tests, validating that a link exists to a given location can be done easily using Cypress APIs. However, actually using that link to navigate is a task better suited for an e2e test. To isolate the component under test, it’s possible to mock out the useRouter hook or the Router itself and allow your component to render and interface with the Router.

Examples of each approach can be found here.

next/head

There are times when a component needs to control content rendered to the head element of a page. You can use the Head component for this which collects all requested content and combines it when building a page for the client. Since this updates global state outside the component, it also breaks the definition of a component test.

To validate a component’s expected head modification, you can mock out the Next.js logic that collects these requests. An example can be found here.

Code Coverage

By design, some Next.js code runs in-browser (such as most React components) and some code runs server-side (such as API routes and data hooks). To accurately assess code coverage, it’s important to understand this division of client- and server-side code. Component tests primarily focus on in-browser code, while e2e tests cover full-stack code. To get a comprehensive picture of your test suite's coverage, you can collect separate coverage reports from component tests and e2e tests and merge them.

This example project shows a basic setup of collecting client- and server-side code coverage figures.

Recommendations

To minimize friction when testing your Next.js app, follow these best practices:

  1. Keep as much content as possible in “pure” React components
    Simple components that only accept props and output JSX are very easy to test using Component Tests. Keep as much of your logic as possible in these components.
  2. Maintain Separation of Concerns
    Each component should have a single purpose or focus. Tacking on behaviors or content to an existing component is tempting, but is usually better to refactor into two smaller components.
  3. Minimize your Next.js integrations
    Using Next.js integrations like useRouter is convenient, but maybe not in every single component. Consider centralizing their usage into a subset of components or hooks, and supply needed features elsewhere by passing props and callbacks.
  4. Use your Page Components as data wrappers
    It’s tempting to directly create page layouts and content within your Page Component JSX, but this can make them difficult to test due to the issues with data hooks and integrations called out above. Consider using a Page Component as a data wrapper that passes props to pure React components for layout and structure.

By following these best practices, you can improve your architecture by promoting light coupling between unrelated parts of the system. Structuring components in this way makes them more testable and reusable, and also facilitates dependency updates, such as a Next.js version upgrade, by placing integration points in deliberate, known locations.

Customize your Next.js Testing Experience

The default mount command for Next.js prioritizes speed and efficiency testing pure components, but sometimes it’s desirable to test a component in combination with other global concerns like the Router. A powerful part of Cypress is the ability to create new commands or override existing ones; this means you have the ability to define a mount command that provides these extra Next.js features to some or all of your component tests, while minimizing duplication.

In the example below, we define a custom nextMount command that wraps your component with a mocked Router instance, a HeadManager to inspect the usage of the Head component, and adds a fallback Image interceptor to display mock images in place of optimized image data. This addresses a large number of “gotcha” behaviors from using the Next.js helper components.

Cypress.Commands.add('nextMount', (component, options) => {
    // Mock a `Router`, enable asserting against function calls using `cy.stub`: ( cy.get('@router:back').should(...) )
    const router = {
        route: '/',
        pathname: '/',
        query: {},
        asPath: '/',
        basePath: '',
        back: cy.stub().as('router:back'),
        forward: cy.stub().as('router:forward'),
        push: cy.stub().as('router:push'),
        reload: cy.stub().as('router:reload'),
        replace: cy.stub().as('router:replace'),
        isReady: true,
        ...(options?.router || {}),
    }
    // Mock a `HeadManager`, enable asserting against additions via `<Head />` & `<Script />` using `cy.stub`
    const headManager = {
        updateHead: cy.stub().as('head:updateHead'),
        mountedInstances: new Set(),
        updateScripts: cy.stub().as('head:updateScripts'),
        scripts: new Set(),
        getIsSsr: () => false,
        appDir: false,
        nonce: '_',
        ...(options?.head || {})
    }

    // Any network call to get optimized image data is intercepted and supplied with a fallback image
    const FallbackImage = /* grab a mock image from filesystem */
    cy.intercept('_next/image*', {
        statusCode: 200,
        headers: { 'Content-Type': 'image/png' },
        body: FallbackImage.buffer,
    })

    return mount(
        <HeadManagerContext.Provider value={headManager}>
            <RouterContext.Provider value={router}>
                {component}
            </RouterContext.Provider>
        </HeadManagerContext.Provider>,
        options
    )
})

Unfortunately, there’s no such thing as a free lunch—adding these extra items to every mount will affect performance and introduce global state elements outside the bounds of your component. It’s up to you to decide whether these trade-offs are worth it based on your use case.

By following best practices, these customizations should be necessary only in rare cases, helping to keep your tests clean and fast. However, where needed, the ability to modify and share this type of reusable logic across your test suite is extremely powerful in that it introduces cross-cutting concerns without requiring changes to every test.

Next v13 and Beyond

Next.js version 13 introduces a new way of structuring components in Next in beta. This includes a new /app directory, new data hooks, shared layouts, and new helper components. The good news is that a lot of these changes extract and refactor parts that were tricky to validate via Component Tests. The Head component, for example, is replaced with a standalone head.jsx component that can be tested without workarounds. However, other changes introduce some bleeding edge capabilities from React that require new patterns in Cypress, as exemplified by the heavy use of Client vs. Server Components. We're always excited to hear from our users and make sure that Cypress is as effective as possible for them, so let us know if you have any ideas or suggestions for how to structure our Next.js support for these new changes introduced in version 13!

Become a part of the thriving Cypress community and take your testing skills to new heights! Connect with like-minded professionals, share knowledge, and stay up-to-date with the latest trends in component testing.