diff --git a/packages/docs/content/docs/testing.mdx b/packages/docs/content/docs/testing.mdx index 79af95138..cd89e14d7 100644 --- a/packages/docs/content/docs/testing.mdx +++ b/packages/docs/content/docs/testing.mdx @@ -184,3 +184,37 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing' ``` It takes the same props as the arguments you can pass to `withNuqsTestingAdapter{:ts}`. + +## Testing custom parsers + +If you create custom parsers with `createParser{:ts}`, you will likely want to +test them. + +Parsers should: +1. Define pure functions for `parse`, `serialize`, and `eq`. +2. Be bijective: `parse(serialize(x)) === x` and `serialize(parse(x)) === x`. + +To help test bijectivity, you can use helpers defined in `nuqs/testing`: + +```ts /isParserBijective/ +import { + isParserBijective, + testParseThenSerialize, + testSerializeThenParse +} from 'nuqs/testing' + +it('is bijective', () => { + // Passing tests return true + expect(isParserBijective(parseAsInteger, '42', 42)).toBe(true) + // Failing test throws an error + expect(() => isParserBijective(parseAsInteger, '42', 47)).toThrowError() + + // You can also test either side separately: + expect(testParseThenSerialize(parseAsInteger, '42')).toBe(true) + expect(testSerializeThenParse(parseAsInteger, 42)).toBe(true) + // Those will also throw an error if the test fails, + // which makes it easier to isolate which side failed: + expect(() => testParseThenSerialize(parseAsInteger, 'not a number')).toThrowError() + expect(() => testSerializeThenParse(parseAsInteger, NaN)).toThrowError() +}) +``` \ No newline at end of file diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 6c3d23734..fbf08dbbb 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -35,6 +35,7 @@ "files": [ "dist/", "server.d.ts", + "testing.d.ts", "adapters/react.d.ts", "adapters/next.d.ts", "adapters/next/app.d.ts", @@ -62,6 +63,11 @@ "import": "./dist/server.js", "require": "./esm-only.cjs" }, + "./testing": { + "types": "./dist/testing.d.ts", + "import": "./dist/testing.js", + "require": "./esm-only.cjs" + }, "./adapters/react": { "types": "./dist/adapters/react.d.ts", "import": "./dist/adapters/react.js", diff --git a/packages/nuqs/src/parsers.test.ts b/packages/nuqs/src/parsers.test.ts index dcbf52f08..15fe2e951 100644 --- a/packages/nuqs/src/parsers.test.ts +++ b/packages/nuqs/src/parsers.test.ts @@ -1,25 +1,53 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, it } from 'vitest' import { parseAsArrayOf, + parseAsBoolean, parseAsFloat, parseAsHex, parseAsIndex, parseAsInteger, parseAsIsoDate, parseAsIsoDateTime, + parseAsNumberLiteral, parseAsString, + parseAsStringEnum, + parseAsStringLiteral, parseAsTimestamp } from './parsers' +import { + isParserBijective, + testParseThenSerialize, + testSerializeThenParse +} from './testing' describe('parsers', () => { - test('parseAsInteger', () => { + it('parseAsString', () => { + expect(parseAsString.parse('')).toBe('') + expect(parseAsString.parse('foo')).toBe('foo') + expect(isParserBijective(parseAsString, 'foo', 'foo')).toBe(true) + }) + it('parseAsInteger', () => { expect(parseAsInteger.parse('')).toBeNull() expect(parseAsInteger.parse('1')).toBe(1) expect(parseAsInteger.parse('3.14')).toBe(3) expect(parseAsInteger.parse('3,14')).toBe(3) expect(parseAsInteger.serialize(3.14)).toBe('3') + expect(isParserBijective(parseAsInteger, '3', 3)).toBe(true) + expect(() => testParseThenSerialize(parseAsInteger, '3.14')).toThrow() + expect(() => testSerializeThenParse(parseAsInteger, 3.14)).toThrow() + }) + it('parseAsHex', () => { + expect(parseAsHex.parse('')).toBeNull() + expect(parseAsHex.parse('1')).toBe(1) + expect(parseAsHex.parse('a')).toBe(0xa) + expect(parseAsHex.parse('g')).toBeNull() + expect(parseAsHex.serialize(0xa)).toBe('0a') + for (let byte = 0; byte < 256; byte++) { + const hexString = byte.toString(16).padStart(2, '0') + expect(isParserBijective(parseAsHex, hexString, byte)).toBe(true) + } }) - test('parseAsFloat', () => { + it('parseAsFloat', () => { expect(parseAsFloat.parse('')).toBeNull() expect(parseAsFloat.parse('1')).toBe(1) expect(parseAsFloat.parse('3.14')).toBe(3.14) @@ -27,8 +55,9 @@ describe('parsers', () => { expect(parseAsFloat.serialize(3.14)).toBe('3.14') // https://0.30000000000000004.com/ expect(parseAsFloat.serialize(0.1 + 0.2)).toBe('0.30000000000000004') + expect(isParserBijective(parseAsFloat, '3.14', 3.14)).toBe(true) }) - test('parseAsIndex', () => { + it('parseAsIndex', () => { expect(parseAsIndex.parse('')).toBeNull() expect(parseAsIndex.parse('1')).toBe(0) expect(parseAsIndex.parse('3.14')).toBe(2) @@ -37,19 +66,47 @@ describe('parsers', () => { expect(parseAsIndex.parse('-1')).toBe(-2) expect(parseAsIndex.serialize(0)).toBe('1') expect(parseAsIndex.serialize(3.14)).toBe('4') + expect(isParserBijective(parseAsIndex, '1', 0)).toBe(true) + expect(isParserBijective(parseAsIndex, '2', 1)).toBe(true) }) - test('parseAsHex', () => { + it('parseAsHex', () => { expect(parseAsHex.parse('')).toBeNull() expect(parseAsHex.parse('1')).toBe(1) expect(parseAsHex.parse('a')).toBe(0xa) expect(parseAsHex.parse('g')).toBeNull() - expect(parseAsHex.serialize(0xa)).toBe('0a') + expect(parseAsHex.serialize(0x0a)).toBe('0a') + expect(parseAsHex.serialize(0x2a)).toBe('2a') + expect(isParserBijective(parseAsHex, '0a', 0x0a)).toBe(true) + expect(isParserBijective(parseAsHex, '2a', 0x2a)).toBe(true) + }) + it('parseAsBoolean', () => { + expect(parseAsBoolean.parse('')).toBe(false) + // In only triggers on 'true', everything else is false + expect(parseAsBoolean.parse('true')).toBe(true) + expect(parseAsBoolean.parse('false')).toBe(false) + expect(parseAsBoolean.parse('0')).toBe(false) + expect(parseAsBoolean.parse('1')).toBe(false) + expect(parseAsBoolean.parse('yes')).toBe(false) + expect(parseAsBoolean.parse('no')).toBe(false) + expect(parseAsBoolean.serialize(true)).toBe('true') + expect(parseAsBoolean.serialize(false)).toBe('false') + expect(isParserBijective(parseAsBoolean, 'true', true)).toBe(true) + expect(isParserBijective(parseAsBoolean, 'false', false)).toBe(true) }) - test('parseAsTimestamp', () => { + + it('parseAsTimestamp', () => { expect(parseAsTimestamp.parse('')).toBeNull() expect(parseAsTimestamp.parse('0')).toStrictEqual(new Date(0)) + expect(testParseThenSerialize(parseAsTimestamp, '0')).toBe(true) + expect(testSerializeThenParse(parseAsTimestamp, new Date(1234567890))).toBe( + true + ) + expect(isParserBijective(parseAsTimestamp, '0', new Date(0))).toBe(true) + expect( + isParserBijective(parseAsTimestamp, '1234567890', new Date(1234567890)) + ).toBe(true) }) - test('parseAsIsoDateTime', () => { + it('parseAsIsoDateTime', () => { expect(parseAsIsoDateTime.parse('')).toBeNull() expect(parseAsIsoDateTime.parse('not-a-date')).toBeNull() const moment = '2020-01-01T00:00:00.000Z' @@ -59,23 +116,87 @@ describe('parsers', () => { expect(parseAsIsoDateTime.parse(moment.slice(0, 16) + 'Z')).toStrictEqual( ref ) + expect(testParseThenSerialize(parseAsIsoDateTime, moment)).toBe(true) + expect(testSerializeThenParse(parseAsIsoDateTime, ref)).toBe(true) + expect(isParserBijective(parseAsIsoDateTime, moment, ref)).toBe(true) }) - test('parseAsIsoDate', () => { + it('parseAsIsoDate', () => { expect(parseAsIsoDate.parse('')).toBeNull() expect(parseAsIsoDate.parse('not-a-date')).toBeNull() const moment = '2020-01-01' const ref = new Date(moment) expect(parseAsIsoDate.parse(moment)).toStrictEqual(ref) expect(parseAsIsoDate.serialize(ref)).toEqual(moment) + expect(testParseThenSerialize(parseAsIsoDate, moment)).toBe(true) + expect(testSerializeThenParse(parseAsIsoDate, ref)).toBe(true) + expect(isParserBijective(parseAsIsoDate, moment, ref)).toBe(true) }) - test('parseAsArrayOf', () => { + it('parseAsStringEnum', () => { + enum Test { + A = 'a', + B = 'b', + C = 'c' + } + const parser = parseAsStringEnum(Object.values(Test)) + expect(parser.parse('')).toBeNull() + expect(parser.parse('a')).toBe('a') + expect(parser.parse('b')).toBe('b') + expect(parser.parse('c')).toBe('c') + expect(parser.parse('d')).toBeNull() + expect(parser.serialize(Test.A)).toBe('a') + expect(parser.serialize(Test.B)).toBe('b') + expect(parser.serialize(Test.C)).toBe('c') + expect(testParseThenSerialize(parser, 'a')).toBe(true) + expect(testSerializeThenParse(parser, Test.A)).toBe(true) + expect(isParserBijective(parser, 'b', Test.B)).toBe(true) + }) + it('parseAsStringLiteral', () => { + const parser = parseAsStringLiteral(['a', 'b', 'c'] as const) + expect(parser.parse('')).toBeNull() + expect(parser.parse('a')).toBe('a') + expect(parser.parse('b')).toBe('b') + expect(parser.parse('c')).toBe('c') + expect(parser.parse('d')).toBeNull() + expect(parser.serialize('a')).toBe('a') + expect(parser.serialize('b')).toBe('b') + expect(parser.serialize('c')).toBe('c') + expect(testParseThenSerialize(parser, 'a')).toBe(true) + expect(testSerializeThenParse(parser, 'a')).toBe(true) + expect(isParserBijective(parser, 'a', 'a')).toBe(true) + expect(isParserBijective(parser, 'b', 'b')).toBe(true) + expect(isParserBijective(parser, 'c', 'c')).toBe(true) + }) + it('parseAsNumberLiteral', () => { + const parser = parseAsNumberLiteral([1, 2, 3] as const) + expect(parser.parse('')).toBeNull() + expect(parser.parse('1')).toBe(1) + expect(parser.parse('2')).toBe(2) + expect(parser.parse('3')).toBe(3) + expect(parser.parse('4')).toBeNull() + expect(parser.serialize(1)).toBe('1') + expect(parser.serialize(2)).toBe('2') + expect(parser.serialize(3)).toBe('3') + expect(testParseThenSerialize(parser, '1')).toBe(true) + expect(testSerializeThenParse(parser, 1)).toBe(true) + expect(isParserBijective(parser, '1', 1)).toBe(true) + expect(isParserBijective(parser, '2', 2)).toBe(true) + expect(isParserBijective(parser, '3', 3)).toBe(true) + }) + + it('parseAsArrayOf', () => { const parser = parseAsArrayOf(parseAsString) expect(parser.serialize([])).toBe('') // It encodes its separator expect(parser.serialize(['a', ',', 'b'])).toBe('a,%2C,b') + expect(testParseThenSerialize(parser, 'a,b')).toBe(true) + expect(testSerializeThenParse(parser, ['a', 'b'])).toBe(true) + expect(isParserBijective(parser, 'a,b', ['a', 'b'])).toBe(true) + expect(() => + isParserBijective(parser, 'not-an-array', ['a', 'b']) + ).toThrow() }) - test('parseServerSide with default (#384)', () => { + it('parseServerSide with default (#384)', () => { const p = parseAsString.withDefault('default') const searchParams = { string: 'foo', @@ -89,18 +210,18 @@ describe('parsers', () => { expect(p.parseServerSide(searchParams.nope)).toBe('default') }) - test('chaining options does not reset them', () => { + it('does not reset options when chaining them', () => { const p = parseAsString.withOptions({ scroll: true }).withOptions({}) expect(p.scroll).toBe(true) }) - test('chaining options merges them', () => { + it('merges options when chaining them', () => { const p = parseAsString .withOptions({ scroll: true }) .withOptions({ history: 'push' }) expect(p.scroll).toBe(true) expect(p.history).toBe('push') }) - test('chaining options & default value', () => { + it('merges default values when chaining options', () => { const p = parseAsString .withOptions({ scroll: true }) .withDefault('default') @@ -110,7 +231,7 @@ describe('parsers', () => { expect(p.defaultValue).toBe('default') expect(p.parseServerSide(undefined)).toBe('default') }) - test('changing default value', () => { + it('allows changing the default value', () => { const p = parseAsString.withDefault('foo').withDefault('bar') expect(p.defaultValue).toBe('bar') expect(p.parseServerSide(undefined)).toBe('bar') @@ -118,7 +239,7 @@ describe('parsers', () => { }) describe('parsers/equality', () => { - test('parseAsArrayOf', () => { + it('parseAsArrayOf', () => { const eq = parseAsArrayOf(parseAsString).eq! expect(eq([], [])).toBe(true) expect(eq(['foo'], ['foo'])).toBe(true) diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index bde81495b..3893fdaab 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -196,6 +196,10 @@ export const parseAsBoolean = createParser({ serialize: v => (v ? 'true' : 'false') }) +function compareDates(a: Date, b: Date) { + return a.valueOf() === b.valueOf() +} + /** * Querystring encoded as the number of milliseconds since epoch, * and returned as a Date object. @@ -208,7 +212,8 @@ export const parseAsTimestamp = createParser({ } return new Date(ms) }, - serialize: (v: Date) => v.valueOf().toString() + serialize: (v: Date) => v.valueOf().toString(), + eq: compareDates }) /** @@ -223,7 +228,8 @@ export const parseAsIsoDateTime = createParser({ } return date }, - serialize: (v: Date) => v.toISOString() + serialize: (v: Date) => v.toISOString(), + eq: compareDates }) /** @@ -242,7 +248,8 @@ export const parseAsIsoDate = createParser({ } return date }, - serialize: (v: Date) => v.toISOString().slice(0, 10) + serialize: (v: Date) => v.toISOString().slice(0, 10), + eq: compareDates }) /** diff --git a/packages/nuqs/src/testing.ts b/packages/nuqs/src/testing.ts new file mode 100644 index 000000000..ca5091e5d --- /dev/null +++ b/packages/nuqs/src/testing.ts @@ -0,0 +1,132 @@ +import type { ParserBuilder } from './parsers' + +/** + * Test that a parser is bijective (serialize then parse gives back the same value). + * + * It will throw if the parser does not serialize the input to the expected serialized value, + * or if the parser does not parse the serialized value to the expected input value. + * The parser's `eq` function (if provided, otherwise `===`) is used to compare the values. + * + * Usage: + * ```ts + * // Expect it to pass (no error thrown) + * expect(isParserBijective(parseAsInteger, '42', 42)).toBe(true) + * // Expect it to fail + * expect(() => isParserBijective(parseAsInteger, '42', 47)).toThrow() + * ``` + * + * @param parser The parser to test + * @param serialized The serialized representation of the input to test against + * @param input An input value to test against + * @returns `true` if the test passes, otherwise it will throw. + */ +export function isParserBijective( + parser: ParserBuilder, + serialized: string, + input: T +): boolean { + // Test either sides of the bijectivitiy + testSerializeThenParse(parser, input) + testParseThenSerialize(parser, serialized) + // Test value equality + if (parser.serialize(input) !== serialized) { + throw new Error( + `[nuqs] parser.serialize does not match expected serialized value + Expected: '${serialized}' + Received: '${parser.serialize(input)}' + ` + ) + } + // @ts-expect-error - might return null + const parsed: T = parser.parse(serialized) + if (!parser.eq(parsed, input)) { + throw new Error( + `[nuqs] parser.parse does not match expected input value + Expected: ${input} + Received: ${parsed} + ` + ) + } + return true +} + +/** + * Test that a parser is bijective (serialize then parse gives back the same value). + * + * It will throw if the parser is not bijective (if the parsed value is not equal to the input value). + * The parser's `eq` function is used to compare the values. + * + * Usage: + * ```ts + * // Expect it to pass (no error thrown) + * expect(testSerializeThenParse(myParser, 'foo')).toBe(true) + * // Expect it to fail + * expect(() => testSerializeThenParse(myParser, 'bar')).toThrow() + * ``` + * + * @param parser The parser to test + * @param input An input value to test against + * @returns `true` if the test passes, otherwise it will throw. + */ +export function testSerializeThenParse( + parser: ParserBuilder, + input: T +): boolean { + const serialized = parser.serialize(input) + const parsed = parser.parse(serialized) + if (parsed === null) { + throw new Error( + `[nuqs] testSerializeThenParse: parsed value is null (when parsing ${serialized} serialized from ${input})` + ) + } + if (!parser.eq(input, parsed)) { + throw new Error( + `[nuqs] parser is not bijective (in testSerializeThenParse) + Expected value: ${typeof input === 'object' ? JSON.stringify(input) : input} + Received parsed value: ${typeof parsed === 'object' ? JSON.stringify(parsed) : parsed} + Serialized as: '${serialized}' + ` + ) + } + return true +} + +/** + * Tests that a parser is bijective (parse then serialize gives back the same query string). + * + * It will throw if the parser is not bijective (if the serialized value is not equal to the input query). + * + * Usage: + * ```ts + * // Expect it to pass (no error thrown) + * expect(testParseThenSerialize(myParser, 'foo')).toBe(true) + * // Expect it to fail + * expect(() => testParseThenSerialize(myParser, 'bar')).toThrow() + * ``` + * + * @param parser The parser to test + * @param input A query string to test against + * @returns `true` if the test passes, otherwise it will throw. + */ +export function testParseThenSerialize( + parser: ParserBuilder, + input: string +): boolean { + const parsed = parser.parse(input) + if (parsed === null) { + throw new Error( + `[nuqs] testParseThenSerialize: parsed value is null (when parsing ${input})` + ) + } + const serialized = parser.serialize(parsed) + if (serialized !== input) { + throw new Error( + `[nuqs] parser is not bijective (in testParseThenSerialize) + Expected query: '${input}' + Received query: '${serialized}' + Parsed value: ${parsed} +` + ) + } + return true +} diff --git a/packages/nuqs/testing.d.ts b/packages/nuqs/testing.d.ts new file mode 100644 index 000000000..2494ea425 --- /dev/null +++ b/packages/nuqs/testing.d.ts @@ -0,0 +1,7 @@ +// This file is needed for projects that have `moduleResolution` set to `node` +// in their tsconfig.json to be able to `import {} from 'nuqs/testing'`. +// Other module resolutions strategies will look for the `exports` in `package.json`, +// but with `node`, TypeScript will look for a .d.ts file with that name at the +// root of the package. + +export * from './dist/testing' diff --git a/packages/nuqs/tsup.config.ts b/packages/nuqs/tsup.config.ts index 2120fa7fc..50dddbf1c 100644 --- a/packages/nuqs/tsup.config.ts +++ b/packages/nuqs/tsup.config.ts @@ -34,7 +34,8 @@ const entrypoints = { 'adapters/testing': 'src/adapters/testing.ts' }, server: { - server: 'src/index.server.ts' + server: 'src/index.server.ts', + testing: 'src/testing.ts' } }