Skip to content

Commit 08a5752

Browse files
authored
feat: Add custom parsers testing helpers (#853)
* 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
1 parent e5f5506 commit 08a5752

File tree

7 files changed

+328
-20
lines changed

7 files changed

+328
-20
lines changed

packages/docs/content/docs/testing.mdx

+34
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,37 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
184184
```
185185

186186
It takes the same props as the arguments you can pass to `withNuqsTestingAdapter{:ts}`.
187+
188+
## Testing custom parsers
189+
190+
If you create custom parsers with `createParser{:ts}`, you will likely want to
191+
test them.
192+
193+
Parsers should:
194+
1. Define pure functions for `parse`, `serialize`, and `eq`.
195+
2. Be bijective: `parse(serialize(x)) === x` and `serialize(parse(x)) === x`.
196+
197+
To help test bijectivity, you can use helpers defined in `nuqs/testing`:
198+
199+
```ts /isParserBijective/
200+
import {
201+
isParserBijective,
202+
testParseThenSerialize,
203+
testSerializeThenParse
204+
} from 'nuqs/testing'
205+
206+
it('is bijective', () => {
207+
// Passing tests return true
208+
expect(isParserBijective(parseAsInteger, '42', 42)).toBe(true)
209+
// Failing test throws an error
210+
expect(() => isParserBijective(parseAsInteger, '42', 47)).toThrowError()
211+
212+
// You can also test either side separately:
213+
expect(testParseThenSerialize(parseAsInteger, '42')).toBe(true)
214+
expect(testSerializeThenParse(parseAsInteger, 42)).toBe(true)
215+
// Those will also throw an error if the test fails,
216+
// which makes it easier to isolate which side failed:
217+
expect(() => testParseThenSerialize(parseAsInteger, 'not a number')).toThrowError()
218+
expect(() => testSerializeThenParse(parseAsInteger, NaN)).toThrowError()
219+
})
220+
```

packages/nuqs/package.json

+6
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"files": [
3636
"dist/",
3737
"server.d.ts",
38+
"testing.d.ts",
3839
"adapters/react.d.ts",
3940
"adapters/next.d.ts",
4041
"adapters/next/app.d.ts",
@@ -62,6 +63,11 @@
6263
"import": "./dist/server.js",
6364
"require": "./esm-only.cjs"
6465
},
66+
"./testing": {
67+
"types": "./dist/testing.d.ts",
68+
"import": "./dist/testing.js",
69+
"require": "./esm-only.cjs"
70+
},
6571
"./adapters/react": {
6672
"types": "./dist/adapters/react.d.ts",
6773
"import": "./dist/adapters/react.js",

packages/nuqs/src/parsers.test.ts

+137-16
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,63 @@
1-
import { describe, expect, test } from 'vitest'
1+
import { describe, expect, it } from 'vitest'
22
import {
33
parseAsArrayOf,
4+
parseAsBoolean,
45
parseAsFloat,
56
parseAsHex,
67
parseAsIndex,
78
parseAsInteger,
89
parseAsIsoDate,
910
parseAsIsoDateTime,
11+
parseAsNumberLiteral,
1012
parseAsString,
13+
parseAsStringEnum,
14+
parseAsStringLiteral,
1115
parseAsTimestamp
1216
} from './parsers'
17+
import {
18+
isParserBijective,
19+
testParseThenSerialize,
20+
testSerializeThenParse
21+
} from './testing'
1322

1423
describe('parsers', () => {
15-
test('parseAsInteger', () => {
24+
it('parseAsString', () => {
25+
expect(parseAsString.parse('')).toBe('')
26+
expect(parseAsString.parse('foo')).toBe('foo')
27+
expect(isParserBijective(parseAsString, 'foo', 'foo')).toBe(true)
28+
})
29+
it('parseAsInteger', () => {
1630
expect(parseAsInteger.parse('')).toBeNull()
1731
expect(parseAsInteger.parse('1')).toBe(1)
1832
expect(parseAsInteger.parse('3.14')).toBe(3)
1933
expect(parseAsInteger.parse('3,14')).toBe(3)
2034
expect(parseAsInteger.serialize(3.14)).toBe('3')
35+
expect(isParserBijective(parseAsInteger, '3', 3)).toBe(true)
36+
expect(() => testParseThenSerialize(parseAsInteger, '3.14')).toThrow()
37+
expect(() => testSerializeThenParse(parseAsInteger, 3.14)).toThrow()
38+
})
39+
it('parseAsHex', () => {
40+
expect(parseAsHex.parse('')).toBeNull()
41+
expect(parseAsHex.parse('1')).toBe(1)
42+
expect(parseAsHex.parse('a')).toBe(0xa)
43+
expect(parseAsHex.parse('g')).toBeNull()
44+
expect(parseAsHex.serialize(0xa)).toBe('0a')
45+
for (let byte = 0; byte < 256; byte++) {
46+
const hexString = byte.toString(16).padStart(2, '0')
47+
expect(isParserBijective(parseAsHex, hexString, byte)).toBe(true)
48+
}
2149
})
22-
test('parseAsFloat', () => {
50+
it('parseAsFloat', () => {
2351
expect(parseAsFloat.parse('')).toBeNull()
2452
expect(parseAsFloat.parse('1')).toBe(1)
2553
expect(parseAsFloat.parse('3.14')).toBe(3.14)
2654
expect(parseAsFloat.parse('3,14')).toBe(3)
2755
expect(parseAsFloat.serialize(3.14)).toBe('3.14')
2856
// https://0.30000000000000004.com/
2957
expect(parseAsFloat.serialize(0.1 + 0.2)).toBe('0.30000000000000004')
58+
expect(isParserBijective(parseAsFloat, '3.14', 3.14)).toBe(true)
3059
})
31-
test('parseAsIndex', () => {
60+
it('parseAsIndex', () => {
3261
expect(parseAsIndex.parse('')).toBeNull()
3362
expect(parseAsIndex.parse('1')).toBe(0)
3463
expect(parseAsIndex.parse('3.14')).toBe(2)
@@ -37,19 +66,47 @@ describe('parsers', () => {
3766
expect(parseAsIndex.parse('-1')).toBe(-2)
3867
expect(parseAsIndex.serialize(0)).toBe('1')
3968
expect(parseAsIndex.serialize(3.14)).toBe('4')
69+
expect(isParserBijective(parseAsIndex, '1', 0)).toBe(true)
70+
expect(isParserBijective(parseAsIndex, '2', 1)).toBe(true)
4071
})
41-
test('parseAsHex', () => {
72+
it('parseAsHex', () => {
4273
expect(parseAsHex.parse('')).toBeNull()
4374
expect(parseAsHex.parse('1')).toBe(1)
4475
expect(parseAsHex.parse('a')).toBe(0xa)
4576
expect(parseAsHex.parse('g')).toBeNull()
46-
expect(parseAsHex.serialize(0xa)).toBe('0a')
77+
expect(parseAsHex.serialize(0x0a)).toBe('0a')
78+
expect(parseAsHex.serialize(0x2a)).toBe('2a')
79+
expect(isParserBijective(parseAsHex, '0a', 0x0a)).toBe(true)
80+
expect(isParserBijective(parseAsHex, '2a', 0x2a)).toBe(true)
81+
})
82+
it('parseAsBoolean', () => {
83+
expect(parseAsBoolean.parse('')).toBe(false)
84+
// In only triggers on 'true', everything else is false
85+
expect(parseAsBoolean.parse('true')).toBe(true)
86+
expect(parseAsBoolean.parse('false')).toBe(false)
87+
expect(parseAsBoolean.parse('0')).toBe(false)
88+
expect(parseAsBoolean.parse('1')).toBe(false)
89+
expect(parseAsBoolean.parse('yes')).toBe(false)
90+
expect(parseAsBoolean.parse('no')).toBe(false)
91+
expect(parseAsBoolean.serialize(true)).toBe('true')
92+
expect(parseAsBoolean.serialize(false)).toBe('false')
93+
expect(isParserBijective(parseAsBoolean, 'true', true)).toBe(true)
94+
expect(isParserBijective(parseAsBoolean, 'false', false)).toBe(true)
4795
})
48-
test('parseAsTimestamp', () => {
96+
97+
it('parseAsTimestamp', () => {
4998
expect(parseAsTimestamp.parse('')).toBeNull()
5099
expect(parseAsTimestamp.parse('0')).toStrictEqual(new Date(0))
100+
expect(testParseThenSerialize(parseAsTimestamp, '0')).toBe(true)
101+
expect(testSerializeThenParse(parseAsTimestamp, new Date(1234567890))).toBe(
102+
true
103+
)
104+
expect(isParserBijective(parseAsTimestamp, '0', new Date(0))).toBe(true)
105+
expect(
106+
isParserBijective(parseAsTimestamp, '1234567890', new Date(1234567890))
107+
).toBe(true)
51108
})
52-
test('parseAsIsoDateTime', () => {
109+
it('parseAsIsoDateTime', () => {
53110
expect(parseAsIsoDateTime.parse('')).toBeNull()
54111
expect(parseAsIsoDateTime.parse('not-a-date')).toBeNull()
55112
const moment = '2020-01-01T00:00:00.000Z'
@@ -59,23 +116,87 @@ describe('parsers', () => {
59116
expect(parseAsIsoDateTime.parse(moment.slice(0, 16) + 'Z')).toStrictEqual(
60117
ref
61118
)
119+
expect(testParseThenSerialize(parseAsIsoDateTime, moment)).toBe(true)
120+
expect(testSerializeThenParse(parseAsIsoDateTime, ref)).toBe(true)
121+
expect(isParserBijective(parseAsIsoDateTime, moment, ref)).toBe(true)
62122
})
63-
test('parseAsIsoDate', () => {
123+
it('parseAsIsoDate', () => {
64124
expect(parseAsIsoDate.parse('')).toBeNull()
65125
expect(parseAsIsoDate.parse('not-a-date')).toBeNull()
66126
const moment = '2020-01-01'
67127
const ref = new Date(moment)
68128
expect(parseAsIsoDate.parse(moment)).toStrictEqual(ref)
69129
expect(parseAsIsoDate.serialize(ref)).toEqual(moment)
130+
expect(testParseThenSerialize(parseAsIsoDate, moment)).toBe(true)
131+
expect(testSerializeThenParse(parseAsIsoDate, ref)).toBe(true)
132+
expect(isParserBijective(parseAsIsoDate, moment, ref)).toBe(true)
70133
})
71-
test('parseAsArrayOf', () => {
134+
it('parseAsStringEnum', () => {
135+
enum Test {
136+
A = 'a',
137+
B = 'b',
138+
C = 'c'
139+
}
140+
const parser = parseAsStringEnum<Test>(Object.values(Test))
141+
expect(parser.parse('')).toBeNull()
142+
expect(parser.parse('a')).toBe('a')
143+
expect(parser.parse('b')).toBe('b')
144+
expect(parser.parse('c')).toBe('c')
145+
expect(parser.parse('d')).toBeNull()
146+
expect(parser.serialize(Test.A)).toBe('a')
147+
expect(parser.serialize(Test.B)).toBe('b')
148+
expect(parser.serialize(Test.C)).toBe('c')
149+
expect(testParseThenSerialize(parser, 'a')).toBe(true)
150+
expect(testSerializeThenParse(parser, Test.A)).toBe(true)
151+
expect(isParserBijective(parser, 'b', Test.B)).toBe(true)
152+
})
153+
it('parseAsStringLiteral', () => {
154+
const parser = parseAsStringLiteral(['a', 'b', 'c'] as const)
155+
expect(parser.parse('')).toBeNull()
156+
expect(parser.parse('a')).toBe('a')
157+
expect(parser.parse('b')).toBe('b')
158+
expect(parser.parse('c')).toBe('c')
159+
expect(parser.parse('d')).toBeNull()
160+
expect(parser.serialize('a')).toBe('a')
161+
expect(parser.serialize('b')).toBe('b')
162+
expect(parser.serialize('c')).toBe('c')
163+
expect(testParseThenSerialize(parser, 'a')).toBe(true)
164+
expect(testSerializeThenParse(parser, 'a')).toBe(true)
165+
expect(isParserBijective(parser, 'a', 'a')).toBe(true)
166+
expect(isParserBijective(parser, 'b', 'b')).toBe(true)
167+
expect(isParserBijective(parser, 'c', 'c')).toBe(true)
168+
})
169+
it('parseAsNumberLiteral', () => {
170+
const parser = parseAsNumberLiteral([1, 2, 3] as const)
171+
expect(parser.parse('')).toBeNull()
172+
expect(parser.parse('1')).toBe(1)
173+
expect(parser.parse('2')).toBe(2)
174+
expect(parser.parse('3')).toBe(3)
175+
expect(parser.parse('4')).toBeNull()
176+
expect(parser.serialize(1)).toBe('1')
177+
expect(parser.serialize(2)).toBe('2')
178+
expect(parser.serialize(3)).toBe('3')
179+
expect(testParseThenSerialize(parser, '1')).toBe(true)
180+
expect(testSerializeThenParse(parser, 1)).toBe(true)
181+
expect(isParserBijective(parser, '1', 1)).toBe(true)
182+
expect(isParserBijective(parser, '2', 2)).toBe(true)
183+
expect(isParserBijective(parser, '3', 3)).toBe(true)
184+
})
185+
186+
it('parseAsArrayOf', () => {
72187
const parser = parseAsArrayOf(parseAsString)
73188
expect(parser.serialize([])).toBe('')
74189
// It encodes its separator
75190
expect(parser.serialize(['a', ',', 'b'])).toBe('a,%2C,b')
191+
expect(testParseThenSerialize(parser, 'a,b')).toBe(true)
192+
expect(testSerializeThenParse(parser, ['a', 'b'])).toBe(true)
193+
expect(isParserBijective(parser, 'a,b', ['a', 'b'])).toBe(true)
194+
expect(() =>
195+
isParserBijective(parser, 'not-an-array', ['a', 'b'])
196+
).toThrow()
76197
})
77198

78-
test('parseServerSide with default (#384)', () => {
199+
it('parseServerSide with default (#384)', () => {
79200
const p = parseAsString.withDefault('default')
80201
const searchParams = {
81202
string: 'foo',
@@ -89,18 +210,18 @@ describe('parsers', () => {
89210
expect(p.parseServerSide(searchParams.nope)).toBe('default')
90211
})
91212

92-
test('chaining options does not reset them', () => {
213+
it('does not reset options when chaining them', () => {
93214
const p = parseAsString.withOptions({ scroll: true }).withOptions({})
94215
expect(p.scroll).toBe(true)
95216
})
96-
test('chaining options merges them', () => {
217+
it('merges options when chaining them', () => {
97218
const p = parseAsString
98219
.withOptions({ scroll: true })
99220
.withOptions({ history: 'push' })
100221
expect(p.scroll).toBe(true)
101222
expect(p.history).toBe('push')
102223
})
103-
test('chaining options & default value', () => {
224+
it('merges default values when chaining options', () => {
104225
const p = parseAsString
105226
.withOptions({ scroll: true })
106227
.withDefault('default')
@@ -110,15 +231,15 @@ describe('parsers', () => {
110231
expect(p.defaultValue).toBe('default')
111232
expect(p.parseServerSide(undefined)).toBe('default')
112233
})
113-
test('changing default value', () => {
234+
it('allows changing the default value', () => {
114235
const p = parseAsString.withDefault('foo').withDefault('bar')
115236
expect(p.defaultValue).toBe('bar')
116237
expect(p.parseServerSide(undefined)).toBe('bar')
117238
})
118239
})
119240

120241
describe('parsers/equality', () => {
121-
test('parseAsArrayOf', () => {
242+
it('parseAsArrayOf', () => {
122243
const eq = parseAsArrayOf(parseAsString).eq!
123244
expect(eq([], [])).toBe(true)
124245
expect(eq(['foo'], ['foo'])).toBe(true)

packages/nuqs/src/parsers.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ export const parseAsBoolean = createParser({
196196
serialize: v => (v ? 'true' : 'false')
197197
})
198198

199+
function compareDates(a: Date, b: Date) {
200+
return a.valueOf() === b.valueOf()
201+
}
202+
199203
/**
200204
* Querystring encoded as the number of milliseconds since epoch,
201205
* and returned as a Date object.
@@ -208,7 +212,8 @@ export const parseAsTimestamp = createParser({
208212
}
209213
return new Date(ms)
210214
},
211-
serialize: (v: Date) => v.valueOf().toString()
215+
serialize: (v: Date) => v.valueOf().toString(),
216+
eq: compareDates
212217
})
213218

214219
/**
@@ -223,7 +228,8 @@ export const parseAsIsoDateTime = createParser({
223228
}
224229
return date
225230
},
226-
serialize: (v: Date) => v.toISOString()
231+
serialize: (v: Date) => v.toISOString(),
232+
eq: compareDates
227233
})
228234

229235
/**
@@ -242,7 +248,8 @@ export const parseAsIsoDate = createParser({
242248
}
243249
return date
244250
},
245-
serialize: (v: Date) => v.toISOString().slice(0, 10)
251+
serialize: (v: Date) => v.toISOString().slice(0, 10),
252+
eq: compareDates
246253
})
247254

248255
/**

0 commit comments

Comments
 (0)