Skip to content

Commit 777627e

Browse files
authored
feat: Add testing HOC to reduce test setup verbosity (#765)
1 parent 8200add commit 777627e

File tree

8 files changed

+117
-75
lines changed

8 files changed

+117
-75
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ package-lock.json
99
.next/
1010
.turbo/
1111
.vercel
12-
.tsbuildinfo
12+
*.tsbuildinfo

packages/docs/content/docs/testing.mdx

+49-15
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ description: Some tips on testing components that use `nuqs`
44
---
55

66
Since nuqs 2, you can unit-test components that use `useQueryState(s){:ts}` hooks
7-
by wrapping your rendered component in a `NuqsTestingAdapter{:ts}`.
7+
by wrapping your rendered component in a `NuqsTestingAdapter{:ts}`, or using
8+
the `withNuqsTestingAdapter{:ts}` higher-order component.
89

910
## With Vitest
1011

@@ -14,10 +15,10 @@ a counter:
1415
<Tabs items={['Vitest v1', 'Vitest v2']}>
1516

1617
```tsx title="counter-button.test.tsx" tab="Vitest v1"
17-
// [!code word:NuqsTestingAdapter]
18+
// [!code word:withNuqsTestingAdapter]
1819
import { render, screen } from '@testing-library/react'
1920
import userEvent from '@testing-library/user-event'
20-
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
21+
import { withNuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
2122
import { describe, expect, it, vi } from 'vitest'
2223
import { CounterButton } from './counter-button'
2324

@@ -26,11 +27,7 @@ it('should increment the count when clicked', async () => {
2627
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
2728
render(<CounterButton />, {
2829
// 1. Setup the test by passing initial search params / querystring:
29-
wrapper: ({ children }) => (
30-
<NuqsTestingAdapter searchParams="?count=42" onUrlUpdate={onUrlUpdate}>
31-
{children}
32-
</NuqsTestingAdapter>
33-
)
30+
wrapper: withNuqsTestingAdapter({ searchParams: '?count=42', onUrlUpdate })
3431
})
3532
// 2. Act
3633
const button = screen.getByRole('button')
@@ -46,10 +43,10 @@ it('should increment the count when clicked', async () => {
4643
```
4744

4845
```tsx title="counter-button.test.tsx" tab="Vitest v2"
49-
// [!code word:NuqsTestingAdapter]
46+
// [!code word:withNuqsTestingAdapter]
5047
import { render, screen } from '@testing-library/react'
5148
import userEvent from '@testing-library/user-event'
52-
import { NuqsTestingAdapter, type OnUrlUpdateFunction } from 'nuqs/adapters/testing'
49+
import { withNuqsTestingAdapter, type OnUrlUpdateFunction } from 'nuqs/adapters/testing'
5350
import { describe, expect, it, vi } from 'vitest'
5451
import { CounterButton } from './counter-button'
5552

@@ -58,11 +55,7 @@ it('should increment the count when clicked', async () => {
5855
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
5956
render(<CounterButton />, {
6057
// 1. Setup the test by passing initial search params / querystring:
61-
wrapper: ({ children }) => (
62-
<NuqsTestingAdapter searchParams="?count=42" onUrlUpdate={onUrlUpdate}>
63-
{children}
64-
</NuqsTestingAdapter>
65-
)
58+
wrapper: withNuqsTestingAdapter({ searchParams: '?count=42', onUrlUpdate })
6659
})
6760
// 2. Act
6861
const button = screen.getByRole('button')
@@ -112,3 +105,44 @@ const config: Config = {
112105
<Callout>
113106
Adapt accordingly for Windows with [`cross-env`](https://www.npmjs.com/package/cross-env).
114107
</Callout>
108+
109+
## NuqsTestingAdapter
110+
111+
The `withNuqsTestingAdapter{:ts}` function is a higher-order component that
112+
wraps your component with a `NuqsTestingAdapter{:ts}`, but you can also use
113+
it directly.
114+
115+
It takes the following props:
116+
117+
- `searchParams{:ts}`: The initial search params to use for the test. These can be a
118+
query string, a `URLSearchParams` object or a record object with string values.
119+
120+
```tsx
121+
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
122+
123+
<NuqsTestingAdapter searchParams="?q=hello&limit=10">
124+
<NuqsTestingAdapter searchParams={new URLSearchParams("?q=hello&limit=10")}>
125+
<NuqsTestingAdapter searchParams={{
126+
q: 'hello',
127+
limit: '10' // Values are serialized strings
128+
}}>
129+
```
130+
131+
- `onUrlUpdate{:ts}`, a function that will be called when the URL is updated
132+
by the component. It receives an object with:
133+
- the new search params as an instance of `URLSearchParams{:ts}`
134+
- the new querystring (for convenience)
135+
- the options used to update the URL.
136+
137+
<details>
138+
<summary>🧪 Internal/advanced options</summary>
139+
140+
- `rateLimitFactor{:ts}`. By default, rate limiting is disabled when testing,
141+
as it can lead to unexpected behaviours. Setting this to 1 will enable rate
142+
limiting with the same factor as in production.
143+
144+
- `resetUrlUpdateQueueOnMount{:ts}`: clear the URL update queue before running the test.
145+
This is `true{:ts}` by default to isolate tests, but you can set it to `false{:ts}` to keep the
146+
URL update queue between renders and match the production behaviour more closely.
147+
148+
</details>

packages/e2e/react-router/src/components/counter-button.test.tsx

+9-11
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,27 @@
11
import { render, screen } from '@testing-library/react'
22
import userEvent from '@testing-library/user-event'
3-
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
3+
import {
4+
withNuqsTestingAdapter,
5+
type UrlUpdateEvent
6+
} from 'nuqs/adapters/testing'
47
import { describe, expect, it, vi } from 'vitest'
58
import { CounterButton } from './counter-button'
69

710
describe('CounterButton', () => {
811
it('should render the button with state loaded from the URL', () => {
912
render(<CounterButton />, {
10-
wrapper: ({ children }) => (
11-
<NuqsTestingAdapter searchParams="?count=42">
12-
{children}
13-
</NuqsTestingAdapter>
14-
)
13+
wrapper: withNuqsTestingAdapter({ searchParams: '?count=42' })
1514
})
1615
expect(screen.getByRole('button')).toHaveTextContent('count is 42')
1716
})
1817
it('should increment the count when clicked', async () => {
1918
const user = userEvent.setup()
2019
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
2120
render(<CounterButton />, {
22-
wrapper: ({ children }) => (
23-
<NuqsTestingAdapter searchParams="?count=42" onUrlUpdate={onUrlUpdate}>
24-
{children}
25-
</NuqsTestingAdapter>
26-
)
21+
wrapper: withNuqsTestingAdapter({
22+
searchParams: '?count=42',
23+
onUrlUpdate
24+
})
2725
})
2826
const button = screen.getByRole('button')
2927
await user.click(button)

packages/e2e/react-router/src/components/search-input.test.tsx

+8-15
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,16 @@
11
import { render, screen } from '@testing-library/react'
22
import userEvent from '@testing-library/user-event'
3-
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
3+
import {
4+
withNuqsTestingAdapter,
5+
type UrlUpdateEvent
6+
} from 'nuqs/adapters/testing'
47
import { describe, expect, it, vi } from 'vitest'
58
import { SearchInput } from './search-input'
69

710
describe('SearchInput', () => {
811
it('should render the input with state loaded from the URL', () => {
912
render(<SearchInput />, {
10-
wrapper: ({ children }) => (
11-
<NuqsTestingAdapter
12-
searchParams={{
13-
search: 'nuqs'
14-
}}
15-
>
16-
{children}
17-
</NuqsTestingAdapter>
18-
)
13+
wrapper: withNuqsTestingAdapter({ searchParams: { search: 'nuqs' } })
1914
})
2015
const input = screen.getByRole('search')
2116
expect(input).toHaveValue('nuqs')
@@ -24,11 +19,9 @@ describe('SearchInput', () => {
2419
const user = userEvent.setup()
2520
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
2621
render(<SearchInput />, {
27-
wrapper: ({ children }) => (
28-
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate} rateLimitFactor={0}>
29-
{children}
30-
</NuqsTestingAdapter>
31-
)
22+
wrapper: withNuqsTestingAdapter({
23+
onUrlUpdate
24+
})
3225
})
3326
const expectedState = 'Hello, world!'
3427
const expectedParam = 'Hello,+world!'

packages/e2e/react/src/components/counter-button.test.tsx

+9-11
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,27 @@
11
import { render, screen } from '@testing-library/react'
22
import userEvent from '@testing-library/user-event'
3-
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
3+
import {
4+
withNuqsTestingAdapter,
5+
type UrlUpdateEvent
6+
} from 'nuqs/adapters/testing'
47
import { describe, expect, it, vi } from 'vitest'
58
import { CounterButton } from './counter-button'
69

710
describe('CounterButton', () => {
811
it('should render the button with state loaded from the URL', () => {
912
render(<CounterButton />, {
10-
wrapper: ({ children }) => (
11-
<NuqsTestingAdapter searchParams="?count=42">
12-
{children}
13-
</NuqsTestingAdapter>
14-
)
13+
wrapper: withNuqsTestingAdapter({ searchParams: '?count=42' })
1514
})
1615
expect(screen.getByRole('button')).toHaveTextContent('count is 42')
1716
})
1817
it('should increment the count when clicked', async () => {
1918
const user = userEvent.setup()
2019
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
2120
render(<CounterButton />, {
22-
wrapper: ({ children }) => (
23-
<NuqsTestingAdapter searchParams="?count=42" onUrlUpdate={onUrlUpdate}>
24-
{children}
25-
</NuqsTestingAdapter>
26-
)
21+
wrapper: withNuqsTestingAdapter({
22+
searchParams: '?count=42',
23+
onUrlUpdate
24+
})
2725
})
2826
const button = screen.getByRole('button')
2927
await user.click(button)

packages/e2e/react/src/components/search-input.test.tsx

+8-15
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,16 @@
11
import { render, screen } from '@testing-library/react'
22
import userEvent from '@testing-library/user-event'
3-
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
3+
import {
4+
withNuqsTestingAdapter,
5+
type UrlUpdateEvent
6+
} from 'nuqs/adapters/testing'
47
import { describe, expect, it, vi } from 'vitest'
58
import { SearchInput } from './search-input'
69

710
describe('SearchInput', () => {
811
it('should render the input with state loaded from the URL', () => {
912
render(<SearchInput />, {
10-
wrapper: ({ children }) => (
11-
<NuqsTestingAdapter
12-
searchParams={{
13-
search: 'nuqs'
14-
}}
15-
>
16-
{children}
17-
</NuqsTestingAdapter>
18-
)
13+
wrapper: withNuqsTestingAdapter({ searchParams: { search: 'nuqs' } })
1914
})
2015
const input = screen.getByRole('search')
2116
expect(input).toHaveValue('nuqs')
@@ -24,11 +19,9 @@ describe('SearchInput', () => {
2419
const user = userEvent.setup()
2520
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
2621
render(<SearchInput />, {
27-
wrapper: ({ children }) => (
28-
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate} rateLimitFactor={0}>
29-
{children}
30-
</NuqsTestingAdapter>
31-
)
22+
wrapper: withNuqsTestingAdapter({
23+
onUrlUpdate
24+
})
3225
})
3326
const expectedState = 'Hello, world!'
3427
const expectedParam = 'Hello,+world!'

packages/nuqs/src/adapters/testing.ts

+30
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,33 @@ export function NuqsTestingAdapter({
4444
props.children
4545
)
4646
}
47+
48+
/**
49+
* A higher order component that wraps the children with the NuqsTestingAdapter
50+
*
51+
* It allows creating wrappers for testing purposes by providing only the
52+
* necessary props to the NuqsTestingAdapter.
53+
*
54+
* Usage:
55+
* ```tsx
56+
* render(<MyComponent />, {
57+
* wrapper: withNuqsTestingAdapter({ searchParams: '?foo=bar' })
58+
* })
59+
* ```
60+
*/
61+
export function withNuqsTestingAdapter(
62+
props: Omit<TestingAdapterProps, 'children'> = {}
63+
) {
64+
return function NuqsTestingAdapterWrapper({
65+
children
66+
}: {
67+
children: ReactNode
68+
}) {
69+
return createElement(
70+
NuqsTestingAdapter,
71+
// @ts-expect-error - Ignore missing children error
72+
props,
73+
children
74+
)
75+
}
76+
}

packages/nuqs/src/sync.test.tsx

+3-7
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
22
import userEvent from '@testing-library/user-event'
33
import React from 'react'
44
import { describe, expect, it } from 'vitest'
5-
import { NuqsTestingAdapter } from './adapters/testing'
5+
import { withNuqsTestingAdapter } from './adapters/testing'
66
import { parseAsInteger, useQueryState, useQueryStates } from './index'
77

88
type TestComponentProps = {
@@ -30,9 +30,7 @@ describe('sync', () => {
3030
<TestComponent testId="b" />
3131
</>,
3232
{
33-
wrapper: ({ children }) => (
34-
<NuqsTestingAdapter>{children}</NuqsTestingAdapter>
35-
)
33+
wrapper: withNuqsTestingAdapter()
3634
}
3735
)
3836
// Act
@@ -79,9 +77,7 @@ describe('sync', () => {
7977
<TestComponentB testId="b" />
8078
</>,
8179
{
82-
wrapper: ({ children }) => (
83-
<NuqsTestingAdapter>{children}</NuqsTestingAdapter>
84-
)
80+
wrapper: withNuqsTestingAdapter()
8581
}
8682
)
8783
// Act

0 commit comments

Comments
 (0)