Skip to content

Commit 3236ceb

Browse files
authored
Add makeStub testing util (#459)
1 parent 164e40a commit 3236ceb

File tree

2 files changed

+187
-0
lines changed

2 files changed

+187
-0
lines changed

src/util/testing-utils.ts

+67
Original file line numberDiff line numberDiff line change
@@ -508,4 +508,71 @@ export const mockWebSocketProvider = (provider: typeof WebSocketClassProvider):
508508
// Need to disable typing, the mock-socket impl does not implement the ws interface fully
509509
provider.set(MockWebSocket as any) // eslint-disable-line @typescript-eslint/no-explicit-any
510510
}
511+
512+
// Wraps an object such that accessing properties that do not exist will throw
513+
// an error instead of returning undefined.
514+
//
515+
// This is useful during unit test creation, as it makes it clear which
516+
// properties are missing rather than giving an error later on that undefined
517+
// doesn't have some other property.
518+
//
519+
// Once a test is passing, you can remove the wrapping and the test will still
520+
// pass. But it might be useful to keep it for when the code under test is
521+
// changed later on.
522+
//
523+
// Example usage:
524+
//
525+
// In code under test:
526+
//
527+
// function doThing(obj) {
528+
// const baz = obj.foo.baz
529+
// ...
530+
// }
531+
//
532+
// In test code:
533+
//
534+
// const obj = makeStub('obj', { foo: { bar: 1 } })
535+
// doThing(obj)
536+
//
537+
// Throws (and logs, in case the error is caught by the code under test):
538+
// Error: Property 'obj.foo.baz' does not exist
539+
export function makeStub<T>(name: string, obj: T): T {
540+
if (obj === null || typeof obj !== 'object') {
541+
return obj
542+
}
543+
return new Proxy(obj, {
544+
get: (target, prop) => {
545+
const propName = `${name}.${String(prop)}`
546+
if (!(prop in target) && !isStubPropAllowedUndefined(prop)) {
547+
const message = `Property '${propName}' does not exist`
548+
// eslint-disable-next-line no-console
549+
console.error(message)
550+
throw new Error(message)
551+
}
552+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
553+
return makeStub(propName, (target as any)[prop])
554+
},
555+
}) as T
556+
}
557+
558+
// Properties checked by jest which don't need to be defined:
559+
export const allowedUndefinedStubProps = [
560+
'$$typeof',
561+
'nodeType',
562+
'tagName',
563+
'hasAttribute',
564+
'@@__IMMUTABLE_ITERABLE__@@',
565+
'@@__IMMUTABLE_RECORD__@@',
566+
'toJSON',
567+
'asymmetricMatch',
568+
'then',
569+
]
570+
571+
const isStubPropAllowedUndefined = (prop: string | symbol) => {
572+
if (typeof prop === 'symbol') {
573+
return true
574+
}
575+
return allowedUndefinedStubProps.includes(prop as string)
576+
}
577+
511578
/* c8 ignore stop */ // eslint-disable-line

test/util/testing-utils.test.ts

+120
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import test from 'ava'
2+
import { allowedUndefinedStubProps, makeStub } from '../../src/util/testing-utils'
3+
4+
test('make a stub', async (t) => {
5+
const stub = makeStub('stub', {
6+
name: 'stub-name',
7+
count: 5,
8+
})
9+
10+
t.is(stub.name, 'stub-name')
11+
t.is(stub.count, 5)
12+
})
13+
14+
test('make a stub with nested fields', async (t) => {
15+
const stub = makeStub('stub', {
16+
name: 'stub-name',
17+
nested: {
18+
count: 5,
19+
},
20+
})
21+
22+
t.is(stub.name, 'stub-name')
23+
t.is(stub.nested.count, 5)
24+
})
25+
26+
test('accessing an absent field should throw an error', async (t) => {
27+
const stub = makeStub('stub', {
28+
name: 'stub-name',
29+
nested: {
30+
count: 5,
31+
},
32+
})
33+
34+
t.throws(
35+
() => {
36+
// @ts-expect-error intended
37+
t.is(stub.count, undefined)
38+
},
39+
{
40+
message: "Property 'stub.count' does not exist",
41+
},
42+
)
43+
})
44+
45+
test('accessing a nested absent field should throw an error', async (t) => {
46+
const stub = makeStub('stub', {
47+
name: 'stub-name',
48+
nested: {
49+
count: 5,
50+
},
51+
})
52+
53+
t.throws(
54+
() => {
55+
// @ts-expect-error intended
56+
t.is(stub.nested.name, undefined)
57+
},
58+
{
59+
message: "Property 'stub.nested.name' does not exist",
60+
},
61+
)
62+
})
63+
64+
test('fields used by jest are allowed to be undefined', async (t) => {
65+
const stub = makeStub('stub', {
66+
name: 'stub-name',
67+
count: 5,
68+
})
69+
70+
// @ts-expect-error intended
71+
t.is(stub.nodeType, undefined)
72+
// @ts-expect-error intended
73+
t.is(stub.tagName, undefined)
74+
})
75+
76+
test('Symbol props are allowed to be undefined', async (t) => {
77+
const stub = makeStub('stub', {
78+
name: 'stub-name',
79+
count: 5,
80+
})
81+
82+
// @ts-expect-error intended
83+
t.is(stub[Symbol('my symbol')], undefined)
84+
})
85+
86+
test('allowedUndefinedStubProps can be extended and restored', async (t) => {
87+
const customProp = 'myCustomProp'
88+
89+
const stub = makeStub('stub', {
90+
name: 'stub-name',
91+
count: 5,
92+
})
93+
94+
t.throws(
95+
() => {
96+
// @ts-expect-error intended
97+
t.is(stub[customProp], undefined)
98+
},
99+
{
100+
message: "Property 'stub.myCustomProp' does not exist",
101+
},
102+
)
103+
104+
allowedUndefinedStubProps.push('myCustomProp')
105+
106+
// @ts-expect-error intended
107+
t.is(stub[customProp], undefined)
108+
109+
allowedUndefinedStubProps.pop()
110+
111+
t.throws(
112+
() => {
113+
// @ts-expect-error intended
114+
t.is(stub[customProp], undefined)
115+
},
116+
{
117+
message: "Property 'stub.myCustomProp' does not exist",
118+
},
119+
)
120+
})

0 commit comments

Comments
 (0)