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 custom parsers testing helpers #853

Merged
merged 4 commits into from
Feb 17, 2025
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
34 changes: 34 additions & 0 deletions packages/docs/content/docs/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
```
6 changes: 6 additions & 0 deletions packages/nuqs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
153 changes: 137 additions & 16 deletions packages/nuqs/src/parsers.test.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,63 @@
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)
expect(parseAsFloat.parse('3,14')).toBe(3)
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)
Expand All @@ -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'
Expand All @@ -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<Test>(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',
Expand All @@ -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')
Expand All @@ -110,15 +231,15 @@ 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')
})
})

describe('parsers/equality', () => {
test('parseAsArrayOf', () => {
it('parseAsArrayOf', () => {
const eq = parseAsArrayOf(parseAsString).eq!
expect(eq([], [])).toBe(true)
expect(eq(['foo'], ['foo'])).toBe(true)
Expand Down
13 changes: 10 additions & 3 deletions packages/nuqs/src/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
})

/**
Expand All @@ -223,7 +228,8 @@ export const parseAsIsoDateTime = createParser({
}
return date
},
serialize: (v: Date) => v.toISOString()
serialize: (v: Date) => v.toISOString(),
eq: compareDates
})

/**
Expand All @@ -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
})

/**
Expand Down
Loading
Loading