Skip to content

Commit d89e57b

Browse files
committed
feat: Add isParserBijective to do a complete roundtrip test
1 parent e1d827e commit d89e57b

File tree

3 files changed

+166
-60
lines changed

3 files changed

+166
-60
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/src/parsers.test.ts

+71-56
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, test } from 'vitest'
1+
import { describe, expect, it } from 'vitest'
22
import {
33
parseAsArrayOf,
44
parseAsBoolean,
@@ -14,50 +14,50 @@ import {
1414
parseAsStringLiteral,
1515
parseAsTimestamp
1616
} from './parsers'
17-
import { testParseThenSerialize, testSerializeThenParse } from './testing'
17+
import {
18+
isParserBijective,
19+
testParseThenSerialize,
20+
testSerializeThenParse
21+
} from './testing'
1822

1923
describe('parsers', () => {
20-
test('parseAsString', () => {
24+
it('parseAsString', () => {
2125
expect(parseAsString.parse('')).toBe('')
2226
expect(parseAsString.parse('foo')).toBe('foo')
23-
testParseThenSerialize(parseAsString, 'foo')
24-
testSerializeThenParse(parseAsString, 'foo')
27+
expect(isParserBijective(parseAsString, 'foo', 'foo')).toBe(true)
2528
})
26-
test('parseAsInteger', () => {
29+
it('parseAsInteger', () => {
2730
expect(parseAsInteger.parse('')).toBeNull()
2831
expect(parseAsInteger.parse('1')).toBe(1)
2932
expect(parseAsInteger.parse('3.14')).toBe(3)
3033
expect(parseAsInteger.parse('3,14')).toBe(3)
3134
expect(parseAsInteger.serialize(3.14)).toBe('3')
32-
testParseThenSerialize(parseAsInteger, '3')
33-
testSerializeThenParse(parseAsInteger, 3)
35+
expect(isParserBijective(parseAsInteger, '3', 3)).toBe(true)
3436
expect(() => testParseThenSerialize(parseAsInteger, '3.14')).toThrow()
3537
expect(() => testSerializeThenParse(parseAsInteger, 3.14)).toThrow()
3638
})
37-
test('parseAsHex', () => {
39+
it('parseAsHex', () => {
3840
expect(parseAsHex.parse('')).toBeNull()
3941
expect(parseAsHex.parse('1')).toBe(1)
4042
expect(parseAsHex.parse('a')).toBe(0xa)
4143
expect(parseAsHex.parse('g')).toBeNull()
4244
expect(parseAsHex.serialize(0xa)).toBe('0a')
43-
for (let i = 0; i < 256; i++) {
44-
const hex = i.toString(16).padStart(2, '0')
45-
testParseThenSerialize(parseAsHex, hex)
46-
testSerializeThenParse(parseAsHex, i)
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)
4748
}
4849
})
49-
test('parseAsFloat', () => {
50+
it('parseAsFloat', () => {
5051
expect(parseAsFloat.parse('')).toBeNull()
5152
expect(parseAsFloat.parse('1')).toBe(1)
5253
expect(parseAsFloat.parse('3.14')).toBe(3.14)
5354
expect(parseAsFloat.parse('3,14')).toBe(3)
5455
expect(parseAsFloat.serialize(3.14)).toBe('3.14')
5556
// https://0.30000000000000004.com/
5657
expect(parseAsFloat.serialize(0.1 + 0.2)).toBe('0.30000000000000004')
57-
testParseThenSerialize(parseAsFloat, '3.14')
58-
testSerializeThenParse(parseAsFloat, 3.14)
58+
expect(isParserBijective(parseAsFloat, '3.14', 3.14)).toBe(true)
5959
})
60-
test('parseAsIndex', () => {
60+
it('parseAsIndex', () => {
6161
expect(parseAsIndex.parse('')).toBeNull()
6262
expect(parseAsIndex.parse('1')).toBe(0)
6363
expect(parseAsIndex.parse('3.14')).toBe(2)
@@ -66,22 +66,20 @@ describe('parsers', () => {
6666
expect(parseAsIndex.parse('-1')).toBe(-2)
6767
expect(parseAsIndex.serialize(0)).toBe('1')
6868
expect(parseAsIndex.serialize(3.14)).toBe('4')
69-
testParseThenSerialize(parseAsIndex, '0')
70-
testParseThenSerialize(parseAsIndex, '1')
71-
testSerializeThenParse(parseAsIndex, 0)
72-
testSerializeThenParse(parseAsIndex, 1)
69+
expect(isParserBijective(parseAsIndex, '1', 0)).toBe(true)
70+
expect(isParserBijective(parseAsIndex, '2', 1)).toBe(true)
7371
})
74-
test('parseAsHex', () => {
72+
it('parseAsHex', () => {
7573
expect(parseAsHex.parse('')).toBeNull()
7674
expect(parseAsHex.parse('1')).toBe(1)
7775
expect(parseAsHex.parse('a')).toBe(0xa)
7876
expect(parseAsHex.parse('g')).toBeNull()
7977
expect(parseAsHex.serialize(0x0a)).toBe('0a')
8078
expect(parseAsHex.serialize(0x2a)).toBe('2a')
81-
testParseThenSerialize(parseAsHex, '2a')
82-
testSerializeThenParse(parseAsHex, 0x2a)
79+
expect(isParserBijective(parseAsHex, '0a', 0x0a)).toBe(true)
80+
expect(isParserBijective(parseAsHex, '2a', 0x2a)).toBe(true)
8381
})
84-
test('parseAsBoolean', () => {
82+
it('parseAsBoolean', () => {
8583
expect(parseAsBoolean.parse('')).toBe(false)
8684
// In only triggers on 'true', everything else is false
8785
expect(parseAsBoolean.parse('true')).toBe(true)
@@ -92,19 +90,23 @@ describe('parsers', () => {
9290
expect(parseAsBoolean.parse('no')).toBe(false)
9391
expect(parseAsBoolean.serialize(true)).toBe('true')
9492
expect(parseAsBoolean.serialize(false)).toBe('false')
95-
testParseThenSerialize(parseAsBoolean, 'true')
96-
testSerializeThenParse(parseAsBoolean, true)
97-
testParseThenSerialize(parseAsBoolean, 'false')
98-
testSerializeThenParse(parseAsBoolean, false)
93+
expect(isParserBijective(parseAsBoolean, 'true', true)).toBe(true)
94+
expect(isParserBijective(parseAsBoolean, 'false', false)).toBe(true)
9995
})
10096

101-
test('parseAsTimestamp', () => {
97+
it('parseAsTimestamp', () => {
10298
expect(parseAsTimestamp.parse('')).toBeNull()
10399
expect(parseAsTimestamp.parse('0')).toStrictEqual(new Date(0))
104-
testParseThenSerialize(parseAsTimestamp, '0')
105-
testSerializeThenParse(parseAsTimestamp, new Date(1234567890))
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)
106108
})
107-
test('parseAsIsoDateTime', () => {
109+
it('parseAsIsoDateTime', () => {
108110
expect(parseAsIsoDateTime.parse('')).toBeNull()
109111
expect(parseAsIsoDateTime.parse('not-a-date')).toBeNull()
110112
const moment = '2020-01-01T00:00:00.000Z'
@@ -114,20 +116,22 @@ describe('parsers', () => {
114116
expect(parseAsIsoDateTime.parse(moment.slice(0, 16) + 'Z')).toStrictEqual(
115117
ref
116118
)
117-
testParseThenSerialize(parseAsIsoDateTime, moment)
118-
testSerializeThenParse(parseAsIsoDateTime, ref)
119+
expect(testParseThenSerialize(parseAsIsoDateTime, moment)).toBe(true)
120+
expect(testSerializeThenParse(parseAsIsoDateTime, ref)).toBe(true)
121+
expect(isParserBijective(parseAsIsoDateTime, moment, ref)).toBe(true)
119122
})
120-
test('parseAsIsoDate', () => {
123+
it('parseAsIsoDate', () => {
121124
expect(parseAsIsoDate.parse('')).toBeNull()
122125
expect(parseAsIsoDate.parse('not-a-date')).toBeNull()
123126
const moment = '2020-01-01'
124127
const ref = new Date(moment)
125128
expect(parseAsIsoDate.parse(moment)).toStrictEqual(ref)
126129
expect(parseAsIsoDate.serialize(ref)).toEqual(moment)
127-
testParseThenSerialize(parseAsIsoDate, moment)
128-
testSerializeThenParse(parseAsIsoDate, ref)
130+
expect(testParseThenSerialize(parseAsIsoDate, moment)).toBe(true)
131+
expect(testSerializeThenParse(parseAsIsoDate, ref)).toBe(true)
132+
expect(isParserBijective(parseAsIsoDate, moment, ref)).toBe(true)
129133
})
130-
test('parseAsStringEnum', () => {
134+
it('parseAsStringEnum', () => {
131135
enum Test {
132136
A = 'a',
133137
B = 'b',
@@ -142,10 +146,11 @@ describe('parsers', () => {
142146
expect(parser.serialize(Test.A)).toBe('a')
143147
expect(parser.serialize(Test.B)).toBe('b')
144148
expect(parser.serialize(Test.C)).toBe('c')
145-
testParseThenSerialize(parser, 'a')
146-
testSerializeThenParse(parser, Test.A)
149+
expect(testParseThenSerialize(parser, 'a')).toBe(true)
150+
expect(testSerializeThenParse(parser, Test.A)).toBe(true)
151+
expect(isParserBijective(parser, 'b', Test.B)).toBe(true)
147152
})
148-
test('parseAsStringLiteral', () => {
153+
it('parseAsStringLiteral', () => {
149154
const parser = parseAsStringLiteral(['a', 'b', 'c'] as const)
150155
expect(parser.parse('')).toBeNull()
151156
expect(parser.parse('a')).toBe('a')
@@ -155,10 +160,13 @@ describe('parsers', () => {
155160
expect(parser.serialize('a')).toBe('a')
156161
expect(parser.serialize('b')).toBe('b')
157162
expect(parser.serialize('c')).toBe('c')
158-
testParseThenSerialize(parser, 'a')
159-
testSerializeThenParse(parser, 'a')
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)
160168
})
161-
test('parseAsNumberLiteral', () => {
169+
it('parseAsNumberLiteral', () => {
162170
const parser = parseAsNumberLiteral([1, 2, 3] as const)
163171
expect(parser.parse('')).toBeNull()
164172
expect(parser.parse('1')).toBe(1)
@@ -168,20 +176,27 @@ describe('parsers', () => {
168176
expect(parser.serialize(1)).toBe('1')
169177
expect(parser.serialize(2)).toBe('2')
170178
expect(parser.serialize(3)).toBe('3')
171-
testParseThenSerialize(parser, '1')
172-
testSerializeThenParse(parser, 1)
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)
173184
})
174185

175-
test('parseAsArrayOf', () => {
186+
it('parseAsArrayOf', () => {
176187
const parser = parseAsArrayOf(parseAsString)
177188
expect(parser.serialize([])).toBe('')
178189
// It encodes its separator
179190
expect(parser.serialize(['a', ',', 'b'])).toBe('a,%2C,b')
180-
testParseThenSerialize(parser, 'a,b')
181-
testSerializeThenParse(parser, ['a', '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()
182197
})
183198

184-
test('parseServerSide with default (#384)', () => {
199+
it('parseServerSide with default (#384)', () => {
185200
const p = parseAsString.withDefault('default')
186201
const searchParams = {
187202
string: 'foo',
@@ -195,18 +210,18 @@ describe('parsers', () => {
195210
expect(p.parseServerSide(searchParams.nope)).toBe('default')
196211
})
197212

198-
test('chaining options does not reset them', () => {
213+
it('does not reset options when chaining them', () => {
199214
const p = parseAsString.withOptions({ scroll: true }).withOptions({})
200215
expect(p.scroll).toBe(true)
201216
})
202-
test('chaining options merges them', () => {
217+
it('merges options when chaining them', () => {
203218
const p = parseAsString
204219
.withOptions({ scroll: true })
205220
.withOptions({ history: 'push' })
206221
expect(p.scroll).toBe(true)
207222
expect(p.history).toBe('push')
208223
})
209-
test('chaining options & default value', () => {
224+
it('merges default values when chaining options', () => {
210225
const p = parseAsString
211226
.withOptions({ scroll: true })
212227
.withDefault('default')
@@ -216,15 +231,15 @@ describe('parsers', () => {
216231
expect(p.defaultValue).toBe('default')
217232
expect(p.parseServerSide(undefined)).toBe('default')
218233
})
219-
test('changing default value', () => {
234+
it('allows changing the default value', () => {
220235
const p = parseAsString.withDefault('foo').withDefault('bar')
221236
expect(p.defaultValue).toBe('bar')
222237
expect(p.parseServerSide(undefined)).toBe('bar')
223238
})
224239
})
225240

226241
describe('parsers/equality', () => {
227-
test('parseAsArrayOf', () => {
242+
it('parseAsArrayOf', () => {
228243
const eq = parseAsArrayOf(parseAsString).eq!
229244
expect(eq([], [])).toBe(true)
230245
expect(eq(['foo'], ['foo'])).toBe(true)

0 commit comments

Comments
 (0)