Skip to content

Commit

Permalink
feat: Add custom parsers testing helpers (#853)
Browse files Browse the repository at this point in the history
* feat: Add custom parser testing helpers

* fix: Provide equality function for date parsers

* test: Hex bijectivity

* feat: Add isParserBijective to do a complete roundtrip test
  • Loading branch information
franky47 authored Feb 17, 2025
1 parent e5f5506 commit 08a5752
Show file tree
Hide file tree
Showing 7 changed files with 328 additions and 20 deletions.
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

0 comments on commit 08a5752

Please sign in to comment.