Skip to content

Commit

Permalink
feat: add support of arbitrary mounting point via attachTo option
Browse files Browse the repository at this point in the history
This allows for users to specify where in the document their component
should attach, either through a CSS selector string or a provided
HTMLElement. This option is passed through directly to the vm.$mount
method that is called as part of mount.js. This enables testing of SSR
code with Vue test utils as well as rendering of applications via
vue-test-utils in contexts that aren't entirely Vue

fixes vuejs#1492
  • Loading branch information
jnields committed Apr 9, 2020
1 parent 0c4a811 commit 80275fa
Show file tree
Hide file tree
Showing 10 changed files with 151 additions and 8 deletions.
5 changes: 4 additions & 1 deletion docs/api/mount.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,13 @@ import Foo from './Foo.vue'

describe('Foo', () => {
it('renders a div', () => {
const div = document.createElement('div')
document.body.appendChild(div)
const wrapper = mount(Foo, {
attachToDocument: true
attachTo: div
})
expect(wrapper.contains('div')).toBe(true)
wrapper.destroy()
})
})
```
Expand Down
32 changes: 31 additions & 1 deletion docs/api/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ These options will be merged with the component's existing options when mounted
- [`stubs`](#stubs)
- [`mocks`](#mocks)
- [`localVue`](#localvue)
- [`attachTo`](#attachto)
- [`attachToDocument`](#attachtodocument)
- [`propsData`](#propsdata)
- [`attrs`](#attrs)
Expand Down Expand Up @@ -288,12 +289,41 @@ const wrapper = mount(Component, {
expect(wrapper.vm.$route).toBeInstanceOf(Object)
```

## attachTo

- type: `HTMLElement | string`
- default: `null`

This either specifies a specific HTMLElement or CSS selector string targeting an
HTMLElement, to which your component will be fully mounted in the document.

When attaching to the DOM, you should call `wrapper.destroy()` at the end of your test to
remove the rendered elements from the document and destroy the component instance.

```js
const Component = {
template: '<div>ABC</div>',
props: ['msg']
}
let wrapper = mount(Component, {
attachTo: '#root'
})
expect(wrapper.vm.$el).to.not.be.null
wrapper.destroy()

wrapper = mount(Component, {
attachTo: document.getElementById('root')
})
expect(wrapper.vm.$el.parentNode).to.not.be.null
wrapper.destroy()
```

## attachToDocument

- type: `boolean`
- default: `false`

