From 777627ef8fd2e9c8585d7215e2f6817bbf862a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Thu, 14 Nov 2024 14:33:26 +0100 Subject: [PATCH] feat: Add testing HOC to reduce test setup verbosity (#765) --- .gitignore | 2 +- packages/docs/content/docs/testing.mdx | 64 ++++++++++++++----- .../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 +++---- packages/nuqs/src/adapters/testing.ts | 30 +++++++++ packages/nuqs/src/sync.test.tsx | 10 +-- 8 files changed, 117 insertions(+), 75 deletions(-) diff --git a/.gitignore b/.gitignore index 918e298f..c5ef2c66 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,4 @@ package-lock.json .next/ .turbo/ .vercel -.tsbuildinfo +*.tsbuildinfo diff --git a/packages/docs/content/docs/testing.mdx b/packages/docs/content/docs/testing.mdx index 7c1c8bd3..289af89f 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') @@ -112,3 +105,44 @@ const config: Config = { Adapt accordingly for Windows with [`cross-env`](https://www.npmjs.com/package/cross-env). + +## 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' + + + + +``` + +- `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. + +
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 f95a2487..98284550 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 db9a450e..576bd412 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 f95a2487..98284550 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 db9a450e..576bd412 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!' diff --git a/packages/nuqs/src/adapters/testing.ts b/packages/nuqs/src/adapters/testing.ts index 80599e09..21b40a6a 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 18c6e312..d0e33211 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