Skip to content

Commit 99209d0

Browse files
authored
feat(stubs): allow to stub directives (fixes #1800) (#1804)
This adds stubbing directives functionality to @vue/test-utils Fixes #1800
1 parent 22c7698 commit 99209d0

File tree

11 files changed

+385
-65
lines changed

11 files changed

+385
-65
lines changed

docs/guide/advanced/stubs-shallow-mount.md

+74-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Stubs and Shallow Mount
22

3-
Vue Test Utils provides some advanced features for _stubbing_ components. A _stub_ is where you replace an existing implementation of a custom component with a dummy component that doesn't do anything at all, which can simplify an otherwise complex test. Let's see an example.
3+
Vue Test Utils provides some advanced features for _stubbing_ components and directives. A _stub_ is where you replace an existing implementation of a custom component or directive with a dummy one that doesn't do anything at all, which can simplify an otherwise complex test. Let's see an example.
44

55
## Stubbing a single child component
66

@@ -238,6 +238,78 @@ test('stubs async component with resolving', async () => {
238238
})
239239
```
240240

241+
## Stubbing a directive
242+
243+
Sometimes directives do quite complex things, like perform a lot of DOM manipulation which might result in errors in your tests (due to JSDOM not resembling entire DOM behavior). A common example is tooltip directives from various libraries, which usually rely heavily on measuring DOM nodes position/sizes.
244+
245+
In this example, we have another `<App>` that renders a message with tooltip
246+
247+
```js
248+
// tooltip directive declared somewhere, named `Tooltip`
249+
250+
const App = {
251+
directives: {
252+
Tooltip
253+
},
254+
template: '<h1 v-tooltip title="Welcome tooltip">Welcome to Vue.js 3</h1>'
255+
}
256+
```
257+
258+
We do not want the `Tooltip` directive code to be executed in this test, we just want to assert the message is rendered. In this case, we could use the `stubs`, which appears in the `global` mounting option passing `vTooltip`.
259+
260+
```js
261+
test('stubs component with custom template', () => {
262+
const wrapper = mount(App, {
263+
global: {
264+
stubs: {
265+
vTooltip: true
266+
}
267+
}
268+
})
269+
270+
console.log(wrapper.html())
271+
// <h1>Welcome to Vue.js 3</h1>
272+
273+
expect(wrapper.html()).toContain('Welcome to Vue.js 3')
274+
})
275+
```
276+
277+
::: tip
278+
Usage of `vCustomDirective` naming scheme to differentiate between components and directives is inspired by [same approach](https://vuejs.org/api/sfc-script-setup.html#using-custom-directives) used in `<script setup>`
279+
:::
280+
281+
Sometimes, we need a part of directive functionality (usually because some code relies on it). Let's assume our directive adds `with-tooltip` CSS class when executed and this is important behavior for our code. In this case we can swap `true` with our mock directive implementation
282+
283+
```js
284+
test('stubs component with custom template', () => {
285+
const wrapper = mount(App, {
286+
global: {
287+
stubs: {
288+
vTooltip: {
289+
beforeMount(el: Element) {
290+
console.log('directive called')
291+
el.classList.add('with-tooltip')
292+
}
293+
}
294+
}
295+
}
296+
})
297+
298+
// 'directive called' logged to console
299+
300+
console.log(wrapper.html())
301+
// <h1 class="with-tooltip">Welcome to Vue.js 3</h1>
302+
303+
expect(wrapper.classes('with-tooltip')).toBe(true)
304+
})
305+
```
306+
307+
We've just swapped our directive implementation with our own one!
308+
309+
::: warning
310+
Stubbing directives won't work on functional components or `<script setup>` due to lack of directive name inside of [withDirectives](https://vuejs.org/api/render-function.html#withdirectives) function. Consider mocking directive module via your testing framework if you need to mock directive used in functional component. See https://github.com/vuejs/core/issues/6887 for proposal to unlock such functionality
311+
:::
312+
241313
## Default Slots and `shallow`
242314

243315
Since `shallow` stubs out all the content of a components, any `<slot>` won't get rendered when using `shallow`. While this is not a problem in most cases, there are some scenarios where this isn't ideal.
@@ -314,6 +386,6 @@ So regardless of which mounting method you choose, we suggest keeping these guid
314386

315387
## Conclusion
316388

317-
- use `global.stubs` to replace a component with a dummy one to simplify your tests
389+
- use `global.stubs` to replace a component or directive with a dummy one to simplify your tests
318390
- use `shallow: true` (or `shallowMount`) to stub out all child components
319391
- use `global.renderStubDefaultSlot` to render the default `<slot>` for a stubbed component

src/mount.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030

3131
import { MountingOptions, Slot } from './types'
3232
import {
33+
getComponentsFromStubs,
34+
getDirectivesFromStubs,
3335
isFunctionalComponent,
3436
isObject,
3537
isObjectComponent,
@@ -41,15 +43,16 @@ import { attachEmitListener } from './emit'
4143
import { createVNodeTransformer } from './vnodeTransformers/util'
4244
import {
4345
createStubComponentsTransformer,
44-
addToDoNotStubComponents,
45-
registerStub
46+
addToDoNotStubComponents
4647
} from './vnodeTransformers/stubComponentsTransformer'
48+
import { createStubDirectivesTransformer } from './vnodeTransformers/stubDirectivesTransformer'
4749
import {
4850
isLegacyFunctionalComponent,
4951
unwrapLegacyVueExtendComponent
5052
} from './utils/vueCompatSupport'
5153
import { trackInstance } from './utils/autoUnmount'
5254
import { createVueWrapper } from './wrapperFactory'
55+
import { registerStub } from './stubs'
5356

5457
// NOTE this should come from `vue`
5558
const MOUNT_OPTIONS: Array<keyof MountingOptions<any>> = [
@@ -338,7 +341,10 @@ export function mount(
338341
}
339342

340343
addToDoNotStubComponents(component)
344+
// We've just replaced our component with its copy
345+
// Let's register it as a stub so user can find it
341346
registerStub({ source: originalComponent, stub: component })
347+
342348
const el = document.createElement('div')
343349

344350
if (options?.attachTo) {
@@ -532,9 +538,12 @@ export function mount(
532538
createVNodeTransformer({
533539
transformers: [
534540
createStubComponentsTransformer({
535-
stubs: global.stubs,
541+
stubs: getComponentsFromStubs(global.stubs),
536542
shallow: options?.shallow,
537543
renderStubDefaultSlot: global.renderStubDefaultSlot
544+
}),
545+
createStubDirectivesTransformer({
546+
directives: getDirectivesFromStubs(global.stubs)
538547
})
539548
]
540549
})
@@ -551,7 +560,7 @@ export function mount(
551560
// ref: https://github.com/vuejs/test-utils/issues/249
552561
// ref: https://github.com/vuejs/test-utils/issues/425
553562
if (global?.stubs) {
554-
for (const name of Object.keys(global.stubs)) {
563+
for (const name of Object.keys(getComponentsFromStubs(global.stubs))) {
555564
if (!app.component(name)) {
556565
app.component(name, { name })
557566
}

src/stubs.ts

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Component } from 'vue'
2+
3+
// Stubbing occurs when in vnode transformer we're swapping
4+
// component vnode type due to stubbing either component
5+
// or directive on component
6+
7+
// In order to be able to find components we need to track pairs
8+
// stub --> original component
9+
10+
// Having this as global might feel unsafe at first point
11+
// One can assume that sharing stub map across mounts might
12+
// lead to false matches, however our vnode mappers always
13+
// produce new nodeTypes for each mount even if you're reusing
14+
// same stub, so we're safe and do not need to pass these stubs
15+
// for each mount operation
16+
const stubs: WeakMap<Component, Component> = new WeakMap()
17+
18+
export function registerStub({
19+
source,
20+
stub
21+
}: {
22+
source: Component
23+
stub: Component
24+
}) {
25+
stubs.set(stub, source)
26+
}
27+
28+
export function getOriginalComponentFromStub(
29+
stub: Component
30+
): Component | undefined {
31+
return stubs.get(stub)
32+
}

src/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export interface MountingOptions<Props, Data = {}> {
8686
shallow?: boolean
8787
}
8888

89-
export type Stub = boolean | Component
89+
export type Stub = boolean | Component | Directive
9090
export type Stubs = Record<string, Stub> | Array<string>
9191
export type GlobalMountOptions = {
9292
/**

src/utils.ts

+44-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import { GlobalMountOptions, RefSelector } from './types'
2-
import { ComponentOptions, ConcreteComponent, FunctionalComponent } from 'vue'
1+
import { GlobalMountOptions, RefSelector, Stub, Stubs } from './types'
2+
import {
3+
Component,
4+
ComponentOptions,
5+
ConcreteComponent,
6+
Directive,
7+
FunctionalComponent
8+
} from 'vue'
39
import { config } from './config'
410

511
function mergeStubs(target: Record<string, any>, source: GlobalMountOptions) {
@@ -143,3 +149,39 @@ export function isRefSelector(
143149
): selector is RefSelector {
144150
return typeof selector === 'object' && 'ref' in selector
145151
}
152+
153+
export function convertStubsToRecord(stubs: Stubs) {
154+
if (Array.isArray(stubs)) {
155+
// ['Foo', 'Bar'] => { Foo: true, Bar: true }
156+
return stubs.reduce((acc, current) => {
157+
acc[current] = true
158+
return acc
159+
}, {} as Record<string, Stub>)
160+
}
161+
162+
return stubs
163+
}
164+
165+
const isDirectiveKey = (key: string) => key.match(/^v[A-Z].*/)
166+
167+
export function getComponentsFromStubs(
168+
stubs: Stubs
169+
): Record<string, Component | boolean> {
170+
const normalizedStubs = convertStubsToRecord(stubs)
171+
172+
return Object.fromEntries(
173+
Object.entries(normalizedStubs).filter(([key]) => !isDirectiveKey(key))
174+
) as Record<string, Component | boolean>
175+
}
176+
177+
export function getDirectivesFromStubs(
178+
stubs: Stubs
179+
): Record<string, Directive | true> {
180+
const normalizedStubs = convertStubsToRecord(stubs)
181+
182+
return Object.fromEntries(
183+
Object.entries(normalizedStubs)
184+
.filter(([key, value]) => isDirectiveKey(key) && value !== false)
185+
.map(([key, value]) => [key.substring(1), value])
186+
) as Record<string, Directive>
187+
}

src/utils/find.ts

+6-7
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,8 @@ import {
55
VNodeNormalizedChildren,
66
VNodeTypes
77
} from 'vue'
8+
import { getOriginalComponentFromStub } from '../stubs'
89
import { FindAllComponentsSelector } from '../types'
9-
import {
10-
getOriginalStubFromSpecializedStub,
11-
getOriginalVNodeTypeFromStub
12-
} from '../vnodeTransformers/stubComponentsTransformer'
1310
import { isComponent } from '../utils'
1411
import { matchName } from './matchName'
1512
import { unwrapLegacyVueExtendComponent } from './vueCompatSupport'
@@ -45,11 +42,13 @@ export function matches(
4542

4643
const nodeTypeCandidates: VNodeTypes[] = [
4744
nodeType,
48-
getOriginalVNodeTypeFromStub(nodeType),
49-
getOriginalStubFromSpecializedStub(nodeType)
45+
getOriginalComponentFromStub(nodeType)
5046
].filter(Boolean) as VNodeTypes[]
5147

52-
if (nodeTypeCandidates.includes(selector)) {
48+
// our selector might be a stub itself
49+
const target = getOriginalComponentFromStub(selector) ?? selector
50+
51+
if (nodeTypeCandidates.includes(target)) {
5352
return true
5453
}
5554

0 commit comments

Comments
 (0)