Full-Stack Testing with Cypress & Next

ยท

5 min read

Take me to the code

Over the past few years we have seen the web ecosystem move increasingly towards executing code on the server instead of on the client browser. The advent and popularity of tools like Next.JS and Remix, as well as the features introduced in React 18 really emphasise this.

We're building faster, more accessible web applications.

However, this improvement comes at a cost...

We have code that runs in more places

After many years of SPAs and running almost all our code on the browser, we have seemingly come full circle and are now back to trying to run as much of our code as possible on the server. Kent C. Dodds has a great article on this and how the web has changed over the years.

This is fine until we have to start testing our applications. Tools like Cypress provide excellent functionality for intercepting browser HTTP requests, however when we start running code on the server, these interceptions mean we might be bypassing half our code! ๐Ÿ˜ฑ

I wanted to have a way to intercept HTTP requests regardless of where they were initiated...

NextJS setup to allow isomorphic mocking with Cypress

You can see the full code example here. ๐Ÿง‘โ€๐Ÿ’ป I have also published a package that provide the utilities we are going to talk about: @gait-tools/cypress-server-mock. ๐Ÿ“ฆ

Cypress allows us to mock client requests using cy.intercept. We want to be able to run a similar command to mock HTTP calls made from the server. We can do this will a tool like nock, or undici's built in mocking.

In order to get this to work seamlessly we'll need to have a way to communicate between the client and the server when Cypress is running. We can do this with Next API routes, and that's where we'll start:

Lets create a new file /api/mock.ts with the following contents:

// Actual values will depend on your mocking requirements
export interface MockConfig {
  path: string;
  method: "GET" | "POST"
  resBody: record<string, any>
}

export default async function handler({
  req: NextApiRequest,
  res: NextApiResponse<string>
}) {
  const mockConfig = JSON.parse(req.body) as MockConfig;

  // Now set up your mocking config as per your mocking requirements
  fetch.mock(mockConfig.path, mockConfig.method)
    .reply(mockConfig.resBody);

  return res.status(200).send('ok')
}

This endpoint can be called from a custom Cypress command. In cypress/commands.ts We can add:

Cypress.command.add('interceptServer', (mockConfig: MockConfig) => {
  cy.request({
    url: '/api/mock',
    method: "POST",
    body: JSON.stringify(mockConfig)
  });
});

We're now ready to start using our command in our cypress tests. Lets take a simple NextJS page that loads a blog post from an external data source in getServerSideProps:

describe("Page load with Server Calls", () => {
  beforeEach(() => {
    const mockPost = {
      postId: '1234',
      author: 'alex@gait.dev',
      title: 'just another post'
    }

    cy.interceptServer({
      path: 'https://external-blog.com/1234',
      method: 'GET',
      resBody: mockPost
    });
  });

  it('should load a post', () => {
    cy.visit('/posts/1234');

    cy.get('h1').contains('just another post').toBeVisible();
  });

});

That's all you need to get a very basic example set up. However, those eagle eyed among you will notice we're missing a few key things:

Lets not leak test state

At the moment, we're doing nothing to reset our mocks between each test, so we'd be getting our mock blog post for the entire time we're running these tests. Even worse, it could persist for the lifetime of your server process depending on your mocking setup.

In order to fix that, we ought to set up another endpoint to reset our tests. lets refactor our mocking API folder a little:

//before
pages/
  api/
    mock.ts

// after
pages/
  api/
    mock/
      index.ts // same content as mock.ts
      reset.ts

We now have a new reset.ts file we can populate:

export default async function handler({
  req: NextApiRequest,
  res: NextApiResponse<string>
}) {

  // Rest your mocks as per your chosen mocking library
  fetch.mock.reset();

  res.status(200).reply("ok");
}

We also need to create a new Cypress command to reset our server:

Cypress.command.add('resetServerIntercept', () => {
  cy.request({
    url: '/api/mock/reset',
    method: "POST",
  });
});

And to run that command in our tests:

describe("Page load with Server Calls", () => {
  beforeEach(() => {
    const mockPost = {
      postId: '1234',
      author: 'alex@gait.dev',
      title: 'just another post'
    }

    cy.interceptServer({
      path: 'https://external-blog.com/1234',
      method: 'GET',
      resBody: mockPost
    });
  });

  afterEach(() => {
    cy.resetServerIntercept();
  });

  it('should load a post', () => {
    cy.visit('/posts/1234');

    cy.get('h1').contains('just another post').toBeVisible();
  });

});

Nice - no more leaking tests!

Make it secure

Our current implementation has a pretty gaping security hole - anybody can hit our API and mock any of our endpoints - which is probably not ideal. Luckily, with a little bit of work we can resolve this!

Firstly, lets add an environment variable to our node process so we can know whether we are running tests or not in our package.json:

...
"dev:ci": "CI=true next dev"
...

We can utilise this to secure our endpoints:


export default async function handler({
  req: NextApiRequest,
  res: NextApiResponse<string>
}) {

  // Add this code for all the mocking endpoints
  if (!process.env.CI || process.env.NODE_ENV === "production") {
    return res.status(404).send("not found");
  }

  // Rest your mocks as per your chosen mocking library
  fetch.mock.reset();

  res.status(200).json("ok");
}

This means that this endpoint will return a 404 for any calls to a production build, or to a build where the CI env variable isn't set.

You can take this further by limiting to a subset of authenticated users, or with a token depending on your circumstances.

And that's it - we can use the power of our server to help make it easier for us to mock out our application tests with Cypress!

You can get started with this pattern easily by using the @gait-tools/cypress-server-mock package. ๐Ÿ“ฆ

ย