Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const config = {
},
{ text: 'Testing Vuex', link: '/guide/advanced/vuex' },
{ text: 'Testing Vue Router', link: '/guide/advanced/vue-router' },
{ text: 'Testing Teleport', link: '/guide/advanced/teleport' },
{
text: 'Third-party integration',
link: '/guide/advanced/third-party'
Expand Down
189 changes: 189 additions & 0 deletions docs/guide/advanced/teleport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# Testing Teleport

Vue 3 comes with a new built-in component: `<Teleport>`, which allows components to "teleport" their content far outside of their own `<template>`. Most tests written with Vue Test Utils are scoped to the component passed to `mount`, which introduces some complexity when it comes to testing a component that is teleported outside of the component where it is initially rendered.

Here are some strategies and techniques for testing components using `<Teleport>`.

## Example

In this example we are testing a `<Navbar>` component. It renders a `<Sigup>` component inside of a `<Teleport>`. The `target` prop of `<Teleport>` is an element located outside of the `<Navbar>` component.

This is the `Navbar.vue` component:

```vue
<template>
<Teleport to="#modal">
<Signup />
</Teleport>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import Signup from './Signup.vue'

export default defineComponent({
components: {
Signup
}
})
</script>
```

It simply teleports a `<Signup>` somewhere else. It's simple for the purpose of this example.

`Signup.vue` is a form that validates if `username` is greater than 8 characters. If it is, when submitted, it emits a `signup` event with the `username` as the payload. Testing that will be our goal.

```vue
<template>
<div>
<form @submit.prevent="submit">
<input v-model="username" />
</form>
</div>
</template>

<script>
export default {
emits: ['signup'],
data() {
return {
username: ''
}
},
computed: {
error() {
return this.username.length < 8
}
},
methods: {
submit() {
if (!this.error) {
this.$emit('signup', this.username)
}
}
}
}
</script>
```

## Mounting the Component

Starting with a minimal test:

```ts
import { mount } from '@vue/test-utils'
import Navbar from './Navbar.vue'
import Signup from './Signup.vue'

test('emits a signup event when valid', async () => {
const wrapper = mount(Navbar)
})
```

Running this test will give you a warning: `[Vue warn]: Failed to locate Teleport target with selector "#modal"`. Let's create it:

```ts {5-15}
import { mount } from '@vue/test-utils'
import Navbar from './Navbar.vue'
import Signup from './Signup.vue'

beforeEach(() => {
// create teleport target
const el = document.createElement('div')
el.id = 'modal'
document.body.appendChild(el)
})

afterEach(() => {
// clean up
document.body.outerHTML = ''
})

test('teleport', async () => {
const wrapper = mount(Navbar)
})
```

We are using Jest for this example, which does not reset the DOM every test. For this reason, it's good to clean up after each test with `afterEach`.

## Interacting with the Teleported Component

The next thing to do is fill out the username input. Unfortunately, we cannot use `wrapper.find('input')`. Why not? A quick `console.log(wrapper.html())` shows us:

```html
<!--teleport start-->
<!--teleport end-->
```

We see some comments used by Vue to handle `<Teleport>` - but no `<input>`. That's because the `<Signup>` component (and its HTML) are not rendered inside of `<Navbar>` anymore - it was teleported outside.

Although the actual HTML is teleported outside, it turns out the Virtual DOM associated with `<Navbar>` maintains a reference to the original component. This means you can use `getComponent` and `findComponent, which operate on the Virtual DOM, not the regular DOM.

```ts {12}
beforeEach(() => {
// ...
})

afterEach(() => {
// ...
})

test('teleport', async () => {
const wrapper = mount(Navbar)

wrapper.getComponent(Signup) // got it!
})
```

`getComponent` returns a `VueWrapper`. Now you can use methods like `get`, `find` and `trigger`.

Let's finish the test:

```ts {4-8}
test('teleport', async () => {
const wrapper = mount(Navbar)

const signup = wrapper.getComponent(Signup)
await signup.get('input').setValue('valid_username')
await signup.get('form').trigger('submit.prevent')

expect(signup.emitted().signup[0]).toEqual(['valid_username'])
})
```

It passes!

The full test:

```ts
import { mount } from '@vue/test-utils'
import Navbar from './Navbar.vue'
import Signup from './Signup.vue'

beforeEach(() => {
// create teleport target
const el = document.createElement('div')
el.id = 'modal'
document.body.appendChild(el)
})

afterEach(() => {
// clean up
document.body.outerHTML = ''
})

test('teleport', async () => {
const wrapper = mount(Navbar)

const signup = wrapper.getComponent(Signup)
await signup.get('input').setValue('valid_username')
await signup.get('form').trigger('submit.prevent')

expect(signup.emitted().signup[0]).toEqual(['valid_username'])
})
```

## Conclusion

- Create a teleport target with `document.createElement`.
- Find teleported components using `getComponent` or `findComponent` which operate on the Virtual DOM level.
18 changes: 18 additions & 0 deletions tests/components/EmitsEvent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<template>
<button @click="greet" />
</template>

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
emits: ['greet'],
setup(props, { emit }) {
return {
greet: () => {
emit('greet', 'Hey!')
}
}
}
})
</script>
9 changes: 9 additions & 0 deletions tests/components/WithTeleportEmitsComp.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<template>
<teleport to="#somewhere">
<emits-event msg="hi there" />
</teleport>
</template>

<script setup lang="ts">
import EmitsEvent from './EmitsEvent.vue'
</script>
9 changes: 9 additions & 0 deletions tests/components/WithTeleportPropsComp.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<template>
<teleport to="#somewhere">
<with-props msg="hi there" />
</teleport>
</template>

<script setup lang="ts">
import WithProps from './WithProps.vue'
</script>
16 changes: 16 additions & 0 deletions tests/docs-examples/Navbar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<template>
<Teleport to="#modal">
<Signup />
</Teleport>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import Signup from './Signup.vue'

export default defineComponent({
components: {
Signup
}
})
</script>
30 changes: 30 additions & 0 deletions tests/docs-examples/Signup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<template>
<div>
<form @submit.prevent="submit">
<input v-model="username" />
</form>
</div>
</template>

<script>
export default {
emits: ['signup'],
data() {
return {
username: ''
}
},
computed: {
error() {
return this.username.length < 8
}
},
methods: {
submit() {
if (!this.error) {
this.$emit('signup', this.username)
}
}
}
}
</script>
25 changes: 25 additions & 0 deletions tests/docs-examples/teleport.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { mount } from '../../src'
import Navbar from './Navbar.vue'
import Signup from './Signup.vue'

beforeEach(() => {
// create teleport target
const el = document.createElement('div')
el.id = 'modal'
document.body.appendChild(el)
})

afterEach(() => {
// clean up
document.body.outerHTML = ''
})

test('teleport', async () => {
const wrapper = mount(Navbar)

const signup = wrapper.getComponent(Signup)
await signup.get('input').setValue('valid_username')
await signup.get('form').trigger('submit.prevent')

expect(signup.emitted().signup[0]).toEqual(['valid_username'])
})
Loading