Component will be attached to DOM when rendered if set to `true`.
Like [`attachTo`](#attachto), but automatically creates a new `div` element for you and inserts it into the body. This is deprecated in favor of [`attachTo`](#attachto).

When attaching to the DOM, you should call `wrapper.destroy()` at the end of your test to
remove the rendered elements from the document and destroy the component instance.
Expand Down
6 changes: 5 additions & 1 deletion docs/api/shallowMount.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- `{Component} component`
- `{Object} options`
- `{HTMLElement|string} string`
- `{boolean} attachToDocument`
- `{Object} context`
- `{Array<Component|Object>|Component} children`
Expand Down Expand Up @@ -64,10 +65,13 @@ import Foo from './Foo.vue'

describe('Foo', () => {
it('renders a div', () => {
const div = document.createElement('div')
document.body.appendChild(div)
const wrapper = shallowMount(Foo, {
attachToDocument: true
attachTo: div
})
expect(wrapper.contains('div')).toBe(true)
wrapper.destroy()
})
})
```
Expand Down
2 changes: 1 addition & 1 deletion docs/api/wrapper/destroy.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ mount({
expect(spy.calledOnce).toBe(true)
```

if `attachToDocument` was set to `true` when mounted, the component DOM elements will
if either the `attachTo` or `attachToDocument` option caused the component to mount to the document, the component DOM elements will
also be removed from the document.

For functional components, `destroy` only removes the rendered DOM elements from the document.
2 changes: 2 additions & 0 deletions flow/options.flow.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
declare type Options = {
// eslint-disable-line no-undef
attachToDocument?: boolean,
attachTo?: HTMLElement | string,
propsData?: Object,
mocks?: Object,
methods?: { [key: string]: Function },
Expand All @@ -17,6 +18,7 @@ declare type Options = {
}

declare type NormalizedOptions = {
attachTo?: HTMLElement | string,
attachToDocument?: boolean,
propsData?: Object,
mocks: Object,
Expand Down
20 changes: 18 additions & 2 deletions packages/shared/validate-options.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {
isPlainObject,
isFunctionalComponent,
isConstructor
isConstructor,
isDomSelector,
isHTMLElement
} from './validators'
import { VUE_VERSION } from './consts'
import { compileTemplateForSlots } from './compile-template'
import { throwError } from './util'
import { throwError, warn } from './util'
import { validateSlots } from './validate-slots'

function vueExtendUnsupportedOption(option) {
Expand All @@ -22,6 +24,20 @@ function vueExtendUnsupportedOption(option) {
const UNSUPPORTED_VERSION_OPTIONS = ['mocks', 'stubs', 'localVue']

export function validateOptions(options, component) {
if (
options.attachTo &&
!isHTMLElement(options.attachTo) &&
!isDomSelector(options.attachTo)
) {
throwError(
`options.attachTo should be a valid HTMLElement or CSS selector string`
)
}
if ('attachToDocument' in options) {
warn(
`options.attachToDocument is deprecated in favor of options.attachTo and will be removed in a future release`
)
}
if (options.parentComponent && !isPlainObject(options.parentComponent)) {
throwError(
`options.parentComponent should be a valid Vue component options object`
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ export function isPlainObject(c: any): boolean {
return Object.prototype.toString.call(c) === '[object Object]'
}

export function isHTMLElement(c: any): boolean {
if (typeof HTMLElement === 'undefined') {
return false
}
// eslint-disable-next-line no-undef
return c instanceof HTMLElement
}

export function isRequiredComponent(name: string): boolean {
return (
name === 'KeepAlive' || name === 'Transition' || name === 'TransitionGroup'
Expand Down
5 changes: 3 additions & 2 deletions packages/test-utils/src/mount.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,16 @@ export default function mount(component, options = {}) {

const parentVm = createInstance(component, mergedOptions, _Vue)

const el = options.attachToDocument ? createElement() : undefined
const el =
options.attachTo || (options.attachToDocument ? createElement() : undefined)
const vm = parentVm.$mount(el)

component._Ctor = {}

throwIfInstancesThrew(vm)

const wrapperOptions = {
attachedToDocument: !!mergedOptions.attachToDocument
attachedToDocument: !!el
}

const root = parentVm.$options._isFunctionalContainer
Expand Down
2 changes: 2 additions & 0 deletions test/specs/mount.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ describeRunIf(process.env.TEST_ENV !== 'node', 'mount', () => {
expect(wrapper.vm.$options.context).to.equal(undefined)
expect(wrapper.vm.$options.attrs).to.equal(undefined)
expect(wrapper.vm.$options.listeners).to.equal(undefined)
wrapper.destroy()
})

itDoNotRunIf(vueVersion < 2.3, 'injects store correctly', () => {
Expand Down Expand Up @@ -366,6 +367,7 @@ describeRunIf(process.env.TEST_ENV !== 'node', 'mount', () => {
' <p class="prop-2"></p>\n' +
'</div>'
)
wrapper.destroy()
})

it('overwrites the component options with the instance options', () => {
Expand Down
77 changes: 77 additions & 0 deletions test/specs/mounting-options/attachTo.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describeWithShallowAndMount } from '~resources/utils'

const innerHTML = '<input><span>Hello world</span>'
const outerHTML = `<div id="attach-to">${innerHTML}</div>`
const ssrHTML = `<div id="attach-to" data-server-rendered="true">${innerHTML}</div>`
const template = '<div id="attach-to"><input /><span>Hello world</span></div>'
const TestComponent = { template }

describeWithShallowAndMount('options.attachTo', mountingMethod => {
it('should not mount to document when null', () => {
const wrapper = mountingMethod(TestComponent, {})
expect(wrapper.vm.$el.parentNode).to.be.null
wrapper.destroy()
})
it('attaches to a provided HTMLElement', () => {
const div = document.createElement('div')
div.id = 'root'
document.body.appendChild(div)
expect(document.getElementById('root')).to.not.be.null
expect(document.getElementById('attach-to')).to.be.null
const wrapper = mountingMethod(TestComponent, {
attachTo: div
})

const root = document.getElementById('root')
const rendered = document.getElementById('attach-to')
expect(wrapper.vm.$el.parentNode).to.not.be.null
expect(root).to.be.null
expect(rendered).to.not.be.null
expect(rendered.outerHTML).to.equal(outerHTML)
expect(wrapper.options.attachedToDocument).to.equal(true)
wrapper.destroy()
expect(document.getElementById('attach-to')).to.be.null
})
it('attaches to a provided CSS selector string', () => {
const div = document.createElement('div')
div.id = 'root'
document.body.appendChild(div)
expect(document.getElementById('root')).to.not.be.null
expect(document.getElementById('attach-to')).to.be.null
const wrapper = mountingMethod(TestComponent, {
attachTo: '#root'
})

const root = document.getElementById('root')
const rendered = document.getElementById('attach-to')
expect(wrapper.vm.$el.parentNode).to.not.be.null
expect(root).to.be.null
expect(rendered).to.not.be.null
expect(rendered.outerHTML).to.equal(outerHTML)
expect(wrapper.options.attachedToDocument).to.equal(true)
wrapper.destroy()
expect(document.getElementById('attach-to')).to.be.null
})

it('correctly hydrates markup', () => {
expect(document.getElementById('attach-to')).to.be.null

const div = document.createElement('div')
div.id = 'attach-to'
div.setAttribute('data-server-rendered', 'true')
div.innerHTML = innerHTML
document.body.appendChild(div)
expect(div.outerHTML).to.equal(ssrHTML)
const wrapper = mountingMethod(TestComponent, {
attachTo: '#attach-to'
})

const rendered = document.getElementById('attach-to')
expect(wrapper.vm.$el.parentNode).to.not.be.null
expect(rendered).to.not.be.null
expect(rendered.outerHTML).to.equal(outerHTML)
expect(wrapper.options.attachedToDocument).to.equal(true)
wrapper.destroy()
expect(document.getElementById('attach-to')).to.be.null
})
})

0 comments on commit 80275fa

Please sign in to comment.