Skip to content

Commit 87cd29e

Browse files
committed
Add makeStub testing util
1 parent 164e40a commit 87cd29e

2 files changed

Lines changed: 185 additions & 0 deletions

File tree

src/util/testing-utils.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,4 +508,69 @@ 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, target: T): T {
540+
if (target === null || typeof target !== 'object') {
541+
return target
542+
}
543+
return new Proxy(target, {
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+
console.error(message)
549+
throw new Error(message)
550+
}
551+
return makeStub(propName, (target as any)[prop])
552+
},
553+
}) as T
554+
}
555+
556+
// Properties checked by jest which don't need to be defined:
557+
export let allowedUndefinedStubProps = [
558+
'$$typeof',
559+
'nodeType',
560+
'tagName',
561+
'hasAttribute',
562+
'@@__IMMUTABLE_ITERABLE__@@',
563+
'@@__IMMUTABLE_RECORD__@@',
564+
'toJSON',
565+
'asymmetricMatch',
566+
'then',
567+
]
568+
569+
const isStubPropAllowedUndefined = (prop: string | Symbol) => {
570+
if (typeof prop === 'symbol') {
571+
return true
572+
}
573+
return allowedUndefinedStubProps.includes(prop as string)
574+
}
575+
511576
/* c8 ignore stop */ // eslint-disable-line

test/util/testing-utils.test.ts

Lines changed: 120 additions & 0 deletions
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-ignore
37+
stub.count
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-ignore
56+
stub.nested.name
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-ignore
71+
t.is(stub.nodeType, undefined)
72+
// @ts-ignore
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-ignore
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-ignore
97+
stub[customProp]
98+
},
99+
{
100+
message: "Property 'stub.myCustomProp' does not exist",
101+
},
102+
)
103+
104+
allowedUndefinedStubProps.push('myCustomProp')
105+
106+
// @ts-ignore
107+
t.is(stub[customProp], undefined)
108+
109+
allowedUndefinedStubProps.pop()
110+
111+
t.throws(
112+
() => {
113+
// @ts-ignore
114+
stub[customProp]
115+
},
116+
{
117+
message: "Property 'stub.myCustomProp' does not exist",
118+
},
119+
)
120+
})

0 commit comments

Comments
 (0)