Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add testing HOC to reduce test setup verbosity #765

Merged
merged 5 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading