개발 기술

[Vue3] slot 테스트 코드 작성시 알아야 할 4가지 포인트(with. vue/test-utils)

by GicoMomg 2025. 4. 13.

0. 들어가며

  • Vue에서는 slot 기능을 사용해 컴포넌트에 콘텐츠를 주입할 수 있다.
  • 이 기능은 재사용성이 높은 컴포넌트를 만드는 데 매우 유용하며, 하나의 컴포넌트 내에서 여러 형태의 콘텐츠를 유연하게 표현할 수 있다.
  • 특히, 버튼, 카드, 모달 창 등 다양한 UI 컴포넌트에 slot을 적용하면, 외부에서 원하는 콘텐츠를 자유롭게 주입할 수 있어 확장성과 커스터마이징에 큰 도움이 된다.
  • 한 예로, slot을 사용한 컴포넌트의 예시를 살펴보자:
<!-- MySlotComponent.vue -->
<template>
  <div class="my-slot-component">
    <h2>Slot 사용 예시</h2>
    <div class="content">
      <slot>
        기본 콘텐츠
      </slot>
    </div>
  </div>
</template>

 

  • 이 컴포넌트는 부모에서 slot을 통해 전달한 콘텐츠를 렌더링한다.
  • 부모 컴포넌트에서는 아래와 같이 사용할 수 있다:
<!-- ParentComponent.vue -->
<template>
  <div>
    <MySlotComponent>
      <p>여기에 사용자가 원하는 콘텐츠를 주입합니다.</p>
    </MySlotComponent>
  </div>
</template>

 

  • MySlotComponent 내의 <slot> 태그는 부모가 전달한 <p> 태그 콘텐츠로 대체되며,
  • 이를 통해 동일한 컴포넌트를 다양한 상황에서 재사용할 수 있다.
  • 하지만, 이처럼 유연하고 다양한 콘텐츠를 주입할 수 있는 slot 을 테스트 코드로 작성할 때는 몇 가지 주의해야 할 포인트가 있다.
  • 이번 시간에는 Vue 3Vue Test Utils를 기반으로 slot 컴포넌트의 테스트 코드 작성 시 주의해야 할 사항을 살펴보고자 한다. 🙂



1. slot 테스트할 때 알아야 할 4가지 포인트

1) shallowMount 대신 mount 사용하기

Vue Test Utils는 테스트 환경에서 컴포넌트를 렌더링하는 2가지 방법(shallowMount, mount)을 제공한다.

  • shallowMount는 컴포넌트의 최상위 레벨만 렌더링하고, 자식 컴포넌트는 자동으로 stub 처리한다.
    • 여기서 stub은 자식 컴포넌트의 실제 구현은 실행하지 않고, 해당 컴포넌트의 기본적인 구조만 간단히 표시하는 역할을 한다.
    • 그래서 불필요한 자식 컴포넌트의 렌더링 비용을 절감할 수 있다.
    • 이 방법은 단위 테스트의 범위를 좁혀 최상위 컴포넌트만 검증해야하는 상황에서 적합하다.
  • mount는 컴포넌트와 그 하위의 모든 자식 컴포넌트를 실제로 렌더링한다.
    • 이 방법은 전체 컴포넌트 구조가 DOM에 어떻게 표현되는지 확인할 때 유리하다.
    • 단, 자식 컴포넌트의 테스트도 별도로 작성해야 한다.
  • slot 테스트에서 shallowMount를 사용하면, 내부 자식 컴포넌트가 stub 처리되기에 slot으로 전달한 실제 콘텐츠가 온전히 렌더링되지 않을 수 있다.
  • 따라서 slot 테스트에서는 mount를 사용해야 한다.

 

(1) shallowMount를 사용할 경우

  • shallowMount의 경우, 내부 자식 컴포넌트가 stub 처리되기에 slot 영역을 검증할 수 없다.
import { shallowMount } from '@vue/test-utils'
import SlotExample from '@/components/SlotExample.vue'

test('shallowMount로 slot 테스트 시 문제 발생 예', () => {
  const wrapper = shallowMount(SlotExample, {
    slots: {
      default: '<div>기본 슬롯 내용</div>'
    }
  })
  expect(wrapper.html()).toContain('기본 슬롯 내용') // ❌ 검증 실패
  })

 

(2) mount를 사용할 경우

  • mount를 사용하면 전체 컴포넌트가 렌더링되므로, slot에 주입된 컨텐츠도 검증할 수 있다.
