Mocking downstream dependencies with Mockttp in Cypress tests for NextJS apps
23 Aug 2023
In a complex or distributed system, you might have an app with multiple downstream dependencies. End-to-end tests might not be an option, or they can serve as another level of testing in addition to your Cypress and Jest tests. It is possible to mock downstream dependencies for calls from your BFF using Mockttp. This post briefly explains how to set this up.
The author assumes you either already have a NextJS app with Cypress or can set one up. Below is a short recap, just in case. If you are not interested in the recap, feel free to skip it.
npx create-next-app@latest --use-yarn # using yarn is just a preference
cd <your_app_name>
yarn add -D cypress
yarn cypress open
In the Cypress UI, you can follow the wizard to bootstrap the files that Cypress needs.
Let's add an external API call on the server side when rendering the home page (src/app/page.js
or another location, depending on your choices during the app setup).
.env.local
:
API=https://jsonplaceholder.typicode.com
src/app/page.js
:
async function getData() {
const res = await fetch(`${process.env.API}/todos/1`)
if (!res.ok) {
throw new Error('Failed to fetch data')
}
return res.json()
}
...
export default async function Home() {
const data = await getData()
...
return (
...
<p>UserID: {data.userId}</p>
...
)
}
Add a simple Cypress test:
it('renders the JSON', () => {
cy.visit('/')
cy.contains('UserID: 1').should('be.visible')
})
(Adjust the baseUrl
values for both Cypress and NextJS as necessary.)
Adding Mockttp to the project
yarn add -D mockttp
The Mockttp CLI starts a standalone server and passes control to the command you provide.
This goes to the scripts
of the package.json
:
"cypress:open": "mockttp -c \"cypress open .\""
Making Mockttp work in Cypress tests
Now, if you look at the Cypress UI, you might see an error related to how Webpack treats WebAssembly modules:
Error: Webpack Compilation Error
Module parse failed: Unexpected character '' (1:0)
The module seem to be a WebAssembly module, but module is not flagged as WebAssembly module for webpack.
BREAKING CHANGE: Since webpack 5 WebAssembly is not enabled by default and flagged as experimental feature.
We can fix it by adding a @cypress/webpack-preprocessor
(and its peer dependencies) and overriding the
webpack config:
yarn add -D @cypress/webpack-preprocessor webpack @babel/core @babel/preset-env babel-loader buffer stream-browserify
cypress.config.js
:
const { defineConfig } = require("cypress");
const webpackPreprocessor = require("@cypress/webpack-preprocessor");
const webpack = require("webpack");
...
setupNodeEvents(on, config) {
const webpackOptions = {
resolve: {
fallback: {
zlib: false,
fs: false,
path: false,
querystring: false,
url: require.resolve("url"),
stream: require.resolve("stream-browserify"),
buffer: require.resolve("buffer"),
},
},
experiments: {
asyncWebAssembly: true,
},
plugins: [
new webpack.ProvidePlugin({ process: "process/browser" }),
new webpack.ProvidePlugin({
Buffer: ["buffer", "Buffer"],
})
]
}
on('file:preprocessor', webpackPreprocessor({ webpackOptions }))
return config
}
(you'll need to restart the yarn cypress:open
command)
Using the mock server in tests
Before supplying our mocks, we must direct the app to use the mock server instead
of the actual downstream dependency. We did it here by supplying a substitute
value inside the dev yarn in scripts
in package.json
, but you can absolutely do
it via a dedicated .env file, CI variable overrides or any other way.
"dev": "API=http://localhost:9999/jsonplaceholder next dev -p 8080",
"start": "API=http://localhost:9999/jsonplaceholder next start -p 8080"
After doing this and restarting the yarn dev
command, we expect the tests to fail since there's no server on localhost:9999 yet.
We used a subpath (
/jsonplaceholder
) on the server. This way, it's possible to use one instance of Mockttp to mock several upstream backends under different subpaths.
Now we can update our spec file so that it connects to the Mockttp server via getRemote
and passes on the mocks:
import { getRemote } from 'mockttp'
const server = getRemote()
before(() => {
cy.wrap(server.start(9999))
})
after(() => {
cy.wrap(server.stop())
})
it('renders the JSON', () => {
cy.wrap(server.forGet("/jsonplaceholder/todos/1").always().thenJson(200, { userId: 2 }))
cy.visit('/')
cy.contains('UserID: 2').should('be.visible')
})
It's possible to get the port dynamically from
server.start()
. We don't do it here because we want to provide the specific mock URL (including the port) as part of our imaginary CI configuration.
On an additional note, while running a dev server of your app locally to avoid any issues with NextJS caching, we found it helpful to turn off NextJS's caching locally:
const res = await fetch(`${process.env.API}/todos/1`, { cache: 'no-store' })
Testing the requests the BFF sends
You can also assert the requests that the NextJS server-side sends to the downstream dependencies. Below
is an example. For this to work, we also need to change before
and after
to beforeEach
and afterEach
to
ensure mocks are isolated between the tests.
it('sends a correct request', () => {
cy.wrap(server.forGet("/jsonplaceholder/todos/1").always().thenJson(200, { userId: 2 }))
.as('mockEndpoint')
cy.visit('/')
cy.get('@mockEndpoint').then(mockEndpoint => {
cy.wrap(mockEndpoint?.getSeenRequests()).then(reqs => {
cy.wrap(reqs).should('have.length', 1)
cy.wrap(reqs[0]).its('headers').should('have.property', 'accept')
})
})
})