Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(stubs): Allow to stub directives (fixes #1800) #1804

Merged
merged 3 commits into from Oct 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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)
xanf marked this conversation as resolved.
Show resolved Hide resolved
})
```

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 `config.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()
cexbrayat marked this conversation as resolved.
Show resolved Hide resolved

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 @@ -137,3 +143,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