import { mount } from '@vue/test-utils'
import SlotExample from '@/components/SlotExample.vue'

test('mount로 slot 테스트', () => {
  const wrapper = mount(SlotExample, {
    slots: {
      default: '<div>기본 슬롯 내용</div>'
    }
  })
  expect(wrapper.html()).toContain('기본 슬롯 내용') // ✅ 검증 성공
})



2) 불필요한 자식 컴포넌트는 stubs로 처리하기

(1) mount 사용시 문제점

  • 하나의 컴포넌트에 slot과 여러 자식 컴포넌트(예: Calendar, Tabs 등)가 포함된 상황을 가정하자.
  • 이 상태에서 mount로 컴포넌트를 렌더링하면, 테스트 대상 컴포넌트의 모든 자식 컴포넌트가 실제로 렌더링된다.
import { mount } from '@vue/test-utils'
import ParentWithSlots from '@/components/ParentWithSlots.vue'

test('mount로 모든 자식 컴포넌트와 slot 콘텐츠를 함께 렌더링', () => {
  const wrapper = mount(ParentWithSlots, {
    slots: {
      default: '<div>슬롯 내용</div>'
    }
  })

  // 부모 컴포넌트에 slot으로 전달된 콘텐츠가 렌더링된다.
  expect(wrapper.html()).toContain('슬롯 내용')

  // 실제 Calendar와 Tabs 컴포넌트가 모두 렌더링되어 불필요한 테스트 대상이 된다.
  // 예를 들어, Calendar 컴포넌트는 '.calendar' 클래스로 구분되어 있다.
  expect(wrapper.find('.calendar').exists()).toBe(true)

  // Tabs 컴포넌트 역시 '.tabs' 클래스로 렌더링된다.
  expect(wrapper.find('.tabs').exists()).toBe(true)
})
  • 하지만 만약 자식 컴포넌트를 이미 별도의 테스트 코드로 검증한 상태라면, 자식 컴포넌트까지 렌더링할 필요가 없다.
  • 그럼 어떻게 해야 테스트 범위를 좁힐 수 있을까? stub을 사용하면 된다!

 

(2) stub를 사용한 경우

  • 검증 범위를 벗어나는 자식 컴포넌트를 stubs로 대체하면, 테스트 대상 컴포넌트의 핵심 기능(예: slot 콘텐츠 렌더링)에 집중할 수 있다.
  • 아래와 같이, 자식 컴포넌트(예: ChildComponentA와 ChildComponentB)를 stub 처리하여, 테스트 범위를 좁힐 수 있다.
import { mount } from '@vue/test-utils'
import ParentWithSlots from '@/components/ParentWithSlots.vue'

test('slot 테스트 - 자식 컴포넌트는 stubs 처리', () => {
  const wrapper = mount(ParentWithSlots, {
    slots: {
      default: '<div>슬롯 내용</div>'
    },
    global: {
      stubs: {
        // ChildComponentA는 실제 복잡한 로직을 호출하지 않고 
        // 간단하게 <p>Stubbed Child A</p>로 대체할 수 있다.
        ChildComponentA: {
          template: '<p>Stubbed Child A</p>'
        },

        // 혹은, 단순히 태그만 표기하도록 stub 처리할 수 있다.
        ChildComponentB: true
      }
    }
  })

  // slot으로 주입한 콘텐츠가 제대로 노출되는지 확인한다.
  expect(wrapper.html()).toContain('슬롯 내용')

  // stub 처리된 ChildComponentA가 간단한 템플릿으로 렌더링되었는지 검증한다.
  expect(wrapper.html()).toContain('Stubbed Child A')

  // ChildComponentB는 stub 처리되어 <child-component-b-stub> 태그로 확인된다.
  expect(wrapper.find('child-component-b-stub').exists()).toBe(true)
})

 

(3) stubs 사용시 주의사항

  • 불필요한 자식 컴포넌트를 stubs로 대체하면 테스트가 깔끔해지고, 검증해야 할 핵심 기능에 집중할 수 있다.
  • 단, stubs로 대체된 자식 컴포넌트는 실제 컴포넌트의 템플릿, 데이터, 메서드, 라이프사이클 훅 등 모든 내부 로직이 동작하지 않는다.
  • 따라서, 특정 자식 컴포넌트의 기능 자체를 검증해야 한다면, 해당 컴포넌트만을 mount하여 독립적으로 테스트해야 한다.



