Table of Contents
- Overview
- Create a server component
- Create tests
- Approach 1 - an end-to-end test pretending to be a component test
- Approach 2 - a true component test using
cy.stub
andasync
/await
- Other possible solutions
- References
Overview
My team often works on component-centric projects, building component libraries and using frameworks such as Storybook to present each component in isolation, in all of its various states.
Using component tests on these types of projects is vital, we need fast, repeatable automated tests to ensure that our components are functioning and appearing correctly.
In the previous articles we have been using Cypress to test client-rendered components and used cy.intercept
to set up different stubbed responses from an API.
For server components its not quite as simple 😔. But dont worry, there are answers 😊! In fact, there are two approaches that I can see, read on to find out…
In this article we are going to:
- expand on the application that we created in part 1 and part 2 of this series. The complete code for those two articles can be found here: https://github.com/speaktosteve/nextjs-cypress-part1-and-part2
- add a simple server component to the application
- explore how to add tests for the server component using Storybook and an end-to-end test
- look at other possible approaches
Create a server component
I’m going to assume you have a Next.js application with Cypress set up. A full guide to setting this up can be found in this previous article.
First step is to introduce a simple server component:
//src/app/components/productsServer/productsServer.tsx
import { getProducts } from '@/app/utils/api'
import { ProductCard } from '../productCard/productCard'
import { IProduct } from '@/app/types/product'
export const ProductsServer = async () => {
const response = await getProducts()
if (!response.ok) {
return <p>Something went wrong...</p>
}
const products: IProduct[] = await response.json()
return (
<section>
<h2 className="text-xl pb-4">Products</h2>
<ul>
{products && products.length === 0 && <p>No products found</p>}
<ul className="grid md:grid-cols-2">
{products &&
products.map((product) => (
<ProductCard product={product} key={product.id} />
))}
</ul>
</ul>
</section>
)
}
This component displays product data in exactly the same way as our <Products />
client component. You will see that:
- it is imaginatively named
<ProductsServer />
to differentiate it from the<Products />
client component - there is no
use client
declaration, so Next.js will assume this code needs to run on the server - its asynchronous
- it retrieves the data from the same
getProducts()
function as the client component
I have incorporated the new component into the app, along with a way to navigate between it and the original client component. You can access the full code in the reference repo.
The app structure is now:
src
└───app
└─── components
└─── products
└─── products.cy.tsx
└─── products.tsx
└─── productsServer
└─── productsServer.tsx
└─── hooks
└───useProducts.tsx
└─── types
└─── product.ts
└─── utils
└─── api.ts
└─── page.
And when you run it, it looks like this:

Next step, get some test coverage over our server component…
Create tests
Starting with a simple Cypress test, I want to ensure the component has loaded correctly…
//src/app/components/productsServer/productsServer.cy.tsx
import { ProductsServer } from './productsServer'
describe('Tests for the <ProductsServer /> component', () => {
it('renders component', () => {
cy.mount(<ProductsServer />)
})
})
And run the test:
yarn cypress
NB: the above is my alias for cypress open
Running this new test in Cypress we see a confusing error:
Error: Objects are not valid as a React child (found: [object Promise]). If you meant to render a collection of children, use an array instead.

The underlying issue here is that the server component is executing its data fetch asynchronously before returning any content, but the Cypress test is not set up to handle this asynchronous behaviour.
Approach 1 - an end-to-end test pretending to be a component test
Having looking at the official Next.js docs on using Cypress…
Cypress currently doesn’t support Component Testing for async Server Components. We recommend using E2E testing.
…I moved my focus onto using E2E tests instead of Component tests. This felt like a compromise as I wanted to test the component in memory, without worrying about spinning them up in a browser. This approach does work, but technically its not a component test.
In my research I settled on the following approach:
- To use E2E tests I needed the component to be available to Cypress’s
cy.visit()
method, basically it needs to be viewable in a browser. - For this, I decided to use Storybook, which would allow me to access a component in isolation at a specific URL
Install Storybook
Please checkout the Storybook docs if you aren’t familiar with the tool. Also, the full installation guide for adding Storybook to a Next.js app can be found here.
I’m going to assume you don’t have Storybook installed and are starting from scratch.
Firstly, install Storybook:
npx storybook@latest init
This will add all of the required scaffolding for Storybook, including some sample stories.
Create a Storybook story
We will now add our first story for the <ProductsServer />
component. This will allow us access to the isolated component in a browser using Storybook.
I’m adding the story file to the same src/app/components/productsServer
folder as the component itself.
//src/app/components/productsServer/productsServer.stories.ts
import type { Meta, StoryObj } from '@storybook/react'
import { fn } from '@storybook/test'
import { ProductsServer } from './productsServer'
const meta = {
title: 'Products (Server Rendered)',
component: ProductsServer,
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
layout: 'fullscreen',
},
args: {
onLogin: fn(),
onLogout: fn(),
onCreateAccount: fn(),
},
} satisfies Meta<typeof ProductsServer>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
Run Storybook
Then, run Storybook:
npm run storybook
Navigate to the Storybook UI at http://localhost:6006/ and you should see something like this:

If you navigate to the Products (Server Rendered)
component (and click on ‘Default’) you will see an error:
async/await is not yet supported in Client Components, only Server Components. This error is often caused by accidentally adding
'use client'
to a module that was originally written for the server.

But don’t worry! This is simple to fix via the experimental RSC support flag. Enable the experimentalRSC
feature in your .storybook/main.ts
file. This provides support for React Server Components:
import type { StorybookConfig } from '@storybook/nextjs'
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-onboarding',
'@storybook/addon-essentials',
'@chromatic-com/storybook',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/nextjs',
options: {},
},
features: {
experimentalRSC: true,
},
staticDirs: ['../public'],
}
export default config
If you restart Storybook you should now be able to see the <ProductsServer />
component:

Add custom CSS
Good, but it looks ugly. Lets make sure Storybook loads our CSS in when previewing our components. In .storybook/preview.ts
simply add a reference to the main .css file:
// .storybook/preview.ts
import type { Preview } from '@storybook/react'
import './../src/app/globals.css'
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
}
export default preview
Now, the CSS will kick in and you can see the component in all of its beautiful glory:

Open the component in isolation
If you click on the ‘Open canvas in new tab’ button in the top right…

…the component will load in its own tab, with no frame or other elements.

We now have a URL that we can point our Cypress E2E test at for testing our component. It should look a little something like this:
http://localhost:6006/iframe.html?globals=&args=&id=products-server-rendered—default&viewMode=story
Add an E2E test
I have chosen to add the E2E test in the same folder as the component. This makes sense to me, unlike traditional E2E tests this is specifically targetting the component.
To ensure Cypress knows where to look for your E2E component test you will need to tweak the specPattern
in the cypress.config.ts
file. The following tells Cypress that specification files can be found within our src/app/components
folder as well as the usual cypress/e2e
folder.
import { defineConfig } from 'cypress'
export default defineConfig({
component: {
devServer: {
framework: 'next',
bundler: 'webpack',
},
},
watchForFileChanges: true,
e2e: {
setupNodeEvents(on, config) {
// implement node event listeners here
},
specPattern: [
'cypress/e2e/*.{js,ts,jsx,tsx}',
'src/app/components/**/*.e2e.cy.{js,ts,jsx,tsx}',
], // Change this to your preferred folder
},
})
Here is the initial E2E test:
//src/app/component/productsServer/productsServer.e2e.cy.ts
describe('Tests for the <ProductsServer /> component', () => {
it('is available', () => {
cy.visit(
'http://localhost:6006/iframe.html?globals=&args=&id=products-server-rendered--default&viewMode=story'
)
})
})
It’s a simple check to ensure there is something at the corresponding URL.
If you launch Cypress and navigate to ‘E2E Testing’:

You should then see the E2E test passing:

Build out E2E test and add response stubbing
Now lets add some further tests and leverage the same cy.intercept
function that we use in our client component tests in part2 of this series.
//src/app/component/productsServer/productsServer.e2e.cy.ts
const apiURL = 'https://fakestoreapi.com/products'
const componentURL =
'http://localhost:6006/iframe.html?globals=&args=&id=products-server-rendered--default&viewMode=story'
describe('Tests for the <ProductsServer /> component', () => {
beforeEach(() => {
cy.log('Adding interceptor to return stubbed data')
cy.intercept('GET', apiURL, { fixture: 'fakeProducts.json' })
cy.visit(componentURL)
})
// test that the component shows the correct header
it('renders header', () => {
cy.get('h2').should('have.text', 'Products (Server-Rendered)')
})
// test that the component renders the products
it('renders at least one item', () => {
cy.get('li').should('have.length.gt', 0)
})
// test that the component renders the product title
it('renders product title', () => {
cy.get('li')
.first()
.get('h3')
.should('exist')
.invoke('text')
.should('not.be.empty')
})
// test that the component renders the product details
it('renders product details', () => {
cy.get('li')
.find('p')
.should('exist')
.invoke('text')
.should('not.be.empty')
})
// test that the component shows an error message if the API call fails
it('shows error message if the API returns a 500 status code', () => {
// set up the API call to return a 500 status code
cy.intercept('GET', apiURL, {
statusCode: 500,
})
cy.visit(componentURL)
cy.contains('Something went wrong...').should('be.visible')
})
})
In the above you can see that we set up the cy.intercept
and also the cy.visit
calls in our beforeEach
step.
Running the test, we can see it is succeeding:

There we go a ‘sort of’ component test for our server-rendered component.
Summary
The main thing is that it works, but I can’t help feeling its a bit of a hack.
- its a lot slower than a true Cypress component test
- its involved some tricky setup and adding Storybook (which might not be desirable)
- on the plus side the test itself is very similar to the client component equivalent, same use of
cy.intercept
, so little knowledge to gain if you are already comfortable with that function.
Approach 2 - a true component test using cy.stub
and async
/await
Firstly kudos to @MuratKeremOzcan, I leaned on the approach outlined in his video.
Remember our initial error?
“Error: Objects are not valid as a React child (found: [object Promise]). If you meant to render a collection of children, use an array instead.”
In the video above by @MuratKeremOzcan he suggests solving this by using cy.stub
and async
/await
Await the loading of the component
Await the loading of the component, making the test async
and reference the <ProductsServer />
with an await
:
//src/app/components/productsServer/productsServer.cy.tsx
import { ProductsServer } from './productsServer'
describe('Tests for the <ProductsServer /> component', () => {
it('renders component', async () => {
cy.mount(await ProductsServer())
})
})
Success! The test is now passing. We are loading the component in using await ProductServer()
- this works as React components are plain old functions after all.

Use the cy.stub
function
Now for the network calls. As with our client component tests, its important for us to be able to control the data coming into our component, so we can test how the component reacts to different responses.
Lets introduce the cy.stub
function:
//src/app/components/productsServer/productsServer.cy.tsx
import { ProductsServer } from './productsServer'
import fakeProducts from '@fixtures/fakeProducts.json'
import { ProductsServer } from './productsServer'
describe('Tests for the <ProductsServer /> component', () => {
let stub: ReturnType<typeof cy.stub>
beforeEach(() => {
cy.log('Adding stub to return stubbed response')
// stub window.fetch
stub = cy.stub(window, 'fetch')
stub.resolves({
json: cy.stub().resolves(fakeProducts),
ok: true,
})
})
it('renders component', async () => {
cy.mount(await ProductsServer())
})
})
And view the test in Cypress:

Great, you can see the stubbed data is being used by the component instead of the real API response.
Where it goes wrong for me
Next lets add a further test, to check the text of the page header:
//src/app/components/productsServer/productsServer.cy.tsx
import { ProductsServer } from './productsServer'
import fakeProducts from '@fixtures/fakeProducts.json'
import { ProductsServer } from './productsServer'
describe('Tests for the <ProductsServer /> component', () => {
let stub: ReturnType<typeof cy.stub>
beforeEach(() => {
cy.log('Adding stub to return stubbed response')
// stub window.fetch
stub = cy.stub(window, 'fetch')
stub.resolves({
json: cy.stub().resolves(fakeProducts),
ok: true,
})
})
it('renders component', async () => {
cy.mount(await ProductsServer())
})
// test that the component shows the correct header
it('renders header', () => {
ProductsServer().then((component) => {
cy.mount(component)
cy.get('h2').should('have.text', 'Products (Server-Rendered)')
})
})
})
The new ‘renders header’ simply does a string match on the page heading, and it seems to pass as you would expect:

However, if I change the test to intentionally fail - checking for an incorrect heading:
//src/app/components/productsServer/productsServer.cy.tsx
import { ProductsServer } from './productsServer'
import fakeProducts from '@fixtures/fakeProducts.json'
import { ProductsServer } from './productsServer'
describe('Tests for the <ProductsServer /> component', () => {
let stub: ReturnType<typeof cy.stub>
beforeEach(() => {
cy.log('Adding stub to return stubbed response')
// stub window.fetch
stub = cy.stub(window, 'fetch')
stub.resolves({
json: cy.stub().resolves(fakeProducts),
ok: true,
})
})
it('renders component', async () => {
cy.mount(await ProductsServer())
})
// test that the component shows the correct header
it('renders header', () => {
ProductsServer().then((component) => {
cy.mount(component)
cy.get('h2').should('have.text', 'A totally different heading')
})
})
})
Take a look at the result:

The renders header
seems to pass (which is unexpected), however the assertion from the .should()
is failing (which is expected).
This is annoying, and I haven’t solved how to get past this, hence why I have settled for the E2E test approach. Cypress documentation doesn’t give me much hope, explaining that its commands run asynchronously and that “Cypress currently doesn’t support Component Testing for async Server Components. We recommend using E2E testing.”.
Try using Promises instead of async
/await
I tried the following to see if removing the async
/await
helps, but no cigar:
//src/app/components/productsServer/productsServer.tsx
import { ProductCard } from '../productCard/productCard'
import { IProduct } from '@/app/types/product'
export const ProductsServer = ({ products }: { products: IProduct[] }) => {
return (
<section>
<h2 className="text-xl pb-4">Products (Server-Rendered)</h2>
<ul>
{products && products.length === 0 && <p>No products found</p>}
<ul className="grid md:grid-cols-2">
{products &&
products.map((product) => (
<ProductCard product={product} key={product.id} />
))}
</ul>
</ul>
</section>
)
}
Other possible solutions
Move asynchronous data fetches out of the server component
This allows you to make your server component synchronous:
import { ProductCard } from '../productCard/productCard'
import { IProduct } from '@/app/types/product'
export const ProductsServer = ({ products }: { products: IProduct[] }) => {
return (
<section>
<h2 className="text-xl pb-4">Products (Server-Rendered)</h2>
<ul>
{products && products.length === 0 && <p>No products found</p>}
<ul className="grid md:grid-cols-2">
{products &&
products.map((product) => (
<ProductCard product={product} key={product.id} />
))}
</ul>
</ul>
</section>
)
}
And does mean you can run true component tests against the component:
import { ProductsServer } from './productsServer'
describe('Tests for the <ProductsServer /> component', () => {
const fakeProducts = [
{
id: 1,
title: 'Fake product',
price: '22.3',
category: "men's clothing",
description:
'Slim-fitting style, contrast raglan long sleeve, three-button henley placket, light weight & soft fabric for breathable and comfortable wearing. And Solid stitched shirts with round neck made for durability and a great fit for casual fashion wear and diehard baseball fans. The Henley style round neckline includes a three-button placket.',
image: 'https://fakestoreapi.com/img/71-3HjGNDUL._AC_SY879._SX._UX._SY._UY_.jpg',
},
]
it('renders component', async () => {
cy.mount(ProductsServer({ products: fakeProducts }))
})
// test that the component shows the correct header
it('renders header', () => {
cy.mount(ProductsServer({ products: fakeProducts }))
cy.get('h2').should('have.text', 'Products (Server-Rendered)')
})
})
The compromise is that you are changing your architecture, moving your data fetching out to a different file - which might not be desirable
Use timouts
Litter your tests with cy.wait
calls to try to get around the async issue. I dont recommend this - its extremely flaky as the timings of these tests are volatile.
import { ProductsServer } from './productsServer';
describe('Tests for the <ProductsServer /> component', () => {
it('renders component', () => {
ProductsServer().then((component) => {
cy.mount(component);
});
});
// Test that the component shows the correct header
it('renders header', () => {
ProductsServer().then((component) => {
cy.mount(component);
// Wait for the component to render before asserting
cy.get('h2', { timeout: 10000 }) // Increase timeout if necessary
.should('exist') // Ensure the element is present
.and('have.text', 'Products (Server-Rendered)');
});
});
});
Note, you can find the full code here: https://github.com/speaktosteve/nextjs-cypress-part3