Vue Test Utils — attachTo

vue test utils - attachTo

Что делает attachTo?

attachTo позволяет монтировать компонент в реальный DOM-элемент, а не в изолированную песочницу. Это критически важно для тестирования поведения, которое зависит от реальной DOM-структуры. По умолчанию Vue Test Utils не подключает компонент к document.body а просто изолирует в некой обертке.

Зачем это нужно? Основные случаи использования:

  • Тестирование модальных окон, тултипов, dropdown-меню
  • Тестирование поведения с position: fixed
  • Тестирование фокуса и событий клавиатуры
  • Тестирование с getBoundingClientRect()

Важно:

  • Очистка после тестов (Если не очищать, элементы накапливаются в DOM)
  • Использование уникального элемента (Лучше создавать отдельный элемент для каждого теста)

Компонент (Modal.vue):

<template>
    <Teleport to="body">
        <div v-if="isOpen" class="modal-overlay" @click="close">
            <div class="modal-content" @click.stop>
                <p>Это модальное окно</p>
                <button @click.stop="close">Закрыть</button>
            </div>
        </div>
    </Teleport>
</template>

<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';

const props = defineProps({
    isOpen: {
        type: Boolean,
        default: false
    }
});

const emit = defineEmits(['close']);
const close = () => {
    emit('close');
};
</script>

<style scoped>
.modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;
    /* Убедитесь, что поверх других элементов */
}

.modal-content {
    background: white;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    text-align: center;
    /* Стили для содержимого модального окна */
}
</style>

Компонент App.vue

<script setup lang="ts">
import { ref } from 'vue';
import Modal from './components/ModalOutside.vue';
const isOpen = ref(false);
</script>

<template>
  <div>
    <button @click="isOpen = true">Открыть модалку</button>

    <Modal :isOpen="isOpen" @close="isOpen = false" />
  </div>
</template>

В начале надо очищать весь наш виртуальный body, чтобы избежать влияния одного теста на другой.

Может возникнуть вопрос, почему использовать attachTo нужно при teleport to="body"?

Когда Vue встречает <Teleport to="body">, он пытается переместить содержимое в document.body. Но:

  • Если компонент не подключён к DOM, то document.body не существует в тестовой среде как настоящий DOM-узел.
  • Vue Test Utils не подключает компонент к document.body по умолчанию — он живёт в "изолированной" обёртке.
  • Без attachTo Teleport не сможет найти document.body и ничего не переместит.

Задание:

Создайте тест для компонента Modal.vue.

Используйте опцию attachTo при вызове mount, чтобы прикрепить компонент к document.body. В данном примере teleport вынесет модальное окно в другое место, потому нам и надо использовать attachTo.

Убедитесь, что когда isOpen равно true, модальное окно отображается (.not.toBeNull()), а когда isOpen равно false, оно не отображается (.toBeNull()). Не забудьте вызвать wrapper.unmount() после теста

Решение

import { mount } from "@vue/test-utils";
import { expect, test } from "vitest";
import ModalOutside from '@/components/ModalOutside.vue';


afterEach(() => {
    document.body.innerHTML = ''
})

test('ModalOutside проверка на существование при пропс = true', () => {
    const div = document.createElement('div');
    document.body.appendChild(div);

    const wrapper = mount(ModalOutside, {
        props: {
            isOpen: true
        },
        attachTo: div
    });

    expect(document.querySelector('.modal-content')).not.toBeNull();
    wrapper.unmount();

});

test('ModalOutside проверка на существование при пропс = false', () => {
    const div = document.createElement('div');
    document.body.appendChild(div);

    const wrapper = mount(ModalOutside, {
        props: {
            isOpen: false
        },
        attachTo: div
    });

    expect(document.querySelector('.modal-content')).toBeNull();
    wrapper.unmount();

})