3) 가상 컴포넌트로 다양한 slot 주입하기

  • vue에서 제공하는 slot에는 default slot, named slot, scoped slot 총 3가지가 존재한다.
  • 기본 slot
    • slot 이름을 지정하지 않고 콘텐츠를 주입하는 기본적인 방식이다.
    • 주입한 콘텐츠가 그대로 렌더링되는지 확인하는 것이 핵심이다.
  • named slot
    • slot의 이름을 지정하여 여러 영역에 개별 콘텐츠를 주입할 수 있다.
    • 각각의 named slot에 올바른 콘텐츠가 전달되는지, 해당 영역이 렌더링되는지 검증하는 것이 중요하다.
  • scoped slot
    • 부모가 slot에 데이터를 전달하고, 자식 slot 콘텐츠가 그 데이터를 활용하는 방식이다.
    • 전달된 데이터(slot props)를 이용해 동적으로 렌더링되는 내용을 테스트해야 한다.
  • 각 슬롯별 테스트 검증 방법은 다음과 같다.

(1) 기본 slot 테스트

  • 기본 slot은 별도의 이름 없이 부모가 전달하는 콘텐츠가 렌더링되는지 확인해야 한다.
  • 아래 예시는 기본 slot을 사용하여 <div>기본 슬롯 내용</div>가 제대로 렌더링되는지 테스트한 경우이다.
import { mount } from '@vue/test-utils'
import SlotExample from '@/components/SlotExample.vue'

test('기본 slot 테스트', () => {
  const wrapper = mount(SlotExample, {
    slots: {
      default: '<div>기본 슬롯 내용</div>'
    }
  })
  expect(wrapper.html()).toContain('기본 슬롯 내용')
})
  • 부모가 기본 slot에 콘텐츠를 주입하면, SlotExample 컴포넌트 내에서 <slot> 에 해당 콘텐츠가 렌더링되는지를 확인한다.

 

(2) named slot 테스트

  • named slot은 컴포넌트 내부에 이름이 정해진 슬롯이 하나 이상 존재한다.
  • 아래 예시는 headerfooter라는 이름의 슬롯에 콘텐츠가 올바르게 렌더링되는지 검사한 경우이다.
import { mount } from '@vue/test-utils'
import NamedSlotExample from '@/components/NamedSlotExample.vue'

test('named slot 테스트', () => {
  const wrapper = mount(NamedSlotExample, {
    slots: {
      header: '<h1>헤더 슬롯</h1>',
      footer: '<footer>푸터 슬롯</footer>'
    }
  })
  expect(wrapper.find('h1').text()).toBe('헤더 슬롯')
  expect(wrapper.find('footer').text()).toBe('푸터 슬롯')
})
  • 각 named slot에 주입한 콘텐츠가 해당 영역에 올바르게 표시되는지, 그리고 특정 영역의 텍스트가 렌더링되었는지 검증한다.

 

(3) scoped slot 테스트

  • scoped slot은 slot에 전달되는 데이터를 기반으로 콘텐츠를 렌더링할 때 사용한다.
  • 부모는 slot에 데이터를 전달하고, 해당 데이터를 이용해 자식이 콘텐츠를 가공해서 보여준다.
  • 아래 예시는 ListComponentitems 배열을 prop으로 받고,
  • 각 아이템을 slot prop으로 전달하여 대문자로 변환해 렌더링하는 경우이다.
<!-- ListComponent.vue -->

<template>
  <ul>
    <li v-for="(item, index) in props.items" :key="index">
      <!-- slot에 item을 전달하는 scoped slot -->
      <slot name="item" :item="item">{{ item }}</slot>
    </li>
  </ul>
</template>

<script setup>
const props = defineProps({
  items: {
    type: Array,
    default: () => []
  }
})
</script>
// 테스트 코드 

import { mount } from '@vue/test-utils'
import ListComponent from '@/components/ListComponent.vue'

test('scoped slot 테스트: 각 item에 대한 커스텀 렌더링', () => {
  const items = ['Apple', 'Banana', 'Cherry']
  const wrapper = mount(ListComponent, {
    props: { items },
    slots: {
      // slot 콘텐츠 내부에서 slot prop으로 전달된 item 데이터를 이용해 대문자로 변환하여 렌더링
      item: `<template #item="{ item }">
               <span class="item-text">{{ item.toUpperCase() }}</span>
             </template>`
    }
  })

  const renderedItems = wrapper.findAll('.item-text').wrappers.map(w => w.text())
  expect(renderedItems).toEqual(['APPLE', 'BANANA', 'CHERRY'])
})
  • 이 예시에서는 부모로부터 전달받은 items 배열의 각 값이 slot prop으로 넘어와, 해당 값을 가공하여 대문자로 렌더링하는지를 확인한다.
  • scoped slot 테스트의 핵심은 전달받은 데이터를 활용해 원하는 로직(여기서는 대문자 변환)이 정상적으로 작동하는지 검증하는 데 있다.



