Skip to content

Commit

Permalink
feat(stubs): allow to stub directives (fixes #1800) (#1804)
Browse files Browse the repository at this point in the history
This adds stubbing directives functionality to @vue/test-utils

Fixes #1800
  • Loading branch information
xanf committed Oct 17, 2022
1 parent 22c7698 commit 99209d0
Show file tree
Hide file tree
Showing 11 changed files with 385 additions and 65 deletions.
76 changes: 74 additions & 2 deletions docs/guide/advanced/stubs-shallow-mount.md
@@ -1,6 +1,6 @@
# Stubs and Shallow Mount

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.
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.

## Stubbing a single child component

Expand Down Expand Up @@ -238,6 +238,78 @@ test('stubs async component with resolving', async () => {
})
```

## Stubbing a directive

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.

In this example, we have another `<App>` that renders a message with tooltip

```js
// tooltip directive declared somewhere, named `Tooltip`

const App = {
directives: {
Tooltip
},
template: '<h1 v-tooltip title="Welcome tooltip">Welcome to Vue.js 3</h1>'
}
```

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`.

```js
test('stubs component with custom template', () => {
const wrapper = mount(App, {
global: {
stubs: {
vTooltip: true
}
}
})

console.log(wrapper.html())
// <h1>Welcome to Vue.js 3</h1>

expect(wrapper.html()).toContain('Welcome to Vue.js 3')
})
```

::: tip
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>`
:::

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

```js
test('stubs component with custom template', () => {
const wrapper = mount(App, {
global: {
stubs: {
vTooltip: {
beforeMount(el: Element) {
console.log('directive called')
el.classList.add('with-tooltip')
}
}
}
}
})

// 'directive called' logged to console

console.log(wrapper.html())
// <h1 class="with-tooltip">Welcome to Vue.js 3</h1>

expect(wrapper.classes('with-tooltip')).toBe(true)
})
```

We've just swapped our directive implementation with our own one!

::: warning
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
:::

## Default Slots and `shallow`

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.
Expand Down Expand Up @@ -314,6 +386,6 @@ So regardless of which mounting method you choose, we suggest keeping these guid

## Conclusion

- use `global.stubs` to replace a component with a dummy one to simplify your tests
- use `global.stubs` to replace a component or directive with a dummy one to simplify your tests
- use `shallow: true` (or `shallowMount`) to stub out all child components
- use `global.renderStubDefaultSlot` to render the default `<slot>` for a stubbed component
17 changes: 13 additions & 4 deletions src/mount.ts
Expand Up @@ -30,6 +30,8 @@ import {

import { MountingOptions, Slot } from './types'
import {
getComponentsFromStubs,
getDirectivesFromStubs,
isFunctionalComponent,
isObject,
isObjectComponent,
Expand All @@ -41,15 +43,16 @@ import { attachEmitListener } from './emit'
import { createVNodeTransformer } from './vnodeTransformers/util'
import {
createStubComponentsTransformer,
addToDoNotStubComponents,
registerStub
addToDoNotStubComponents
} from './vnodeTransformers/stubComponentsTransformer'
import { createStubDirectivesTransformer } from './vnodeTransformers/stubDirectivesTransformer'
import {
isLegacyFunctionalComponent,
unwrapLegacyVueExtendComponent
} from './utils/vueCompatSupport'
import { trackInstance } from './utils/autoUnmount'
import { createVueWrapper } from './wrapperFactory'
import { registerStub } from './stubs'

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

addToDoNotStubComponents(component)
// We've just replaced our component with its copy
// Let's register it as a stub so user can find it
registerStub({ source: originalComponent, stub: component })

const el = document.createElement('div')

if (options?.attachTo) {
Expand Down Expand Up @@ -532,9 +538,12 @@ export function mount(
createVNodeTransformer({
transformers: [
createStubComponentsTransformer({
stubs: global.stubs,
stubs: getComponentsFromStubs(global.stubs),
shallow: options?.shallow,
renderStubDefaultSlot: global.renderStubDefaultSlot
}),
createStubDirectivesTransformer({
directives: getDirectivesFromStubs(global.stubs)
})
]
})
Expand All @@ -551,7 +560,7 @@ export function mount(
// ref: https://github.com/vuejs/test-utils/issues/249
// ref: https://github.com/vuejs/test-utils/issues/425
if (global?.stubs) {
for (const name of Object.keys(global.stubs)) {
for (const name of Object.keys(getComponentsFromStubs(global.stubs))) {
if (!app.component(name)) {
app.component(name, { name })
}
Expand Down
32 changes: 32 additions & 0 deletions src/stubs.ts
@@ -0,0 +1,32 @@
import { Component } from 'vue'

// Stubbing occurs when in vnode transformer we're swapping
// component vnode type due to stubbing either component
// or directive on component

// In order to be able to find components we need to track pairs
// stub --> original component

// Having this as global might feel unsafe at first point
// One can assume that sharing stub map across mounts might
// lead to false matches, however our vnode mappers always
// produce new nodeTypes for each mount even if you're reusing
// same stub, so we're safe and do not need to pass these stubs
// for each mount operation
const stubs: WeakMap<Component, Component> = new WeakMap()

export function registerStub({
source,
stub
}: {
source: Component
stub: Component
}) {
stubs.set(stub, source)
}

export function getOriginalComponentFromStub(
stub: Component
): Component | undefined {
return stubs.get(stub)
}
2 changes: 1 addition & 1 deletion src/types.ts
Expand Up @@ -86,7 +86,7 @@ export interface MountingOptions<Props, Data = {}> {
shallow?: boolean
}

export type Stub = boolean | Component
export type Stub = boolean | Component | Directive
export type Stubs = Record<string, Stub> | Array<string>
export type GlobalMountOptions = {
/**
Expand Down
46 changes: 44 additions & 2 deletions src/utils.ts
@@ -1,5 +1,11 @@
import { GlobalMountOptions, RefSelector } from './types'
import { ComponentOptions, ConcreteComponent, FunctionalComponent } from 'vue'
import { GlobalMountOptions, RefSelector, Stub, Stubs } from './types'
import {
Component,
ComponentOptions,
ConcreteComponent,
Directive,
FunctionalComponent
} from 'vue'
import { config } from './config'

function mergeStubs(target: Record<string, any>, source: GlobalMountOptions) {
Expand Down Expand Up @@ -143,3 +149,39 @@ export function isRefSelector(
): selector is RefSelector {
return typeof selector === 'object' && 'ref' in selector
}

export function convertStubsToRecord(stubs: Stubs) {
if (Array.isArray(stubs)) {
// ['Foo', 'Bar'] => { Foo: true, Bar: true }
return stubs.reduce((acc, current) => {
acc[current] = true
return acc
}, {} as Record<string, Stub>)
}

return stubs
}

const isDirectiveKey = (key: string) => key.match(/^v[A-Z].*/)

export function getComponentsFromStubs(
stubs: Stubs
): Record<string, Component | boolean> {
const normalizedStubs = convertStubsToRecord(stubs)

return Object.fromEntries(
Object.entries(normalizedStubs).filter(([key]) => !isDirectiveKey(key))
) as Record<string, Component | boolean>
}

export function getDirectivesFromStubs(
stubs: Stubs
): Record<string, Directive | true> {
const normalizedStubs = convertStubsToRecord(stubs)

return Object.fromEntries(
Object.entries(normalizedStubs)
.filter(([key, value]) => isDirectiveKey(key) && value !== false)
.map(([key, value]) => [key.substring(1), value])
) as Record<string, Directive>
}
13 changes: 6 additions & 7 deletions src/utils/find.ts
Expand Up @@ -5,11 +5,8 @@ import {
VNodeNormalizedChildren,
VNodeTypes
} from 'vue'
import { getOriginalComponentFromStub } from '../stubs'
import { FindAllComponentsSelector } from '../types'
import {
getOriginalStubFromSpecializedStub,
getOriginalVNodeTypeFromStub
} from '../vnodeTransformers/stubComponentsTransformer'
import { isComponent } from '../utils'
import { matchName } from './matchName'
import { unwrapLegacyVueExtendComponent } from './vueCompatSupport'
Expand Down Expand Up @@ -45,11 +42,13 @@ export function matches(

const nodeTypeCandidates: VNodeTypes[] = [
nodeType,
getOriginalVNodeTypeFromStub(nodeType),
getOriginalStubFromSpecializedStub(nodeType)
getOriginalComponentFromStub(nodeType)
].filter(Boolean) as VNodeTypes[]

if (nodeTypeCandidates.includes(selector)) {
// our selector might be a stub itself
const target = getOriginalComponentFromStub(selector) ?? selector

if (nodeTypeCandidates.includes(target)) {
return true
}

Expand Down

0 comments on commit 99209d0

Please sign in to comment.