Skip to content

Commit

Permalink
feat: Add testing HOC to reduce test setup verbosity (#765)
Browse files Browse the repository at this point in the history
  • Loading branch information
franky47 authored Nov 14, 2024
1 parent 8200add commit 777627e
Show file tree
Hide file tree
Showing 8 changed files with 117 additions and 75 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ package-lock.json
.next/
.turbo/
.vercel
.tsbuildinfo
*.tsbuildinfo
64 changes: 49 additions & 15 deletions packages/docs/content/docs/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ description: Some tips on testing components that use `nuqs`
---

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

## With Vitest

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

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

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

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

Expand All @@ -58,11 +55,7 @@ it('should increment the count when clicked', async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
render(<CounterButton />, {
// 1. Setup the test by passing initial search params / querystring:
wrapper: ({ children }) => (
<NuqsTestingAdapter searchParams="?count=42" onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
)
wrapper: withNuqsTestingAdapter({ searchParams: '?count=42', onUrlUpdate })
})
// 2. Act
const button = screen.getByRole('button')
Expand Down Expand Up @@ -112,3 +105,44 @@ const config: Config = {
<Callout>
Adapt accordingly for Windows with [`cross-env`](https://www.npmjs.com/package/cross-env).
</Callout>

## NuqsTestingAdapter

The `withNuqsTestingAdapter{:ts}` function is a higher-order component that
wraps your component with a `NuqsTestingAdapter{:ts}`, but you can also use
it directly.

It takes the following props:

- `searchParams{:ts}`: The initial search params to use for the test. These can be a
query string, a `URLSearchParams` object or a record object with string values.

```tsx
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'

<NuqsTestingAdapter searchParams="?q=hello&limit=10">
<NuqsTestingAdapter searchParams={new URLSearchParams("?q=hello&limit=10")}>
<NuqsTestingAdapter searchParams={{
q: 'hello',
limit: '10' // Values are serialized strings
}}>
```

- `onUrlUpdate{:ts}`, a function that will be called when the URL is updated
by the component. It receives an object with:
- the new search params as an instance of `URLSearchParams{:ts}`
- the new querystring (for convenience)
- the options used to update the URL.

<details>
<summary>🧪 Internal/advanced options</summary>

- `rateLimitFactor{:ts}`. By default, rate limiting is disabled when testing,
as it can lead to unexpected behaviours. Setting this to 1 will enable rate
limiting with the same factor as in production.

- `resetUrlUpdateQueueOnMount{:ts}`: clear the URL update queue before running the test.
This is `true{:ts}` by default to isolate tests, but you can set it to `false{:ts}` to keep the
URL update queue between renders and match the production behaviour more closely.

</details>
20 changes: 9 additions & 11 deletions packages/e2e/react-router/src/components/counter-button.test.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,27 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
import {
withNuqsTestingAdapter,
type UrlUpdateEvent
} from 'nuqs/adapters/testing'
import { describe, expect, it, vi } from 'vitest'
import { CounterButton } from './counter-button'

describe('CounterButton', () => {
it('should render the button with state loaded from the URL', () => {
render(<CounterButton />, {
wrapper: ({ children }) => (
<NuqsTestingAdapter searchParams="?count=42">
{children}
</NuqsTestingAdapter>
)
wrapper: withNuqsTestingAdapter({ searchParams: '?count=42' })
})
expect(screen.getByRole('button')).toHaveTextContent('count is 42')
})
it('should increment the count when clicked', async () => {
const user = userEvent.setup()
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
render(<CounterButton />, {
wrapper: ({ children }) => (
<NuqsTestingAdapter searchParams="?count=42" onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
)
wrapper: withNuqsTestingAdapter({
searchParams: '?count=42',
onUrlUpdate
})
})
const button = screen.getByRole('button')
await user.click(button)
Expand Down
23 changes: 8 additions & 15 deletions packages/e2e/react-router/src/components/search-input.test.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
import {
withNuqsTestingAdapter,
type UrlUpdateEvent
} from 'nuqs/adapters/testing'
import { describe, expect, it, vi } from 'vitest'
import { SearchInput } from './search-input'

describe('SearchInput', () => {
it('should render the input with state loaded from the URL', () => {
render(<SearchInput />, {
wrapper: ({ children }) => (
<NuqsTestingAdapter
searchParams={{
search: 'nuqs'
}}
>
{children}
</NuqsTestingAdapter>
)
wrapper: withNuqsTestingAdapter({ searchParams: { search: 'nuqs' } })
})
const input = screen.getByRole('search')
expect(input).toHaveValue('nuqs')
Expand All @@ -24,11 +19,9 @@ describe('SearchInput', () => {
const user = userEvent.setup()
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
render(<SearchInput />, {
wrapper: ({ children }) => (
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate} rateLimitFactor={0}>
{children}
</NuqsTestingAdapter>
)
wrapper: withNuqsTestingAdapter({
onUrlUpdate
})
})
const expectedState = 'Hello, world!'
const expectedParam = 'Hello,+world!'
Expand Down
20 changes: 9 additions & 11 deletions packages/e2e/react/src/components/counter-button.test.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,27 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
import {
withNuqsTestingAdapter,
type UrlUpdateEvent
} from 'nuqs/adapters/testing'
import { describe, expect, it, vi } from 'vitest'
import { CounterButton } from './counter-button'

describe('CounterButton', () => {
it('should render the button with state loaded from the URL', () => {
render(<CounterButton />, {
wrapper: ({ children }) => (
<NuqsTestingAdapter searchParams="?count=42">
{children}
</NuqsTestingAdapter>
)
wrapper: withNuqsTestingAdapter({ searchParams: '?count=42' })
})
expect(screen.getByRole('button')).toHaveTextContent('count is 42')
})
it('should increment the count when clicked', async () => {
const user = userEvent.setup()
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
render(<CounterButton />, {
wrapper: ({ children }) => (
<NuqsTestingAdapter searchParams="?count=42" onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
)
wrapper: withNuqsTestingAdapter({
searchParams: '?count=42',
onUrlUpdate
})
})
const button = screen.getByRole('button')
await user.click(button)
Expand Down
23 changes: 8 additions & 15 deletions packages/e2e/react/src/components/search-input.test.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
import {
withNuqsTestingAdapter,
type UrlUpdateEvent
} from 'nuqs/adapters/testing'
import { describe, expect, it, vi } from 'vitest'
import { SearchInput } from './search-input'

describe('SearchInput', () => {
it('should render the input with state loaded from the URL', () => {
render(<SearchInput />, {
wrapper: ({ children }) => (
<NuqsTestingAdapter
searchParams={{
search: 'nuqs'
}}
>
{children}
</NuqsTestingAdapter>
)
wrapper: withNuqsTestingAdapter({ searchParams: { search: 'nuqs' } })
})
const input = screen.getByRole('search')
expect(input).toHaveValue('nuqs')
Expand All @@ -24,11 +19,9 @@ describe('SearchInput', () => {
const user = userEvent.setup()
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
render(<SearchInput />, {
wrapper: ({ children }) => (
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate} rateLimitFactor={0}>
{children}
</NuqsTestingAdapter>
)
wrapper: withNuqsTestingAdapter({
onUrlUpdate
})
})
const expectedState = 'Hello, world!'
const expectedParam = 'Hello,+world!'
Expand Down
30 changes: 30 additions & 0 deletions packages/nuqs/src/adapters/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,33 @@ export function NuqsTestingAdapter({
props.children
)
}

/**
* A higher order component that wraps the children with the NuqsTestingAdapter
*
* It allows creating wrappers for testing purposes by providing only the
* necessary props to the NuqsTestingAdapter.
*
* Usage:
* ```tsx
* render(<MyComponent />, {
* wrapper: withNuqsTestingAdapter({ searchParams: '?foo=bar' })
* })
* ```
*/
export function withNuqsTestingAdapter(
props: Omit<TestingAdapterProps, 'children'> = {}
) {
return function NuqsTestingAdapterWrapper({
children
}: {
children: ReactNode
}) {
return createElement(
NuqsTestingAdapter,
// @ts-expect-error - Ignore missing children error
props,
children
)
}
}
10 changes: 3 additions & 7 deletions packages/nuqs/src/sync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'
import { describe, expect, it } from 'vitest'
import { NuqsTestingAdapter } from './adapters/testing'
import { withNuqsTestingAdapter } from './adapters/testing'
import { parseAsInteger, useQueryState, useQueryStates } from './index'

type TestComponentProps = {
Expand Down Expand Up @@ -30,9 +30,7 @@ describe('sync', () => {
<TestComponent testId="b" />
</>,
{
wrapper: ({ children }) => (
<NuqsTestingAdapter>{children}</NuqsTestingAdapter>
)
wrapper: withNuqsTestingAdapter()
}
)
// Act
Expand Down Expand Up @@ -79,9 +77,7 @@ describe('sync', () => {
<TestComponentB testId="b" />
</>,
{
wrapper: ({ children }) => (
<NuqsTestingAdapter>{children}</NuqsTestingAdapter>
)
wrapper: withNuqsTestingAdapter()
}
)
// Act
Expand Down

0 comments on commit 777627e

Please sign in to comment.