4) slot 조건부 렌더링 테스트하기

  • slot으로 콘텐츠를 주입하는 것 외에도 조건에 따라 콘텐츠를 표시하거나 숨기는 조건부 렌더링을 적용할 수 있다.
  • 이 경우, 조건 변화에 따라 slot 콘텐츠의 표시 여부가 올바르게 동작하는지 확인해야 한다.
  • Vue의 반응형 업데이트 특성상, DOM이 변경된 후 결과를 확인하기 위해 nextTick을 사용해야 한다.

(1) 예시 살펴보기

  • 아래 예시에서는 slot 콘텐츠에 v-if를 적용해 조건부 렌더링을 구현했다.
  • 그래서 테스트코드에서는 조건(show)이 false이므로 slot 콘텐츠가 렌더링되지 않다가,
  • 데이터 변경 후에는 DOM 업데이트를 거쳐 slot 콘텐츠가 출력되는지를 검증해야한다.
import { mount } from '@vue/test-utils'
import ConditionalSlotExample from '@/components/ConditionalSlotExample.vue'
import { nextTick } from 'vue'

test('조건부 slot 렌더링 테스트', async () => {
  const wrapper = mount(ConditionalSlotExample, {
    slots: {
      default: '<div v-if="show">조건부 슬롯 내용</div>'
    },
    data() {
      return { show: false }
    }
  })

  // 초기 상태에서는 조건이 false이므로 slot 내용이 렌더링되지 않는다.
  expect(wrapper.html()).not.toContain('조건부 슬롯 내용')

  // 데이터 변경 후 DOM 업데이트를 기다린다.
  wrapper.setData({ show: true })
  await nextTick()

  // 조건 변경 후 slot 내용이 렌더링되었는지 확인한다.
  expect(wrapper.html()).toContain('조건부 슬롯 내용')
})



2. 마치며…

이번 시간에는 Vue Test Utils와 Vitest를 사용해 slot 기능을 테스트할 때 주의해야 할 주요 사항들을 정리해보았다.

핵심 요점을 다시 정리하면 다음과 같다.

  • mount 사용의 중요성
    • shallowMount를 사용할 경우 자식 컴포넌트가 stub 처리되어, slot에 전달된 콘텐츠가 완전히 렌더링되지 않을 수 있다.
    • 따라서 slot 테스트에서는 반드시 mount를 사용해 전체 DOM 구조에서 콘텐츠가 올바르게 삽입되었는지를 확인해야 한다.
  • 불필요한 자식 컴포넌트 관리
    • slot 테스트의 초점은 부모 컴포넌트의 렌더링 결과에 있으므로,
    • 이미 별도로 테스트한 자식 컴포넌트는 stubs 옵션을 통해 단순화하는 것이 테스트의 효율성과 가독성을 높인다.
  • 다양한 slot 유형 검증
    • 기본 slot은 콘텐츠가 제대로 주입되어 렌더링되는지를 확인한다.
    • named slot은 각각의 영역에 콘텐츠가 제대로 배치되는지를 검증한다.
    • scoped slot은 전달받은 데이터를 기반으로 콘텐츠가 동적으로 잘 변환되는지를 확인한다.
  • slot 조건부 렌더링
    • v-if, v-show와 같은 조건에 따라 slot 콘텐츠가 렌더링되는지 테스트하며,
    • 상태 변화 후 nextTick을 사용해 DOM 업데이트가 반영되었는지까지 확인한다.

이 외에도, 각 테스트 간 독립성을 보장하기 위해 beforeEachafterEach를 활용해 wrapper를 매번 재생성하거나 상태를 초기화하는 습관이 필요하다.
이러한 테스트는 귀찮고 반복적인 작업처럼 느껴질 수 있지만, 작은 컴포넌트 하나하나의 신뢰도를 쌓는 과정이 결국 프로젝트 전체의 완성도를 높이는 길이다.

slot 테스트도 예외는 아니다. 필요한 부분을 정확히 검증하고, 불필요한 부분은 stub 처리하며, 각 케이스를 명확히 분리해서 테스트를 구성하는 습관을 들여보는 건 어떨까? 🙂



반응형

댓글