From a61adf66244a151e906f9136133bb1e39efc652a Mon Sep 17 00:00:00 2001 From: Francois Best Date: Thu, 14 Nov 2024 13:50:21 +0100 Subject: [PATCH 1/5] chore: Ignore all tsbuildinfo --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 918e298f9..c5ef2c664 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,4 @@ package-lock.json .next/ .turbo/ .vercel -.tsbuildinfo +*.tsbuildinfo From d6c77bb915dc70231541add6024a33a26d220997 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Thu, 14 Nov 2024 13:50:43 +0100 Subject: [PATCH 2/5] feat: Add testing HOC to reduce test setup verbosity --- packages/docs/content/docs/testing.mdx | 17 +++++++++++++++ packages/nuqs/src/adapters/testing.ts | 30 ++++++++++++++++++++++++++ packages/nuqs/src/sync.test.tsx | 10 +++------ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/docs/content/docs/testing.mdx b/packages/docs/content/docs/testing.mdx index 7c1c8bd32..bb54f3274 100644 --- a/packages/docs/content/docs/testing.mdx +++ b/packages/docs/content/docs/testing.mdx @@ -112,3 +112,20 @@ const config: Config = { Adapt accordingly for Windows with [`cross-env`](https://www.npmjs.com/package/cross-env). + +## Higher-order component + +Creating a wrapper component to pass props to the `NuqsTestingAdapter{:ts}` +can be verbose as your test suite grows. + +You can use the `withNuqsTestingAdapter{:ts}` function to pass the right props, +and it will return a wrapper component: + +```tsx +// [!code word:withNuqsTestingAdapter] +import { withNuqsTestingAdapter } from 'nuqs/adapters/testing' + +render(, { + wrapper: withNuqsTestingAdapter({ searchParams: '?count=42', onUrlUpdate }) +}) +``` diff --git a/packages/nuqs/src/adapters/testing.ts b/packages/nuqs/src/adapters/testing.ts index 80599e09f..21b40a6a6 100644 --- a/packages/nuqs/src/adapters/testing.ts +++ b/packages/nuqs/src/adapters/testing.ts @@ -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(, { + * wrapper: withNuqsTestingAdapter({ searchParams: '?foo=bar' }) + * }) + * ``` + */ +export function withNuqsTestingAdapter( + props: Omit = {} +) { + return function NuqsTestingAdapterWrapper({ + children + }: { + children: ReactNode + }) { + return createElement( + NuqsTestingAdapter, + // @ts-expect-error - Ignore missing children error + props, + children + ) + } +} diff --git a/packages/nuqs/src/sync.test.tsx b/packages/nuqs/src/sync.test.tsx index 18c6e3123..d0e332110 100644 --- a/packages/nuqs/src/sync.test.tsx +++ b/packages/nuqs/src/sync.test.tsx @@ -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 = { @@ -30,9 +30,7 @@ describe('sync', () => { , { - wrapper: ({ children }) => ( - {children} - ) + wrapper: withNuqsTestingAdapter() } ) // Act @@ -79,9 +77,7 @@ describe('sync', () => { , { - wrapper: ({ children }) => ( - {children} - ) + wrapper: withNuqsTestingAdapter() } ) // Act From 4c7d325eb2124d221ff4445d741d504186c7cef1 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Thu, 14 Nov 2024 14:00:53 +0100 Subject: [PATCH 3/5] test: Use HOC in tests --- .../src/components/counter-button.test.tsx | 20 ++++++++-------- .../src/components/search-input.test.tsx | 23 +++++++------------ .../src/components/counter-button.test.tsx | 20 ++++++++-------- .../src/components/search-input.test.tsx | 23 +++++++------------ 4 files changed, 34 insertions(+), 52 deletions(-) diff --git a/packages/e2e/react-router/src/components/counter-button.test.tsx b/packages/e2e/react-router/src/components/counter-button.test.tsx index f95a24876..982845500 100644 --- a/packages/e2e/react-router/src/components/counter-button.test.tsx +++ b/packages/e2e/react-router/src/components/counter-button.test.tsx @@ -1,17 +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 { CounterButton } from './counter-button' describe('CounterButton', () => { it('should render the button with state loaded from the URL', () => { render(, { - wrapper: ({ children }) => ( - - {children} - - ) + wrapper: withNuqsTestingAdapter({ searchParams: '?count=42' }) }) expect(screen.getByRole('button')).toHaveTextContent('count is 42') }) @@ -19,11 +18,10 @@ describe('CounterButton', () => { const user = userEvent.setup() const onUrlUpdate = vi.fn<[UrlUpdateEvent]>() render(, { - wrapper: ({ children }) => ( - - {children} - - ) + wrapper: withNuqsTestingAdapter({ + searchParams: '?count=42', + onUrlUpdate + }) }) const button = screen.getByRole('button') await user.click(button) diff --git a/packages/e2e/react-router/src/components/search-input.test.tsx b/packages/e2e/react-router/src/components/search-input.test.tsx index db9a450ee..576bd4120 100644 --- a/packages/e2e/react-router/src/components/search-input.test.tsx +++ b/packages/e2e/react-router/src/components/search-input.test.tsx @@ -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(, { - wrapper: ({ children }) => ( - - {children} - - ) + wrapper: withNuqsTestingAdapter({ searchParams: { search: 'nuqs' } }) }) const input = screen.getByRole('search') expect(input).toHaveValue('nuqs') @@ -24,11 +19,9 @@ describe('SearchInput', () => { const user = userEvent.setup() const onUrlUpdate = vi.fn<[UrlUpdateEvent]>() render(, { - wrapper: ({ children }) => ( - - {children} - - ) + wrapper: withNuqsTestingAdapter({ + onUrlUpdate + }) }) const expectedState = 'Hello, world!' const expectedParam = 'Hello,+world!' diff --git a/packages/e2e/react/src/components/counter-button.test.tsx b/packages/e2e/react/src/components/counter-button.test.tsx index f95a24876..982845500 100644 --- a/packages/e2e/react/src/components/counter-button.test.tsx +++ b/packages/e2e/react/src/components/counter-button.test.tsx @@ -1,17 +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 { CounterButton } from './counter-button' describe('CounterButton', () => { it('should render the button with state loaded from the URL', () => { render(, { - wrapper: ({ children }) => ( - - {children} - - ) + wrapper: withNuqsTestingAdapter({ searchParams: '?count=42' }) }) expect(screen.getByRole('button')).toHaveTextContent('count is 42') }) @@ -19,11 +18,10 @@ describe('CounterButton', () => { const user = userEvent.setup() const onUrlUpdate = vi.fn<[UrlUpdateEvent]>() render(, { - wrapper: ({ children }) => ( - - {children} - - ) + wrapper: withNuqsTestingAdapter({ + searchParams: '?count=42', + onUrlUpdate + }) }) const button = screen.getByRole('button') await user.click(button) diff --git a/packages/e2e/react/src/components/search-input.test.tsx b/packages/e2e/react/src/components/search-input.test.tsx index db9a450ee..576bd4120 100644 --- a/packages/e2e/react/src/components/search-input.test.tsx +++ b/packages/e2e/react/src/components/search-input.test.tsx @@ -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(, { - wrapper: ({ children }) => ( - - {children} - - ) + wrapper: withNuqsTestingAdapter({ searchParams: { search: 'nuqs' } }) }) const input = screen.getByRole('search') expect(input).toHaveValue('nuqs') @@ -24,11 +19,9 @@ describe('SearchInput', () => { const user = userEvent.setup() const onUrlUpdate = vi.fn<[UrlUpdateEvent]>() render(, { - wrapper: ({ children }) => ( - - {children} - - ) + wrapper: withNuqsTestingAdapter({ + onUrlUpdate + }) }) const expectedState = 'Hello, world!' const expectedParam = 'Hello,+world!' From 7fcff7cba92dd86aced8c006ca18ca9f175b5df0 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Thu, 14 Nov 2024 14:12:21 +0100 Subject: [PATCH 4/5] doc: Flip the HOC/manual order --- packages/docs/content/docs/testing.mdx | 48 ++++++++++++-------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/packages/docs/content/docs/testing.mdx b/packages/docs/content/docs/testing.mdx index bb54f3274..b6538e54f 100644 --- a/packages/docs/content/docs/testing.mdx +++ b/packages/docs/content/docs/testing.mdx @@ -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 @@ -14,10 +15,10 @@ a counter: ```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' @@ -26,11 +27,7 @@ it('should increment the count when clicked', async () => { const onUrlUpdate = vi.fn<[UrlUpdateEvent]>() render(, { // 1. Setup the test by passing initial search params / querystring: - wrapper: ({ children }) => ( - - {children} - - ) + wrapper: withNuqsTestingAdapter({ searchParams: '?count=42', onUrlUpdate }) }) // 2. Act const button = screen.getByRole('button') @@ -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' @@ -58,11 +55,7 @@ it('should increment the count when clicked', async () => { const onUrlUpdate = vi.fn() render(, { // 1. Setup the test by passing initial search params / querystring: - wrapper: ({ children }) => ( - - {children} - - ) + wrapper: withNuqsTestingAdapter({ searchParams: '?count=42', onUrlUpdate }) }) // 2. Act const button = screen.getByRole('button') @@ -113,19 +106,22 @@ const config: Config = { Adapt accordingly for Windows with [`cross-env`](https://www.npmjs.com/package/cross-env). -## Higher-order component +## NuqsTestingAdapter -Creating a wrapper component to pass props to the `NuqsTestingAdapter{:ts}` -can be verbose as your test suite grows. +The `withNuqsTestingAdapter{:ts}` function is a higher-order component that +wraps your component with a `NuqsTestingAdapter{:ts}`, but you can also use +it directly. -You can use the `withNuqsTestingAdapter{:ts}` function to pass the right props, -and it will return a wrapper component: +It takes the following props: -```tsx -// [!code word:withNuqsTestingAdapter] -import { withNuqsTestingAdapter } from 'nuqs/adapters/testing' +- `searchParams`: 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. -render(, { - wrapper: withNuqsTestingAdapter({ searchParams: '?count=42', onUrlUpdate }) -}) +```tsx + + + ``` From ee053e4adfc5e7320fa21499579f733fa7464cb7 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Thu, 14 Nov 2024 14:30:53 +0100 Subject: [PATCH 5/5] doc: Details for NuqsTestingAdapter props --- packages/docs/content/docs/testing.mdx | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/docs/content/docs/testing.mdx b/packages/docs/content/docs/testing.mdx index b6538e54f..289af89fa 100644 --- a/packages/docs/content/docs/testing.mdx +++ b/packages/docs/content/docs/testing.mdx @@ -114,10 +114,12 @@ it directly. It takes the following props: -- `searchParams`: The initial search params to use for the test. These can be a +- `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' + ``` + +- `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. + +
+🧪 Internal/advanced options + +- `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. + +