This is the fourth part in a series were we look into using and testing the newsearchParams
interface inNext 15
. In the first part we explained what changed inNext 15
and what the difference is between synchronous and asynchronoussearchParams
. In the second part we quickly went over the code for a little example app. In the 3rd part we setupJest
and@testing-library/react
witheslint
plugins.
In this part we're going to start testing. We start of with testing thesearchParams
prop in the page route component.
Note: this code is available in agithub repo.
page Component
Let's look at our page component:
// src/app/list/page.tsximportListfrom'@/components/List';importListControlesfrom'@/components/ListControles';importvalidateSortOrderfrom'@/lib/validateSortOrder';typeProps={searchParams:Promise<{[key:string]:string|string[]|undefined}>;};constITEMS=['apple','banana','cherry','lemon'];exportdefaultasyncfunctionListPage({searchParams}:Props){constcurrSearchParams=awaitsearchParams;constsortOrder=validateSortOrder(currSearchParams.sortOrder);return(<><h2className='text-2xl font-bold mb-2'>List</h2><ListControles/><Listitems={ITEMS}sortOrder={sortOrder}/></>);}
This is more a container component but it is wheresearchParams
is passed. The component receivessearchParams
, validates it assortOrder
and then passes it to<List />
.
We're doing a unit test here. We want to test<ListPage />
in isolation. That means we have to mock thevalidateSortOrder
function and the<ListControles />
and<List />
components. Let's start with that.
Mocking
We write a testfile, import everything and then mock said 3 files:
// src/app/list/__tests__/page.test.tsximport{screen,render}from'@testing-library/react';importListPagefrom'../page';importvalidateSortOrderfrom'@/lib/validateSortOrder';importListfrom'@/components/List';importListControlesfrom'@/components/ListControles';jest.mock('@/lib/validateSortOrder');jest.mock('@/components/List');jest.mock('@/components/ListControles');describe('<ListPage />',()=>{});
Rendering async components in Jest
Before we continue we need to talk about how you render asynchronous server components inJest
.
We have a<Home />
component to test this on. Let's make that async:
// src/app/page.tsxexportdefaultasyncfunctionHome(){return<div>hello world</div>;}
We already wrote a test for this component in a previous part when we were setting upJest
:
// src/app/__tests__/page.test.tsximport{screen,render}from'@testing-library/react';importHomefrom'@/app/page';describe('<Home />',()=>{test('It renders',()=>{render(<Home/>);expect(screen.getByText(/hello world/i)).toBeInTheDocument();});});
It worked before we made<Home />
async, let's run it again. It errors with some confusing messages:
...A suspended resource finished loading inside a test, but the event was not wrapped in act(...).When testing, code that resolves suspended data should be wrapped into act(...):act(() => { /* finish loading suspended data */});/* assert on the output */...
There is more but it doesn't really matter. The thing is that the test doesn't run anymore.
I looked into this, tried and tested different methods likeact
,waitFor
,await findBy...
and more. Nothing worked. It seems thatJest
is not equipped to handle async server components.
But we can make it work:
// src/app/__tests__/page.test.tsximport{screen,render}from'@testing-library/react';importHomefrom'@/app/page';describe('<Home />',()=>{test('It renders',async()=>{constcomponent=awaitHome();render(component);expect(screen.getByText(/hello world/i)).toBeInTheDocument();});});
We called<Home />
as a function. In case you don't know this, any functional component can simply be called as a function. We also await the function (made the entire test async) and assign it to aconst
. Finally, we render ourconst
component.
Test passes, everything works. This seems to be the only way to make async server components work inJest
so we will use it. Please comment if you know a better way!
async props
Remember,searchParams
has to be async. If not we could just do this:
// does not workconstcomponent=awaitListPage({searchParams:{sortOrder:'asc'}});render(component);
Immediately, TypeScript will yell at us because the type doesn't match. This is the type we set on searchParams:
typeProps={searchParams:Promise<{[key:string]:string|string[]|undefined}>;};
It's a promise that resolves into an object. The value for each property of this object has to be string, an array of strings or undefined. So, we're missing the promise part.
How do we do that then? We could just manually write a promise:
// write promiseconstpromise=newPromise<{[key:string]:string|string[]|undefined;}>((res)=>{res({sortOrder:'asc'});});// pass it to <ListPage />constcomponent=awaitListPage({searchParams:promise});render(component);
This works but there is a simpler way. Async functions also return a promise:
asyncfunctiongenerateSearchParams(value:{[key:string]:string|string[]|undefined;}){returnvalue;}
We can then call this function like this:
constparams={sortOrder:'asc',};constcomponent=awaitListPage({searchParams:generateSearchParams(params),});render(component);
This might be a bit confusing, I know, let me recap. We need to call<ListPage />
with an asyncsearchParams
prop. So we created an async function that just returns an object it gets passed as argument. Since it's an async function, it returns said object inside a promise. Exactly what we needed.
testing<ListPage />
Now that everything is setup, let's actually test our component.
// src/app/list/page.tsximportListfrom'@/components/List';importListControlesfrom'@/components/ListControles';importvalidateSortOrderfrom'@/lib/validateSortOrder';typeProps={searchParams:Promise<{[key:string]:string|string[]|undefined}>;};constITEMS=['apple','banana','cherry','lemon'];exportdefaultasyncfunctionListPage({searchParams}:Props){constcurrSearchParams=awaitsearchParams;constsortOrder=validateSortOrder(currSearchParams.sortOrder);return(<><h2className='text-2xl font-bold mb-2'>List</h2><ListControles/><Listitems={ITEMS}sortOrder={sortOrder}/></>);}
We first write anit renders
test:
// test passestest('It renders',async()=>{constparams={sortOrder:'asc',};constcomponent=awaitListPage({searchParams:generateSearchParams(params),});render(component);expect(validateSortOrder).toHaveBeenCalled();expect(screen.getByRole('heading',{level:2})).toHaveTextContent(/list/i);expect(ListControles).toHaveBeenCalled();expect(List).toHaveBeenCalled();});
We already mockedvalidateSortOrder
so that will now returnundefined
. But, we can use.toHaveBeenCalledWith
on thevalidateSortOrder
mock to verify that our searchParams.sortOrder value is correctly passed.
Note that this is coreNextJs
behavior and we shouldn't really test this. It's aNext
thing and should work. We will just do it here to prove our setup works.
// test passestest('searchParams is correctly passed down',async()=>{constparams={sortOrder:'asc',};constcomponent=awaitListPage({searchParams:generateSearchParams(params),});render(component);expect(validateSortOrder).toHaveBeenCalledWith('asc');});
Finally, should we test with different sortOrder params? No. Why not? BecausevalidateSortOrder
handles this. The function returns eitherdesc
orasc
(as default or when sortOrder=asc). But, we mockedvalidateSortOrder
. So it doesn't matter. Maybe one last test, what happens when there is no sortOrder param?
// test passestest('It calls validateSortOrder with undefined when searchParams.sortOrder does not exist',async()=>{constparams={};constcomponent=awaitListPage({searchParams:generateSearchParams(params),});render(component);expect(validateSortOrder).toHaveBeenCalledWith(undefined);});
And that's all we need to test<ListPage />
. Here is our final full test file:
// src/app/list/page.tsx// tests passimport{screen,render}from'@testing-library/react';importListPagefrom'../page';importvalidateSortOrderfrom'@/lib/validateSortOrder';importListfrom'@/components/List';importListControlesfrom'@/components/ListControles';jest.mock('@/lib/validateSortOrder');jest.mock('@/components/List');jest.mock('@/components/ListControles');asyncfunctiongenerateSearchParams(value:{[key:string]:string|string[]|undefined;}){returnvalue;}describe('<ListPage />',()=>{test('It renders',async()=>{constparams={sortOrder:'asc',};constcomponent=awaitListPage({searchParams:generateSearchParams(params),});render(component);expect(validateSortOrder).toHaveBeenCalled();expect(screen.getByRole('heading',{level:2})).toHaveTextContent(/list/i);expect(ListControles).toHaveBeenCalled();expect(List).toHaveBeenCalled();});test('searchParams is correctly passed down',async()=>{constparams={sortOrder:'asc',};constcomponent=awaitListPage({searchParams:generateSearchParams(params),});render(component);expect(validateSortOrder).toHaveBeenCalledWith('asc');});test('It calls validateSortOrder with undefined when searchParams.sortOrder does not exist',async()=>{constparams={};constcomponent=awaitListPage({searchParams:generateSearchParams(params),});render(component);expect(validateSortOrder).toHaveBeenCalledWith(undefined);});});
Conclusion
Mainly, we learned how to render an async component inJest
and how to pass an async prop to an async component. All and all it's not very hard, just a bit cumbersome.
Our little app has some more components and functions but only<ListControles />
matters for this series because it usesuseSearchParams
,usePathname
anduseRouter
. In the next part I will show how to mock and test this component.
I wrote some tests for the other components too but I won't go into to those. You can see the tests in therepo.
If you want to support my writing, you candonate with paypal.
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse