Skip to content

Commit 1712af1

Browse files
committed
feat: Add custom parser testing helpers
1 parent b1ca0d2 commit 1712af1

File tree

5 files changed

+193
-1
lines changed

5 files changed

+193
-1
lines changed

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

+103
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,50 @@
11
import { describe, expect, test } 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 { testParseThenSerialize, testSerializeThenParse } from './testing'
1318

1419
describe('parsers', () => {
20+
test('parseAsString', () => {
21+
expect(parseAsString.parse('')).toBe('')
22+
expect(parseAsString.parse('foo')).toBe('foo')
23+
testParseThenSerialize(parseAsString, 'foo')
24+
testSerializeThenParse(parseAsString, 'foo')
25+
})
1526
test('parseAsInteger', () => {
1627
expect(parseAsInteger.parse('')).toBeNull()
1728
expect(parseAsInteger.parse('1')).toBe(1)
1829
expect(parseAsInteger.parse('3.14')).toBe(3)
1930
expect(parseAsInteger.parse('3,14')).toBe(3)
2031
expect(parseAsInteger.serialize(3.14)).toBe('3')
32+
testParseThenSerialize(parseAsInteger, '3')
33+
testSerializeThenParse(parseAsInteger, 3)
34+
expect(() => testParseThenSerialize(parseAsInteger, '3.14')).toThrow()
35+
expect(() => testSerializeThenParse(parseAsInteger, 3.14)).toThrow()
36+
})
37+
test('parseAsHex', () => {
38+
expect(parseAsHex.parse('')).toBeNull()
39+
expect(parseAsHex.parse('1')).toBe(1)
40+
expect(parseAsHex.parse('a')).toBe(0xa)
41+
expect(parseAsHex.parse('g')).toBeNull()
42+
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)
47+
}
2148
})
2249
test('parseAsFloat', () => {
2350
expect(parseAsFloat.parse('')).toBeNull()
@@ -27,6 +54,8 @@ describe('parsers', () => {
2754
expect(parseAsFloat.serialize(3.14)).toBe('3.14')
2855
// https://0.30000000000000004.com/
2956
expect(parseAsFloat.serialize(0.1 + 0.2)).toBe('0.30000000000000004')
57+
testParseThenSerialize(parseAsFloat, '3.14')
58+
testSerializeThenParse(parseAsFloat, 3.14)
3059
})
3160
test('parseAsIndex', () => {
3261
expect(parseAsIndex.parse('')).toBeNull()
@@ -37,6 +66,10 @@ 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+
testParseThenSerialize(parseAsIndex, '0')
70+
testParseThenSerialize(parseAsIndex, '1')
71+
testSerializeThenParse(parseAsIndex, 0)
72+
testSerializeThenParse(parseAsIndex, 1)
4073
})
4174
test('parseAsHex', () => {
4275
expect(parseAsHex.parse('')).toBeNull()
@@ -45,9 +78,28 @@ describe('parsers', () => {
4578
expect(parseAsHex.parse('g')).toBeNull()
4679
expect(parseAsHex.serialize(0xa)).toBe('0a')
4780
})
81+
test('parseAsBoolean', () => {
82+
expect(parseAsBoolean.parse('')).toBe(false)
83+
// In only triggers on 'true', everything else is false
84+
expect(parseAsBoolean.parse('true')).toBe(true)
85+
expect(parseAsBoolean.parse('false')).toBe(false)
86+
expect(parseAsBoolean.parse('0')).toBe(false)
87+
expect(parseAsBoolean.parse('1')).toBe(false)
88+
expect(parseAsBoolean.parse('yes')).toBe(false)
89+
expect(parseAsBoolean.parse('no')).toBe(false)
90+
expect(parseAsBoolean.serialize(true)).toBe('true')
91+
expect(parseAsBoolean.serialize(false)).toBe('false')
92+
testParseThenSerialize(parseAsBoolean, 'true')
93+
testSerializeThenParse(parseAsBoolean, true)
94+
testParseThenSerialize(parseAsBoolean, 'false')
95+
testSerializeThenParse(parseAsBoolean, false)
96+
})
97+
4898
test('parseAsTimestamp', () => {
4999
expect(parseAsTimestamp.parse('')).toBeNull()
50100
expect(parseAsTimestamp.parse('0')).toStrictEqual(new Date(0))
101+
testParseThenSerialize(parseAsTimestamp, '0')
102+
testSerializeThenParse(parseAsTimestamp, new Date(1234567890))
51103
})
52104
test('parseAsIsoDateTime', () => {
53105
expect(parseAsIsoDateTime.parse('')).toBeNull()
@@ -59,6 +111,8 @@ describe('parsers', () => {
59111
expect(parseAsIsoDateTime.parse(moment.slice(0, 16) + 'Z')).toStrictEqual(
60112
ref
61113
)
114+
testParseThenSerialize(parseAsIsoDateTime, moment)
115+
testSerializeThenParse(parseAsIsoDateTime, ref)
62116
})
63117
test('parseAsIsoDate', () => {
64118
expect(parseAsIsoDate.parse('')).toBeNull()
@@ -67,12 +121,61 @@ describe('parsers', () => {
67121
const ref = new Date(moment)
68122
expect(parseAsIsoDate.parse(moment)).toStrictEqual(ref)
69123
expect(parseAsIsoDate.serialize(ref)).toEqual(moment)
124+
testParseThenSerialize(parseAsIsoDate, moment)
125+
testSerializeThenParse(parseAsIsoDate, ref)
126+
})
127+
test('parseAsStringEnum', () => {
128+
enum Test {
129+
A = 'a',
130+
B = 'b',
131+
C = 'c'
132+
}
133+
const parser = parseAsStringEnum<Test>(Object.values(Test))
134+
expect(parser.parse('')).toBeNull()
135+
expect(parser.parse('a')).toBe('a')
136+
expect(parser.parse('b')).toBe('b')
137+
expect(parser.parse('c')).toBe('c')
138+
expect(parser.parse('d')).toBeNull()
139+
expect(parser.serialize(Test.A)).toBe('a')
140+
expect(parser.serialize(Test.B)).toBe('b')
141+
expect(parser.serialize(Test.C)).toBe('c')
142+
testParseThenSerialize(parser, 'a')
143+
testSerializeThenParse(parser, Test.A)
144+
})
145+
test('parseAsStringLiteral', () => {
146+
const parser = parseAsStringLiteral(['a', 'b', 'c'] as const)
147+
expect(parser.parse('')).toBeNull()
148+
expect(parser.parse('a')).toBe('a')
149+
expect(parser.parse('b')).toBe('b')
150+
expect(parser.parse('c')).toBe('c')
151+
expect(parser.parse('d')).toBeNull()
152+
expect(parser.serialize('a')).toBe('a')
153+
expect(parser.serialize('b')).toBe('b')
154+
expect(parser.serialize('c')).toBe('c')
155+
testParseThenSerialize(parser, 'a')
156+
testSerializeThenParse(parser, 'a')
70157
})
158+
test('parseAsNumberLiteral', () => {
159+
const parser = parseAsNumberLiteral([1, 2, 3] as const)
160+
expect(parser.parse('')).toBeNull()
161+
expect(parser.parse('1')).toBe(1)
162+
expect(parser.parse('2')).toBe(2)
163+
expect(parser.parse('3')).toBe(3)
164+
expect(parser.parse('4')).toBeNull()
165+
expect(parser.serialize(1)).toBe('1')
166+
expect(parser.serialize(2)).toBe('2')
167+
expect(parser.serialize(3)).toBe('3')
168+
testParseThenSerialize(parser, '1')
169+
testSerializeThenParse(parser, 1)
170+
})
171+
71172
test('parseAsArrayOf', () => {
72173
const parser = parseAsArrayOf(parseAsString)
73174
expect(parser.serialize([])).toBe('')
74175
// It encodes its separator
75176
expect(parser.serialize(['a', ',', 'b'])).toBe('a,%2C,b')
177+
testParseThenSerialize(parser, 'a,b')
178+
testSerializeThenParse(parser, ['a', 'b'])
76179
})
77180

78181
test('parseServerSide with default (#384)', () => {

packages/nuqs/src/testing.ts

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { ParserBuilder } from './parsers'
2+
3+
/**
4+
* Test that a parser is bijective (serialize then parse gives back the same value).
5+
*
6+
* It will throw if the parser is not bijective (if the parsed value is not equal to the input value).
7+
* The parser's `eq` function is used to compare the values.
8+
*
9+
* Usage:
10+
* ```ts
11+
* // Expect it to pass (no error thrown)
12+
* testSerializeThenParse(myParser, 'foo')
13+
* // Expect it to fail
14+
* expect(() => testSerializeThenParse(myParser, 'bar')).toThrow()
15+
* ```
16+
*
17+
* @param parser The parser to test
18+
* @param input An input value to test against
19+
*/
20+
export function testSerializeThenParse<T>(parser: ParserBuilder<T>, input: T) {
21+
const serialized = parser.serialize(input)
22+
const parsed = parser.parse(serialized)
23+
if (parsed === null) {
24+
throw new Error(
25+
`[nuqs] testSerializeThenParse: parsed value is null (when parsing ${serialized} serialized from ${input})`
26+
)
27+
}
28+
if (!parser.eq(input, parsed)) {
29+
throw new Error(
30+
`[nuqs] parser is not bijective (in testSerializeThenParse)
31+
Expected value: ${typeof input === 'object' ? JSON.stringify(input) : input}
32+
Received parsed value: ${typeof parsed === 'object' ? JSON.stringify(parsed) : parsed}
33+
Serialized as: '${serialized}'
34+
`
35+
)
36+
}
37+
}
38+
39+
/**
40+
* Tests that a parser is bijective (parse then serialize gives back the same query string).
41+
*
42+
* It will throw if the parser is not bijective (if the serialized value is not equal to the input query).
43+
*
44+
* Usage:
45+
* ```ts
46+
* // Expect it to pass (no error thrown)
47+
* testParseThenSerialize(myParser, 'foo')
48+
* // Expect it to fail
49+
* expect(() => testParseThenSerialize(myParser, 'bar')).toThrow()
50+
* ```
51+
*
52+
* @param parser The parser to test
53+
* @param input A query string to test against
54+
*/
55+
export function testParseThenSerialize<T>(
56+
parser: ParserBuilder<T>,
57+
input: string
58+
) {
59+
const parsed = parser.parse(input)
60+
if (parsed === null) {
61+
throw new Error(
62+
`[nuqs] testParseThenSerialize: parsed value is null (when parsing ${input})`
63+
)
64+
}
65+
const serialized = parser.serialize(parsed)
66+
if (serialized !== input) {
67+
throw new Error(
68+
`[nuqs] parser is not bijective (in testParseThenSerialize)
69+
Expected query: '${input}'
70+
Received query: '${serialized}'
71+
Parsed value: ${parsed}
72+
`
73+
)
74+
}
75+
}

packages/nuqs/testing.d.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// This file is needed for projects that have `moduleResolution` set to `node`
2+
// in their tsconfig.json to be able to `import {} from 'nuqs/testing'`.
3+
// Other module resolutions strategies will look for the `exports` in `package.json`,
4+
// but with `node`, TypeScript will look for a .d.ts file with that name at the
5+
// root of the package.
6+
7+
export * from './dist/testing'

packages/nuqs/tsup.config.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ const entrypoints = {
3434
'adapters/testing': 'src/adapters/testing.ts'
3535
},
3636
server: {
37-
server: 'src/index.server.ts'
37+
server: 'src/index.server.ts',
38+
testing: 'src/testing.ts'
3839
}
3940
}
4041

0 commit comments

Comments
 (0)